Merge branch 'development' of https://github.com/Stremio/stremio-web into refactor/settings

This commit is contained in:
Tim 2025-06-18 11:00:50 +02:00
commit 1b7d618a89
40 changed files with 321 additions and 129 deletions

46
package-lock.json generated
View file

@ -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#8efdffbcf6eeadf01ab658e54adcc6a236b7b10f",
"url": "0.11.4",
"use-long-press": "^3.2.0"
},
@ -68,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",
@ -4815,6 +4816,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",
@ -12526,6 +12540,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",
@ -13402,9 +13433,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#8efdffbcf6eeadf01ab658e54adcc6a236b7b10f",
"integrity": "sha512-b38OjGwlsvFm/aNn/ia18mPxPjZvnI/GaToppn1XaQqCuZuSHxQlYDddwOYTztskWo4VO/IZmCi3UFewqpsqCQ==",
"license": "MIT"
},
"node_modules/string_decoder": {
@ -14005,6 +14036,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

@ -10,7 +10,8 @@
"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",
@ -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#8efdffbcf6eeadf01ab658e54adcc6a236b7b10f",
"url": "0.11.4",
"use-long-press": "^3.2.0"
},
@ -72,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>

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

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

@ -63,7 +63,7 @@ const ColorInput = ({ className, value, onChange, ...props }: Props) => {
};
return [
{
label: 'Select',
label: t('SELECT'),
props: {
'data-autofocus': true,
onClick: selectButtonOnClick
@ -92,7 +92,7 @@ const ColorInput = ({ className, value, onChange, ...props }: 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>
:

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

@ -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,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');
@ -13,6 +13,7 @@ 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 { t } = useTranslation();
const routeFocused = useRouteFocused();
const profile = useProfile();
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
@ -107,12 +108,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 +122,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 +131,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 +146,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}>

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>
:
@ -201,7 +203,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

@ -49,7 +49,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 +60,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 } = require('stremio/services');
@ -14,6 +15,7 @@ const useMetaExtensionTabs = require('./useMetaExtensionTabs');
const styles = require('./styles');
const MetaDetails = ({ urlParams, queryParams }) => {
const { t } = useTranslation();
const { core } = useServices();
const metaDetails = useMetaDetails(urlParams);
const [season, setSeason] = useSeason(urlParams, queryParams);
@ -129,20 +131,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' ?

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

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

@ -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,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 DiscreteSelectInput = ({ className, value, label, disabled, dataset, onChange }) => {
const { t } = useTranslation();
const buttonOnClick = React.useCallback((event) => {
if (typeof onChange === 'function') {
onChange({
@ -22,7 +24,7 @@ const DiscreteSelectInput = ({ className, value, label, disabled, dataset, onCha
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}>
<div className={styles['input-container']} title={disabled ? t('DISABLED_LABEL', { label }) : null}>
<Button className={classnames(styles['button-container'], { 'disabled': disabled })} data-type={'decrement'} onClick={buttonOnClick}>
<Icon className={styles['icon']} name={'remove'} />
</Button>

View file

@ -112,7 +112,7 @@ const General = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
href={'https://stremio.zendesk.com/hc/en-us'}
/>
<Link
label={'Source Code'}
label={'SETTINGS_SOURCE_CODE'}
href={`https://github.com/stremio/stremio-web/tree/${process.env.COMMIT_HASH}`}
/>
<Link
@ -137,8 +137,8 @@ const General = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
href={`https://www.strem.io/reset-password/${profile.auth.user.email}`}
/>
}
<Option className={styles['trakt-container']} icon={'trakt'} label={'Trakt Scrobbling'}>
<Button className={'button'} title={'Authenticate'} disabled={profile.auth === null} tabIndex={-1} onClick={onToggleTrakt}>
<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>

View file

@ -1,5 +1,5 @@
import React, { useCallback, useMemo } from 'react';
import { t } from 'i18next';
import { useTranslation } from 'react-i18next';
import { useServices } from 'stremio/services';
import { Link } from '../../components';
import styles from './User.less';
@ -9,6 +9,7 @@ type Props = {
};
const User = ({ profile }: Props) => {
const { t } = useTranslation();
const { core } = useServices();
const avatar = useMemo(() => (
@ -38,9 +39,9 @@ const User = ({ profile }: Props) => {
style={{ backgroundImage: avatar }}
/>
<div className={styles['email-logout-container']}>
<div className={styles['email-label-container']} title={profile.auth === null ? 'Anonymous user' : profile.auth.user.email}>
<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 ? 'Anonymous user' : profile.auth.user.email}
{profile.auth === null ? t('ANONYMOUS_USER') : profile.auth.user.email}
</div>
</div>
{

View file

@ -1,6 +1,7 @@
import React, { useMemo } from 'react';
import { Option, Section } from '../components';
import { useTranslation } from 'react-i18next';
import { useServices } from 'stremio/services';
import { Option, Section } from '../components';
import styles from './Info.less';
type Props = {
@ -9,6 +10,7 @@ type Props = {
const Info = ({ streamingServer }: Props) => {
const { shell } = useServices();
const { t } = useTranslation();
const settings = useMemo(() => (
streamingServer?.settings?.type === 'Ready' ?
@ -17,19 +19,19 @@ const Info = ({ streamingServer }: Props) => {
return (
<Section className={styles['info']}>
<Option label={'App Vesion'}>
<Option label={t('SETTINGS_APP_VERSION')}>
<div className={styles['label']}>
{process.env.VERSION}
</div>
</Option>
<Option label={'Build Version'}>
<Option label={t('SETTINGS_BUILD_VERSION')}>
<div className={styles['label']}>
{process.env.COMMIT_HASH}
</div>
</Option>
{
settings?.serverVersion &&
<Option label={'Server Version'}>
<Option label={t('SETTINGS_SERVER_VERSION')}>
<div className={styles['label']}>
{settings.serverVersion}
</div>
@ -37,7 +39,7 @@ const Info = ({ streamingServer }: Props) => {
}
{
typeof shell?.transport?.props?.shellVersion === 'string' &&
<Option label={'Shell Version'}>
<Option label={t('SETTINGS_SHELL_VERSION')}>
<div className={styles['label']}>
{shell.transport.props.shellVersion}
</div>

View file

@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
import classNames from 'classnames';
import { t } from 'i18next';
import { useTranslation } from 'react-i18next';
import { useServices } from 'stremio/services';
import { Button } from 'stremio/components';
import { SECTIONS } from '../constants';
@ -13,6 +13,7 @@ type Props = {
};
const Menu = ({ selected, streamingServer, onSelect }: Props) => {
const { t } = useTranslation();
const { shell } = useServices();
const settings = useMemo(() => (
@ -37,21 +38,21 @@ const Menu = ({ selected, streamingServer, onSelect }: Props) => {
<div className={styles['spacing']} />
<div className={styles['version-info-label']} title={process.env.VERSION}>
App Version: {process.env.VERSION}
{t('SETTINGS_APP_VERSION')}: {process.env.VERSION}
</div>
<div className={styles['version-info-label']} title={process.env.COMMIT_HASH}>
Build Version: {process.env.COMMIT_HASH}
{t('SETTINGS_BUILD_VERSION')}: {process.env.COMMIT_HASH}
</div>
{
settings?.serverVersion &&
<div className={styles['version-info-label']} title={settings.serverVersion}>
Server Version: {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}>
Shell Version: {shell.transport.props.shellVersion}
{t('SETTINGS_SHELL_VERSION')}: {shell.transport.props.shellVersion}
</div>
}
</div>

View file

@ -1,9 +1,11 @@
import React, { forwardRef } from 'react';
import { t } from 'i18next';
import { Section, Option } from '../components';
import styles from './Shortcuts.less';
import { useTranslation } from 'react-i18next';
const Shortcuts = forwardRef<HTMLDivElement>((_, ref) => {
const { t } = useTranslation();
return (
<Section ref={ref} label={'SETTINGS_NAV_SHORTCUTS'}>
<Option label={'SETTINGS_SHORTCUT_PLAY_PAUSE'}>

View file

@ -1,5 +1,5 @@
import React, { forwardRef, useCallback } from 'react';
import { t } from 'i18next';
import { useTranslation } from 'react-i18next';
import Icon from '@stremio/stremio-icons/react';
import { Button, MultiselectMenu } from 'stremio/components';
import { useToast } from 'stremio/common';
@ -14,6 +14,7 @@ type Props = {
};
const Streaming = forwardRef<HTMLDivElement, Props>(({ profile, streamingServer }: Props, ref) => {
const { t } = useTranslation();
const toast = useToast();
const {

View file

@ -30,7 +30,7 @@ const URLsManager = () => {
return (
<div className={styles['wrapper']}>
<div className={styles['header']}>
<div className={styles['label']}>URL</div>
<div className={styles['label']}>{t('URL')}</div>
<div className={styles['label']}>{t('STATUS')}</div>
</div>
<div className={styles['content']}>
@ -46,11 +46,11 @@ const URLsManager = () => {
}
</div>
<div className={styles['footer']}>
<Button title={'Add URL'} className={styles['add-url']} onClick={onAdd}>
<Button title={t('SETTINGS_SERVER_ADD_URL')} className={styles['add-url']} onClick={onAdd}>
<Icon name={'add'} className={styles['icon']} />
{t('SETTINGS_SERVER_ADD_URL')}
</Button>
<Button className={styles['reload']} title={'Reload'} onClick={reloadServer}>
<Button className={styles['reload']} title={t('RELOAD')} onClick={reloadServer}>
<Icon name={'reset'} className={styles['icon']} />
<div className={styles['label']}>{t('RELOAD')}</div>
</Button>

View file

@ -165,7 +165,7 @@ const useStreamingOptions = (streamingServer: StreamingServer) => {
return {
options: Object.keys(TORRENT_PROFILES)
.map((profileName) => ({
label: profileName,
label: t('TORRENT_PROFILE_' + profileName.replace(' ', '_').toUpperCase()),
value: JSON.stringify(TORRENT_PROFILES[profileName])
}))
.concat(

107
tests/i18nScan.test.js Normal file
View file

@ -0,0 +1,107 @@
const fs = require('fs');
const path = require('path');
const recast = require('recast');
const babelParser = require('@babel/parser');
const directoryToScan = './src';
function toKey(str) {
return str
.toLowerCase()
.replace(/[^a-z0-9\s]/g, '')
.replace(/\s+/g, '_')
.slice(0, 40);
}
function scanFile(filePath, report) {
try {
const code = fs.readFileSync(filePath, 'utf8');
const ast = babelParser.parse(code, {
sourceType: 'module',
plugins: [
'jsx',
'typescript',
'classProperties',
'objectRestSpread',
'optionalChaining',
'nullishCoalescingOperator',
],
errorRecovery: true,
});
recast.types.visit(ast, {
visitJSXText(path) {
const text = path.node.value.trim();
if (text.length > 1 && /\w/.test(text)) {
const loc = path.node.loc?.start || { line: 0 };
report.push({
file: filePath,
line: loc.line,
string: text,
key: toKey(text),
});
}
this.traverse(path);
},
visitJSXExpressionContainer(path) {
const expr = path.node.expression;
if (
expr.type === 'CallExpression' &&
expr.callee.type === 'Identifier' &&
expr.callee.name === 't'
) {
return false;
}
if (expr.type === 'StringLiteral') {
const parent = path.parentPath.node;
if (parent.type === 'JSXElement') {
const loc = path.node.loc?.start || { line: 0 };
report.push({
file: filePath,
line: loc.line,
string: expr.value,
key: toKey(expr.value),
});
}
}
this.traverse(path);
}
});
} catch (err) {
console.warn(`❌ Skipping ${filePath}: ${err.message}`);
}
}
function walk(dir, report) {
fs.readdirSync(dir).forEach((file) => {
const fullPath = path.join(dir, file);
if (fs.statSync(fullPath).isDirectory()) {
walk(fullPath, report);
} else if (/\.(js|jsx|ts|tsx)$/.test(file)) {
// console.log('📄 Scanning file:', fullPath);
scanFile(fullPath, report);
}
});
}
const report = [];
walk(directoryToScan, report);
if (report.length !== 0) {
describe.each(report)('Missing translation key', (entry) => {
it(`should not have "${entry.string}" in ${entry.file} at line ${entry.line}`, () => {
expect(entry.string).toBeFalsy();
});
});
} else {
describe('Missing translation key', () => {
it('No hardcoded strings found', () => {
expect(true).toBe(true); // or just skip
});
});
}