Merge branch 'development' into feat/season-episode-inputs

This commit is contained in:
Timothy Z. 2025-03-05 12:03:22 +01:00
commit ad260f3ded
47 changed files with 898 additions and 353 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

12
package-lock.json generated
View file

@ -1,18 +1,18 @@
{
"name": "stremio",
"version": "5.0.0-beta.18",
"version": "5.0.0-beta.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stremio",
"version": "5.0.0-beta.18",
"version": "5.0.0-beta.20",
"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": "0.48.5",
"@stremio/stremio-core-web": "0.49.0",
"@stremio/stremio-icons": "5.4.1",
"@stremio/stremio-video": "0.0.53",
"a-color-picker": "1.2.1",
@ -3371,9 +3371,9 @@
"integrity": "sha512-dYlPgu9W/H7c9s1zmW5tiDnRenaUa4Hg1QCyOg1lhOcgSfM/bVTi5nnqX+IfvGTTUNA0zgzh8hI3o3miwnZxTg=="
},
"node_modules/@stremio/stremio-core-web": {
"version": "0.48.5",
"resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.48.5.tgz",
"integrity": "sha512-oDTNBrv8zZi1VGbeV+1Bm6CliI2rF23ERdJpz+gv8EnbFjRIo78WIsoS0yO0EOg8HHXYsFytPq5+c0+YlxmBlA==",
"version": "0.49.0",
"resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.49.0.tgz",
"integrity": "sha512-oxJRVAE6z6Eh1B0qomdz6L2CVaTkwt70kDNC1TmHyGNo+Hhp2RaMlygqBKvBLXyHUXi82R67Mc11gT/JqlmaMw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "7.24.1"

View file

@ -1,7 +1,7 @@
{
"name": "stremio",
"displayName": "Stremio",
"version": "5.0.0-beta.18",
"version": "5.0.0-beta.20",
"author": "Smart Code OOD",
"private": true,
"license": "gpl-2.0",
@ -16,7 +16,7 @@
"@babel/runtime": "7.26.0",
"@sentry/browser": "8.42.0",
"@stremio/stremio-colors": "5.2.0",
"@stremio/stremio-core-web": "0.48.5",
"@stremio/stremio-core-web": "0.49.0",
"@stremio/stremio-icons": "5.4.1",
"@stremio/stremio-video": "0.0.53",
"a-color-picker": "1.2.1",

View file

@ -6,7 +6,7 @@ const { useTranslation } = require('react-i18next');
const { Router } = require('stremio-router');
const { Core, Shell, Chromecast, DragAndDrop, KeyboardShortcuts, ServicesProvider } = require('stremio/services');
const { NotFound } = require('stremio/routes');
const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, CONSTANTS, withCoreSuspender } = require('stremio/common');
const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, CONSTANTS, withCoreSuspender, useShell } = require('stremio/common');
const ServicesToaster = require('./ServicesToaster');
const DeepLinkHandler = require('./DeepLinkHandler');
const SearchParamsHandler = require('./SearchParamsHandler');
@ -20,6 +20,8 @@ const RouterWithProtectedRoutes = withCoreSuspender(withProtectedRoutes(Router))
const App = () => {
const { i18n } = useTranslation();
const shell = useShell();
const [windowHidden, setWindowHidden] = React.useState(false);
const onPathNotMatch = React.useCallback(() => {
return NotFound;
}, []);
@ -97,6 +99,17 @@ const App = () => {
services.chromecast.off('stateChanged', onChromecastStateChange);
};
}, []);
// Handle shell window visibility changed event
React.useEffect(() => {
const onWindowVisibilityChanged = (state) => {
setWindowHidden(state.visible === false && state.visibility === 0);
};
shell.on('win-visibility-changed', onWindowVisibilityChanged);
return () => shell.off('win-visibility-changed', onWindowVisibilityChanged);
}, []);
React.useEffect(() => {
const onCoreEvent = ({ event, args }) => {
switch (event) {
@ -104,6 +117,11 @@ const App = () => {
if (args && args.settings && typeof args.settings.interfaceLanguage === 'string') {
i18n.changeLanguage(args.settings.interfaceLanguage);
}
if (args?.settings?.quitOnClose && windowHidden) {
shell.send('quit');
}
break;
}
}
@ -112,6 +130,10 @@ const App = () => {
if (state && state.profile && state.profile.settings && typeof state.profile.settings.interfaceLanguage === 'string') {
i18n.changeLanguage(state.profile.settings.interfaceLanguage);
}
if (state?.profile?.settings?.quitOnClose && windowHidden) {
shell.send('quit');
}
};
const onWindowFocus = () => {
services.core.transport.dispatch({
@ -146,7 +168,7 @@ const App = () => {
services.core.transport
.getState('ctx')
.then(onCtxState)
.catch((e) => console.error(e));
.catch(console.error);
}
return () => {
if (services.core.active) {
@ -154,7 +176,7 @@ const App = () => {
services.core.transport.off('CoreEvent', onCoreEvent);
}
};
}, [initialized]);
}, [initialized, windowHidden]);
return (
<React.StrictMode>
<ServicesProvider services={services}>

View file

@ -63,6 +63,8 @@
--overlay-color: rgba(255, 255, 255, 0.05);
--modal-background-color: rgba(15, 13, 32, 1);
--outer-glow: 0px 0px 15px rgba(123, 91, 245, 0.37);
--warning-accent-color: rgba(255, 165, 0, 1);
--danger-accent-color: rgba(220, 38, 38, 1);
--border-radius: 0.75rem;
--top-overlay-size: @top-overlay-size;
--bottom-overlay-size: @bottom-overlay-size;

View file

@ -24,6 +24,7 @@ const { default: useShell } = require('./useShell');
const useStreamingServer = require('./useStreamingServer');
const useTorrent = require('./useTorrent');
const useTranslate = require('./useTranslate');
const { default: useOrientation } = require('./useOrientation');
module.exports = {
FileDropProvider,
@ -55,4 +56,5 @@ module.exports = {
useStreamingServer,
useTorrent,
useTranslate,
useOrientation,
};

View file

@ -0,0 +1,34 @@
// Copyright (C) 2017-2025 Smart code 203358507
import { useState, useEffect, useMemo } from 'react';
type DeviceOrientation = 'landscape' | 'portrait';
const useOrientation = () => {
const [windowHeight, setWindowHeight] = useState(window.innerHeight);
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
const orientation: DeviceOrientation = useMemo(() => {
if (windowHeight > windowWidth) {
return 'portrait';
} else {
return 'landscape';
}
}, [windowWidth, windowHeight]);
useEffect(() => {
const handleResize = () => {
setWindowHeight(window.innerHeight);
setWindowWidth(window.innerWidth);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [window.innerWidth, window.innerHeight]);
return orientation;
};
export default useOrientation;

View file

@ -1,21 +1,71 @@
import { useEffect } from 'react';
import EventEmitter from 'eventemitter3';
const SHELL_EVENT_OBJECT = 'transport';
const transport = globalThis?.chrome?.webview;
const events = new EventEmitter();
enum ShellEventType {
SIGNAL = 1,
INVOKE_METHOD = 6,
}
type ShellEvent = {
id: number;
type: ShellEventType;
object: string;
args: string[];
};
const createId = () => Math.floor(Math.random() * 9999) + 1;
const useShell = () => {
const transport = globalThis?.qt?.webChannelTransport;
const on = (name: string, listener: (arg: any) => void) => {
events.on(name, listener);
};
const off = (name: string, listener: (arg: any) => void) => {
events.off(name, listener);
};
const send = (method: string, ...args: (string | number)[]) => {
transport?.send(JSON.stringify({
id: createId(),
type: 6,
object: 'transport',
method: 'onEvent',
args: [method, ...args],
}));
try {
transport?.postMessage(JSON.stringify({
id: createId(),
type: ShellEventType.INVOKE_METHOD,
object: SHELL_EVENT_OBJECT,
method: 'onEvent',
args: [method, ...args],
}));
} catch (e) {
console.error('Shell', 'Failed to send event', e);
}
};
useEffect(() => {
if (!transport) return;
const onMessage = ({ data }: { data: string }) => {
try {
const { type, args } = JSON.parse(data) as ShellEvent;
if (type === ShellEventType.SIGNAL) {
const [methodName, methodArg] = args;
events.emit(methodName, methodArg);
}
} catch (e) {
console.error('Shell', 'Failed to handle event', e);
}
};
transport.addEventListener('message', onMessage);
return () => transport.removeEventListener('message', onMessage);
}, []);
return {
active: !!transport,
send,
on,
off,
};
};

View file

@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom';
import classNames from 'classnames';
import useBinaryState from 'stremio/common/useBinaryState';
import useOrientation from 'stremio/common/useOrientation';
import styles from './BottomSheet.less';
const CLOSE_THRESHOLD = 100;
@ -17,6 +18,7 @@ type Props = {
const BottomSheet = ({ children, title, show, onClose }: Props) => {
const containerRef = useRef<HTMLDivElement>(null);
const orientation = useOrientation();
const [startOffset, setStartOffset] = useState(0);
const [offset, setOffset] = useState(0);
@ -58,6 +60,10 @@ const BottomSheet = ({ children, title, show, onClose }: Props) => {
!opened && onClose();
}, [opened]);
useEffect(() => {
opened && close();
}, [orientation]);
return opened && createPortal((
<div className={styles['bottom-sheet']}>
<div className={styles['backdrop']} onClick={onCloseRequest} />

View file

@ -8,6 +8,7 @@ import styles from './Button.less';
type Props = {
className?: string,
href?: string,
target?: string
title?: string,
disabled?: boolean,
tabIndex?: number,

View file

@ -0,0 +1,83 @@
// Copyright (C) 2017-2025 Smart code 203358507
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
.checkbox {
display: flex;
align-items: center;
overflow: visible;
.label {
display: flex;
flex-direction: row;
align-items: center;
padding: 0.5rem 0;
cursor: pointer;
span {
font-size: 0.9rem;
color: var(--primary-foreground-color);
opacity: 0.6;
}
.link {
font-size: 0.9rem;
color: var(--primary-accent-color);
&:hover {
text-decoration: underline;
}
}
}
.checkbox-container {
position: relative;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.3rem;
background-color: var(--overlay-color);
padding: 0.1rem;
display: flex;
flex: none;
margin: 0 1rem 0 0.3rem;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease-in-out;
cursor: pointer;
outline: none;
user-select: none;
outline-width: var(--focus-outline-size);
outline-color: @color-surface-light5;
outline-offset: 2px;
input[type='checkbox'] {
opacity: 0;
width: 0;
height: 0;
position: absolute;
cursor: pointer;
}
.checkbox-icon {
width: 100%;
height: 100%;
color: var(--primary-foreground-color);
}
&.disabled {
cursor: not-allowed;
}
&.error {
border-color: var(--color-trakt);
}
&.checked {
background-color: var(--primary-accent-color);
}
&:hover, &:focus {
outline-style: solid;
}
}
}

View file

@ -0,0 +1,97 @@
// Copyright (C) 2017-2025 Smart code 203358507
import React, { useCallback, ChangeEvent, KeyboardEvent, RefCallback } from 'react';
import classNames from 'classnames';
import styles from './Checkbox.less';
import Button from '../Button';
import Icon from '@stremio/stremio-icons/react';
type Props = {
ref?: RefCallback<HTMLInputElement>;
name: string;
disabled?: boolean;
checked?: boolean;
className?: string;
label?: string;
link?: string;
href?: string;
onChange?: (props: {
type: string;
checked: boolean;
reactEvent: KeyboardEvent<HTMLInputElement> | ChangeEvent<HTMLInputElement>;
nativeEvent: Event;
}) => void;
error?: string;
};
const Checkbox = React.forwardRef<HTMLInputElement, Props>(({ name, disabled, className, label, href, link, onChange, error, checked }, ref) => {
const handleSelect = useCallback((event: ChangeEvent<HTMLInputElement>) => {
if (!disabled && onChange) {
onChange({
type: 'select',
checked: event.target.checked,
reactEvent: event,
nativeEvent: event.nativeEvent,
});
}
}, [disabled, onChange]);
const onKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
if ((event.key === 'Enter' || event.key === ' ') && !disabled) {
onChange && onChange({
type: 'select',
checked: !checked,
reactEvent: event as KeyboardEvent<HTMLInputElement>,
nativeEvent: event.nativeEvent,
});
}
}, [disabled, checked, onChange]);
return (
<div className={classNames(styles['checkbox'], className)}>
<label className={styles['label']} htmlFor={name}>
<div
className={classNames(
styles['checkbox-container'],
{ [styles['checked']]: checked },
{ [styles['disabled']]: disabled },
{ [styles['error']]: error }
)}
role={'checkbox'}
tabIndex={disabled ? -1 : 0}
aria-checked={checked}
onKeyDown={onKeyDown}
>
<input
ref={ref}
id={name}
type={'checkbox'}
checked={checked}
disabled={disabled}
onChange={handleSelect}
className={styles['input']}
/>
{
checked ?
<Icon name={'checkmark'} className={styles['checkbox-icon']} />
: null
}
</div>
<div>
<span>{label}</span>
{' '}
{
href && link ?
<Button className={styles['link']} href={href} target={'_blank'} tabIndex={-1}>
{link}
</Button>
: null
}
</div>
</label>
</div>
);
});
export default Checkbox;

View file

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

View file

@ -29,8 +29,7 @@
.nav-content-container {
position: absolute;
padding-top: calc(var(--horizontal-nav-bar-size) + var(--safe-area-inset-top));
top: 0;
top: calc(var(--horizontal-nav-bar-size) + var(--safe-area-inset-top));
right: 0;
bottom: 0;
left: var(--vertical-nav-bar-size);
@ -43,7 +42,7 @@
.main-nav-bars-container {
.nav-content-container {
left: 0;
padding-bottom: var(--vertical-nav-bar-size);
bottom: var(--vertical-nav-bar-size);
}
.vertical-nav-bar {

View file

@ -12,15 +12,23 @@ const useProfile = require('stremio/common/useProfile');
const usePWA = require('stremio/common/usePWA');
const useTorrent = require('stremio/common/useTorrent');
const { withCoreSuspender } = require('stremio/common/CoreSuspender');
const useStreamingServer = require('stremio/common/useStreamingServer');
const styles = require('./styles');
const NavMenuContent = ({ onClick }) => {
const { t } = useTranslation();
const { core } = useServices();
const profile = useProfile();
const streamingServer = useStreamingServer();
const { createTorrentFromMagnet } = useTorrent();
const [fullscreen, requestFullscreen, exitFullscreen] = useFullscreen();
const [isIOSPWA, isAndroidPWA] = usePWA();
const streamingServerWarningDismissed = React.useMemo(() => {
return streamingServer.settings !== null && streamingServer.settings.type === 'Ready' || (
!isNaN(profile.settings.streamingServerWarningDismissed.getTime()) &&
profile.settings.streamingServerWarningDismissed.getTime() > Date.now()
);
}, [profile.settings, streamingServer.settings]);
const logoutButtonOnClick = React.useCallback(() => {
core.transport.dispatch({
action: 'Ctx',
@ -38,7 +46,7 @@ const NavMenuContent = ({ onClick }) => {
}
}, []);
return (
<div className={classnames(styles['nav-menu-container'], 'animation-fade-in')} onClick={onClick}>
<div className={classnames(styles['nav-menu-container'], 'animation-fade-in', { [styles['with-warning']]: !streamingServerWarningDismissed } )} onClick={onClick}>
<div className={styles['user-info-container']}>
<div
className={styles['avatar-container']}

View file

@ -7,6 +7,9 @@
popup-menu-container: menu-container;
}
@mobile-height: calc(var(--small-viewport-height) - var(--horizontal-nav-bar-size) - var(--vertical-nav-bar-size));
@height: calc(var(--small-viewport-height) - var(--horizontal-nav-bar-size));
.nav-menu-popup-label {
.popup-menu-container {
margin-top: 1rem;
@ -14,11 +17,15 @@
}
.nav-menu-container {
width: 22rem;
max-height: calc(100vh - var(--horizontal-nav-bar-size) - 1rem);
max-height: calc(@height - 1rem);
overflow-y: auto;
border-radius: var(--border-radius);
background-color: var(--modal-background-color);
&.with-warning {
max-height: calc(@height - 6rem);
}
.user-info-container {
display: flex;
padding: 1.5rem 1rem;
@ -108,6 +115,10 @@
@media only screen and (max-width: @minimum) {
.nav-menu-container {
max-height: calc(100vh - var(--horizontal-nav-bar-size) - var(--vertical-nav-bar-size) - 1rem);
max-height: calc(@mobile-height - 1rem);
&.with-warning {
max-height: calc(@mobile-height - 8.5rem);
}
}
}

View file

@ -13,6 +13,7 @@
background-color: transparent;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
@ -21,6 +22,7 @@
.nav-tab-button {
width: calc(var(--vertical-nav-bar-size) - 1.2rem);
height: calc(var(--vertical-nav-bar-size) - 1.2rem);
min-height: 3.5rem;
}
}

View file

@ -8,7 +8,7 @@ const useAnimationFrame = require('stremio/common/useAnimationFrame');
const useLiveRef = require('stremio/common/useLiveRef');
const styles = require('./styles');
const Slider = ({ className, value, buffered, minimumValue, maximumValue, disabled, onSlide, onComplete }) => {
const Slider = ({ className, value, buffered, minimumValue, maximumValue, disabled, onSlide, onComplete, audioBoost }) => {
const minimumValueRef = useLiveRef(minimumValue !== null && !isNaN(minimumValue) ? minimumValue : 0);
const maximumValueRef = useLiveRef(maximumValue !== null && !isNaN(maximumValue) ? maximumValue : 100);
const valueRef = useLiveRef(value !== null && !isNaN(value) ? Math.min(maximumValueRef.current, Math.max(minimumValueRef.current, value)) : 0);
@ -100,13 +100,16 @@ const Slider = ({ className, value, buffered, minimumValue, maximumValue, disabl
return (
<div ref={sliderContainerRef} className={classnames(className, styles['slider-container'], { 'disabled': disabled })} onMouseDown={onMouseDown}>
<div className={styles['layer']}>
<div className={styles['track']} />
<div className={classnames(styles['track'], { [styles['audio-boost']]: audioBoost })} />
</div>
<div className={styles['layer']}>
<div className={styles['track-before']} style={{ width: `calc(100% * ${bufferedPosition})` }} />
</div>
<div className={styles['layer']}>
<div className={styles['track-after']} style={{ width: `calc(100% * ${thumbPosition})` }} />
<div
className={classnames(styles['track-after'], { [styles['audio-boost']]: audioBoost })}
style={{ '--mask-width': `calc(${thumbPosition} * 100%)` }}
/>
</div>
<div className={styles['layer']}>
<div className={styles['thumb']} style={{ marginLeft: `calc(100% * ${thumbPosition})` }} />
@ -123,7 +126,8 @@ Slider.propTypes = {
maximumValue: PropTypes.number,
disabled: PropTypes.bool,
onSlide: PropTypes.func,
onComplete: PropTypes.func
onComplete: PropTypes.func,
audioBoost: PropTypes.bool
};
module.exports = Slider;

View file

@ -2,6 +2,12 @@
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
@audio-boost-background: linear-gradient(to right,
var(--primary-foreground-color) 0%,
var(--primary-foreground-color) 50%,
var(--warning-accent-color) 75%,
var(--danger-accent-color) 100%) !important;
html.active-slider-within {
cursor: grabbing;
@ -37,9 +43,15 @@ html.active-slider-within {
.track {
z-index: 0;
flex: 1;
width: 100%;
height: var(--track-size);
border-radius: var(--track-size);
background-color: var(--overlay-color);
&.audio-boost {
opacity: 0.3;
background: @audio-boost-background;
}
}
.track-before {
@ -53,9 +65,19 @@ html.active-slider-within {
.track-after {
z-index: 2;
flex: none;
width: 100%;
height: var(--track-size);
border-radius: var(--track-size);
background-color: var(--primary-foreground-color);
mask-image: linear-gradient(to right,
black 0%,
black var(--mask-width),
transparent var(--mask-width)
);
&.audio-boost {
background: @audio-boost-background;
}
}
.thumb {

View file

@ -11,7 +11,7 @@ const useBinaryState = require('stremio/common/useBinaryState');
const VideoPlaceholder = require('./VideoPlaceholder');
const styles = require('./styles');
const Video = ({ className, id, title, thumbnail, episode, released, upcoming, watched, progress, scheduled, deepLinks, onMarkVideoAsWatched, ...props }) => {
const Video = ({ className, id, title, thumbnail, season, episode, released, upcoming, watched, progress, scheduled, seasonWatched, deepLinks, onMarkVideoAsWatched, onMarkSeasonAsWatched, ...props }) => {
const routeFocused = useRouteFocused();
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
const popupLabelOnMouseUp = React.useCallback((event) => {
@ -50,6 +50,12 @@ const Video = ({ className, id, title, thumbnail, episode, released, upcoming, w
closeMenu();
onMarkVideoAsWatched({ id, released }, watched);
}, [id, released, watched]);
const toggleWatchedSeasonOnClick = React.useCallback((event) => {
event.preventDefault();
event.stopPropagation();
closeMenu();
onMarkSeasonAsWatched(season, seasonWatched);
}, [season, seasonWatched, onMarkSeasonAsWatched]);
const videoButtonOnClick = React.useCallback(() => {
if (deepLinks) {
if (typeof deepLinks.player === 'string') {
@ -142,9 +148,12 @@ const Video = ({ className, id, title, thumbnail, episode, released, upcoming, w
<Button className={styles['context-menu-option-container']} title={watched ? 'Mark as non-watched' : 'Mark as 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}>
<div className={styles['context-menu-option-label']}>{seasonWatched ? t('CTX_UNMARK_REST') : t('CTX_MARK_REST')}</div>
</Button>
</div>
);
}, [watched, toggleWatchedOnClick]);
}, [watched, seasonWatched, toggleWatchedOnClick]);
React.useEffect(() => {
if (!routeFocused) {
closeMenu();
@ -182,17 +191,20 @@ Video.propTypes = {
id: PropTypes.string,
title: PropTypes.string,
thumbnail: PropTypes.string,
season: PropTypes.number,
episode: PropTypes.number,
released: PropTypes.instanceOf(Date),
upcoming: PropTypes.bool,
watched: PropTypes.bool,
progress: PropTypes.number,
scheduled: PropTypes.bool,
seasonWatched: PropTypes.bool,
deepLinks: PropTypes.shape({
metaDetailsStreams: PropTypes.string,
player: PropTypes.string
}),
onMarkVideoAsWatched: PropTypes.func,
onMarkSeasonAsWatched: PropTypes.func,
};
module.exports = Video;

View file

@ -1,6 +1,7 @@
import AddonDetailsModal from './AddonDetailsModal';
import BottomSheet from './BottomSheet';
import Button from './Button';
import Checkbox from './Checkbox';
import Chips from './Chips';
import ColorInput from './ColorInput';
import ContinueWatchingItem from './ContinueWatchingItem';
@ -32,6 +33,7 @@ export {
AddonDetailsModal,
BottomSheet,
Button,
Checkbox,
Chips,
ColorInput,
ContinueWatchingItem,

View file

@ -3,7 +3,7 @@
const React = require('react');
const classnames = require('classnames');
const debounce = require('lodash.debounce');
const { useTranslation } = require('react-i18next');
const useTranslate = require('stremio/common/useTranslate');
const { useStreamingServer, useNotifications, withCoreSuspender, getVisibleChildrenRange, useProfile } = require('stremio/common');
const { ContinueWatchingItem, EventModal, MainNavBars, MetaItem, MetaRow } = require('stremio/components');
const useBoard = require('./useBoard');
@ -14,7 +14,7 @@ const { default: StreamingServerWarning } = require('./StreamingServerWarning');
const THRESHOLD = 5;
const Board = () => {
const { t } = useTranslation();
const t = useTranslate();
const streamingServer = useStreamingServer();
const continueWatchingPreview = useContinueWatchingPreview();
const [board, loadBoardRows] = useBoard();
@ -55,7 +55,7 @@ const Board = () => {
continueWatchingPreview.items.length > 0 ?
<MetaRow
className={classnames(styles['board-row'], styles['continue-watching-row'], 'animation-fade-in')}
title={t('BOARD_CONTINUE_WATCHING')}
title={t.string('BOARD_CONTINUE_WATCHING')}
catalog={continueWatchingPreview}
itemComponent={ContinueWatchingItem}
notifications={notifications}
@ -94,6 +94,7 @@ const Board = () => {
key={index}
className={classnames(styles['board-row'], styles['board-row-poster'], 'animation-fade-in')}
catalog={catalog}
title={t.catalogTitle(catalog)}
/>
);
}

View file

@ -13,7 +13,7 @@
gap: 0.5rem;
width: 100%;
height: 100%;
padding: 0 0 calc(1.5rem + var(--safe-area-inset-bottom)) 2rem;
padding: 0 0 1.5rem 1.5rem;
.main {
flex: auto;
@ -31,12 +31,4 @@
padding: 0;
}
}
}
@media only screen and (max-width: @small) and (orientation: landscape) {
.calendar {
.content {
padding: 0 0 calc(1.5rem + var(--safe-area-inset-bottom)) 1rem;
}
}
}
}

View file

@ -95,4 +95,4 @@
&:not(.active):hover {
border-color: var(--overlay-color);
}
}
}

View file

@ -10,6 +10,10 @@
width: 20rem;
padding: 0 1rem;
overflow-y: auto;
@supports (scroll-padding-block-start: 0.15rem) {
scroll-padding-block-start: 0.15rem;
}
}
@media only screen and (max-width: @small) and (orientation: portrait) {
@ -34,4 +38,4 @@
.list {
display: none;
}
}
}

View file

@ -8,12 +8,11 @@
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 100%;
width: 100%;
overflow-y: auto;
.title {
flex: none;
font-size: 1.75rem;
font-weight: 400;
text-align: center;
@ -22,19 +21,22 @@
opacity: 0.5;
}
.image {
flex: none;
height: 14rem;
margin: 1.5rem 0;
.image-container {
padding: 1.5rem 0;
.image {
height: 100%;
max-height: 14rem;
object-fit: contain;
}
}
.overview {
flex: none;
display: flex;
flex-direction: row;
align-items: center;
gap: 4rem;
margin-bottom: 3rem;
margin-bottom: 1rem;
.point {
display: flex;
@ -61,21 +63,47 @@
}
}
.button {
flex: none;
justify-content: center;
height: 4rem;
line-height: 4rem;
padding: 0 5rem;
font-size: 1.1rem;
color: var(--primary-foreground-color);
text-align: center;
border-radius: 3.5rem;
background-color: var(--overlay-color);
.button-container {
margin: 1rem 0;
.button {
display: flex;
justify-content: center;
height: 4rem;
line-height: 4rem;
padding: 0 5rem;
font-size: 1.1rem;
color: var(--primary-foreground-color);
text-align: center;
border-radius: 3.5rem;
background-color: var(--overlay-color);
&:hover {
outline: var(--focus-outline-size) solid var(--primary-foreground-color);
background-color: transparent;
&:hover {
outline: var(--focus-outline-size) solid var(--primary-foreground-color);
background-color: transparent;
}
}
}
}
@media only screen and (max-width: @xsmall) {
.placeholder {
padding: 1rem 2rem;
.title {
margin-bottom: 0;
}
.image-container {
padding: 1rem;
.image {
max-height: 10rem;
}
}
.button-container {
margin: 1rem 0 0;
}
}
}
@ -84,16 +112,21 @@
.placeholder {
padding: 1rem 2rem;
.image {
height: 10rem;
}
.overview {
flex-direction: column;
gap: 1rem;
.point {
.text {
font-size: 1rem;
}
}
}
.button {
width: 100%;
.button-container {
.button {
width: 100%;
}
}
}
}

View file

@ -14,11 +14,13 @@ const Placeholder = () => {
<div className={styles['title']}>
{t('CALENDAR_NOT_LOGGED_IN')}
</div>
<Image
className={styles['image']}
src={require('/images/calendar_placeholder.png')}
alt={' '}
/>
<div className={styles['image-container']}>
<Image
className={styles['image']}
src={require('/images/calendar_placeholder.png')}
alt={' '}
/>
</div>
<div className={styles['overview']}>
<div className={styles['point']}>
<Icon className={styles['icon']} name={'megaphone'} />
@ -33,9 +35,11 @@ const Placeholder = () => {
</div>
</div>
</div>
<Button className={styles['button']} href={'#/intro?form=login'}>
{t('LOG_IN')}
</Button>
<div className={styles['button-container']}>
<Button className={styles['button']} href={'#/intro?form=login'}>
{t('LOG_IN')}
</Button>
</div>
</div>
);
};

View file

@ -1,56 +0,0 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { Button, Toggle } = require('stremio/components');
const styles = require('./styles');
const ConsentToggle = React.forwardRef(({ className, label, link, href, onToggle, ...props }, ref) => {
const toggleOnClick = React.useCallback((event) => {
if (typeof props.onClick === 'function') {
props.onClick(event);
}
if (!event.nativeEvent.togglePrevented && typeof onToggle === 'function') {
onToggle({
type: 'toggle',
reactEvent: event,
nativeEvent: event.nativeEvent
});
}
}, [onToggle, props.onClick]);
const linkOnClick = React.useCallback((event) => {
event.nativeEvent.togglePrevented = true;
}, []);
return (
<Toggle {...props} ref={ref} className={classnames(className, styles['consent-toggle-container'])} onClick={toggleOnClick}>
<div className={styles['label']}>
{label}
{' '}
{
typeof link === 'string' && link.length > 0 && typeof href === 'string' && href.length > 0 ?
<Button className={styles['link']} href={href} target={'_blank'} tabIndex={-1} onClick={linkOnClick}>
{link}
</Button>
:
null
}
</div>
</Toggle>
);
});
ConsentToggle.displayName = 'ConsentToggle';
ConsentToggle.propTypes = {
className: PropTypes.string,
checked: PropTypes.bool,
label: PropTypes.string,
link: PropTypes.string,
href: PropTypes.string,
onToggle: PropTypes.func,
onClick: PropTypes.func
};
module.exports = ConsentToggle;

View file

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

View file

@ -1,43 +0,0 @@
// Copyright (C) 2017-2023 Smart code 203358507
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
:import('~stremio/components/Toggle/styles.less') {
checkbox-icon: icon;
}
.consent-toggle-container {
display: flex;
flex-direction: row;
align-items: center;
padding: 0.5rem 0;
border-radius: var(--border-radius);
&:focus {
outline: none;
background-color: var(--overlay-color);
}
&:global(.checked) {
.label {
opacity: 1;
}
}
.label {
flex: 1;
margin-left: 1rem;
font-size: 0.9rem;
color: var(--primary-foreground-color);
opacity: 0.6;
.link {
font-size: 0.9rem;
color: var(--primary-accent-color);
&:hover {
text-decoration: underline;
}
}
}
}

View file

@ -8,9 +8,8 @@ const { default: Icon } = require('@stremio/stremio-icons/react');
const { Modal, useRouteFocused } = require('stremio-router');
const { useServices } = require('stremio/services');
const { useBinaryState } = require('stremio/common');
const { Button, Image } = require('stremio/components');
const { Button, Image, Checkbox } = require('stremio/components');
const CredentialsTextInput = require('./CredentialsTextInput');
const ConsentToggle = require('./ConsentToggle');
const PasswordResetModal = require('./PasswordResetModal');
const useFacebookLogin = require('./useFacebookLogin');
const styles = require('./styles');
@ -308,30 +307,27 @@ const Intro = ({ queryParams }) => {
onChange={confirmPasswordOnChange}
onSubmit={confirmPasswordOnSubmit}
/>
<ConsentToggle
<Checkbox
ref={termsRef}
className={styles['consent-toggle']}
label={'I have read and agree with the Stremio'}
link={'Terms and conditions'}
href={'https://www.stremio.com/tos'}
checked={state.termsAccepted}
onToggle={toggleTermsAccepted}
onChange={toggleTermsAccepted}
/>
<ConsentToggle
<Checkbox
ref={privacyPolicyRef}
className={styles['consent-toggle']}
label={'I have read and agree with the Stremio'}
link={'Privacy Policy'}
href={'https://www.stremio.com/privacy'}
checked={state.privacyPolicyAccepted}
onToggle={togglePrivacyPolicyAccepted}
onChange={togglePrivacyPolicyAccepted}
/>
<ConsentToggle
<Checkbox
ref={marketingRef}
className={styles['consent-toggle']}
label={'I agree to receive marketing communications from Stremio'}
checked={state.marketingAccepted}
onToggle={toggleMarketingAccepted}
onChange={toggleMarketingAccepted}
/>
</React.Fragment>
:

View file

@ -252,7 +252,7 @@
}
}
@media only screen and (max-width: @minimum) {
@media only screen and (max-width: @xsmall) {
.intro-container {
justify-content: initial;
padding: 3rem 1.5rem;
@ -279,6 +279,21 @@
.content-container {
flex-direction: column-reverse;
.form-container, .options-container {
width: 50%;
margin: 0 auto;
}
.options-container {
margin-bottom: 4rem;
}
}
}
}
@media only screen and (max-width: @minimum) {
.intro-container {
.content-container {
.form-container, .options-container {
width: 100%;
margin: 0;

View file

@ -5,7 +5,8 @@ const PropTypes = require('prop-types');
const classnames = require('classnames');
const NotFound = require('stremio/routes/NotFound');
const { useProfile, useNotifications, routesRegexp, useOnScrollToBottom, withCoreSuspender } = require('stremio/common');
const { Button, DelayedRenderer, Chips, Image, MainNavBars, Multiselect, LibItem } = require('stremio/components');
const { DelayedRenderer, Chips, Image, MainNavBars, Multiselect, LibItem } = require('stremio/components');
const { default: Placeholder } = require('./Placeholder');
const useLibrary = require('./useLibrary');
const useSelectableInputs = require('./useSelectableInputs');
const styles = require('./styles');
@ -58,65 +59,54 @@ const Library = ({ model, urlParams, queryParams }) => {
}, [hasNextPage, loadNextPage]);
const onScroll = useOnScrollToBottom(onScrollToBottom, SCROLL_TO_BOTTOM_TRESHOLD);
React.useLayoutEffect(() => {
if (profile.auth !== null && library.selected && library.selected.request.page === 1 && library.catalog.length !== 0 ) {
if (scrollContainerRef.current !== null && library.selected && library.selected.request.page === 1 && library.catalog.length !== 0) {
scrollContainerRef.current.scrollTop = 0;
}
}, [profile.auth, library.selected]);
return (
<MainNavBars className={styles['library-container']} route={model}>
<div className={styles['library-content']}>
{
model === 'continue_watching' || profile.auth !== null ?
{
profile.auth !== null ?
<div className={styles['library-content']}>
<div className={styles['selectable-inputs-container']}>
<Multiselect {...typeSelect} className={styles['select-input-container']} />
<Chips {...sortChips} className={styles['select-input-container']} />
</div>
:
null
}
{
model === 'library' && profile.auth === null ?
<div className={classnames(styles['message-container'], styles['no-user-message-container'])}>
<Image
className={styles['image']}
src={require('/images/anonymous.png')}
alt={' '}
/>
<div className={styles['message-label']}>Library is only available for logged in users!</div>
<Button className={styles['login-button-container']} href={'#/intro'}>
<div className={styles['label']}>LOG IN</div>
</Button>
</div>
:
library.selected === null ?
<DelayedRenderer delay={500}>
<div className={styles['message-container']}>
<Image
className={styles['image']}
src={require('/images/empty.png')}
alt={' '}
/>
<div className={styles['message-label']}>{model === 'library' ? 'Library' : 'Continue Watching'} not loaded!</div>
</div>
</DelayedRenderer>
:
library.catalog.length === 0 ?
<div className={styles['message-container']}>
<Image
className={styles['image']}
src={require('/images/empty.png')}
alt={' '}
/>
<div className={styles['message-label']}>Empty {model === 'library' ? 'Library' : 'Continue Watching'}</div>
</div>
{
library.selected === null ?
<DelayedRenderer delay={500}>
<div className={styles['message-container']}>
<Image
className={styles['image']}
src={require('/images/empty.png')}
alt={' '}
/>
<div className={styles['message-label']}>{model === 'library' ? 'Library' : 'Continue Watching'} not loaded!</div>
</div>
</DelayedRenderer>
:
<div ref={scrollContainerRef} className={classnames(styles['meta-items-container'], 'animation-fade-in')} onScroll={onScroll}>
{library.catalog.map((libItem, index) => (
<LibItem {...libItem} notifications={notifications} removable={model === 'library'} key={index} />
))}
</div>
}
</div>
library.catalog.length === 0 ?
<div className={styles['message-container']}>
<Image
className={styles['image']}
src={require('/images/empty.png')}
alt={' '}
/>
<div className={styles['message-label']}>Empty {model === 'library' ? 'Library' : 'Continue Watching'}</div>
</div>
:
<div ref={scrollContainerRef} className={classnames(styles['meta-items-container'], 'animation-fade-in')} onScroll={onScroll}>
{
library.catalog.map((libItem, index) => (
<LibItem {...libItem} notifications={notifications} removable={model === 'library'} key={index} />
))
}
</div>
}
</div>
:
<Placeholder />
}
</MainNavBars>
);
};

View file

@ -0,0 +1,132 @@
// Copyright (C) 2017-2025 Smart code 203358507
@import (reference) '~stremio/common/screen-sizes.less';
.placeholder {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100%;
width: 100%;
overflow-y: auto;
.title {
font-size: 1.75rem;
font-weight: 400;
text-align: center;
color: var(--primary-foreground-color);
margin-bottom: 1rem;
opacity: 0.5;
}
.image-container {
padding: 1.5rem 0;
.image {
height: 100%;
max-height: 14rem;
object-fit: contain;
}
}
.overview {
display: flex;
flex-direction: row;
align-items: center;
gap: 4rem;
margin-bottom: 1rem;
.point {
display: flex;
flex-direction: row;
align-items: center;
gap: 1.5rem;
width: 18rem;
.icon {
flex: none;
height: 3.25rem;
width: 3.25rem;
color: var(--primary-foreground-color);
opacity: 0.3;
}
.text {
flex: auto;
font-size: 1.1rem;
font-size: 500;
color: var(--primary-foreground-color);
opacity: 0.9;
}
}
}
.button-container {
margin: 1rem 0;
.button {
display: flex;
justify-content: center;
height: 4rem;
line-height: 4rem;
padding: 0 5rem;
font-size: 1.1rem;
color: var(--primary-foreground-color);
text-align: center;
border-radius: 3.5rem;
background-color: var(--overlay-color);
&:hover {
outline: var(--focus-outline-size) solid var(--primary-foreground-color);
background-color: transparent;
}
}
}
}
@media only screen and (max-width: @xsmall) {
.placeholder {
padding: 1rem 2rem;
.title {
margin-bottom: 0;
}
.image-container {
padding: 1rem;
.image {
max-height: 10rem;
}
}
.button-container {
margin: 1rem 0 0;
}
}
}
@media only screen and (max-width: @minimum) {
.placeholder {
padding: 1rem 2rem;
.overview {
flex-direction: column;
gap: 1rem;
.point {
.text {
font-size: 1rem;
}
}
}
.button-container {
.button {
width: 100%;
}
}
}
}

View file

@ -0,0 +1,47 @@
// Copyright (C) 2017-2025 Smart code 203358507
import React from 'react';
import { useTranslation } from 'react-i18next';
import Icon from '@stremio/stremio-icons/react';
import { Button, Image } from 'stremio/components';
import styles from './Placeholder.less';
const Placeholder = () => {
const { t } = useTranslation();
return (
<div className={styles['placeholder']}>
<div className={styles['title']}>
{t('LIBRARY_NOT_LOGGED_IN')}
</div>
<div className={styles['image-container']}>
<Image
className={styles['image']}
src={require('/images/library_placeholder.png')}
alt={' '}
/>
</div>
<div className={styles['overview']}>
<div className={styles['point']}>
<Icon className={styles['icon']} name={'cloud-library'} />
<div className={styles['text']}>
{t('NOT_LOGGED_IN_CLOUD')}
</div>
</div>
<div className={styles['point']}>
<Icon className={styles['icon']} name={'actors'} />
<div className={styles['text']}>
{t('NOT_LOGGED_IN_RECOMMENDATIONS')}
</div>
</div>
</div>
<div className={styles['button-container']}>
<Button className={styles['button']} href={'#/intro?form=login'}>
{t('LOG_IN')}
</Button>
</div>
</div>
);
};
export default Placeholder;

View file

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

View file

@ -66,38 +66,6 @@
padding: 4rem;
}
&.no-user-message-container {
.login-button-container {
flex: none;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 20rem;
height: 3.5rem;
border-radius: 3.5rem;
padding: 0.5rem 1rem;
margin-bottom: 1rem;
background-color: var(--secondary-accent-color);
&:hover {
outline: var(--focus-outline-size) solid var(--secondary-accent-color);
background-color: transparent;
}
.label {
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
max-height: 4.8em;
font-size: 1.2rem;
font-weight: 700;
color: var(--primary-foreground-color);
text-align: center;
}
}
}
.image {
flex: none;
width: 12rem;

View file

@ -36,17 +36,23 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
return season;
}
const video = videos?.find((video) => video.id === libraryItem?.state.video_id);
if (video && video.season && seasons.includes(video.season)) {
return video.season;
}
const nonSpecialSeasons = seasons.filter((season) => season !== 0);
if (nonSpecialSeasons.length > 0) {
return nonSpecialSeasons[nonSpecialSeasons.length - 1];
return nonSpecialSeasons[0];
}
if (seasons.length > 0) {
return seasons[seasons.length - 1];
return seasons[0];
}
return null;
}, [seasons, season]);
}, [seasons, season, videos, libraryItem]);
const videosForSeason = React.useMemo(() => {
return videos
.filter((video) => {
@ -56,6 +62,11 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
return a.episode - b.episode;
});
}, [videos, selectedSeason]);
const seasonWatched = React.useMemo(() => {
return videosForSeason.every((video) => video.watched);
}, [videosForSeason]);
const [search, setSearch] = React.useState('');
const searchInputOnChange = React.useCallback((event) => {
setSearch(event.currentTarget.value);
@ -71,6 +82,16 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
});
};
const onMarkSeasonAsWatched = (season, watched) => {
core.transport.dispatch({
action: 'MetaDetails',
args: {
action: 'MarkSeasonAsWatched',
args: [season, !watched]
}
});
};
return (
<div className={classnames(className, styles['videos-list-container'])}>
{
@ -135,6 +156,7 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
id={video.id}
title={video.title}
thumbnail={video.thumbnail}
season={video.season}
episode={video.episode}
released={video.released}
upcoming={video.upcoming}
@ -142,7 +164,9 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
progress={video.progress}
deepLinks={video.deepLinks}
scheduled={video.scheduled}
seasonWatched={seasonWatched}
onMarkVideoAsWatched={onMarkVideoAsWatched}
onMarkSeasonAsWatched={onMarkSeasonAsWatched}
/>
))
}

View file

@ -5,13 +5,16 @@ const PropTypes = require('prop-types');
const classnames = require('classnames');
const debounce = require('lodash.debounce');
const { useRouteFocused } = require('stremio-router');
const { useServices } = require('stremio/services');
const { Slider } = require('stremio/components');
const styles = require('./styles');
const VolumeSlider = ({ className, volume, onVolumeChangeRequested, muted }) => {
const { shell } = useServices();
const disabled = volume === null || isNaN(volume);
const routeFocused = useRouteFocused();
const [slidingVolume, setSlidingVolume] = React.useState(null);
const maxVolume = shell.active ? 200: 100;
const resetVolumeDebounced = React.useCallback(debounce(() => {
setSlidingVolume(null);
}, 100), []);
@ -52,10 +55,11 @@ const VolumeSlider = ({ className, volume, onVolumeChangeRequested, muted }) =>
100
}
minimumValue={0}
maximumValue={100}
maximumValue={maxVolume}
disabled={disabled}
onSlide={onSlide}
onComplete={onComplete}
audioBoost={!!shell.active}
/>
);
};

View file

@ -506,14 +506,14 @@ const Player = ({ urlParams, queryParams }) => {
}
case 'ArrowUp': {
if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) {
onVolumeChangeRequested(video.state.volume + 5);
onVolumeChangeRequested(Math.min(video.state.volume + 5, 200));
}
break;
}
case 'ArrowDown': {
if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) {
onVolumeChangeRequested(video.state.volume - 5);
onVolumeChangeRequested(Math.max(video.state.volume - 5, 0));
}
break;
@ -571,13 +571,13 @@ const Player = ({ urlParams, queryParams }) => {
}
};
const onWheel = ({ deltaY }) => {
if (menusOpen || video.state.volume === null) return;
if (deltaY > 0) {
if (!menusOpen && video.state.volume !== null) {
onVolumeChangeRequested(video.state.volume - 5);
}
onVolumeChangeRequested(Math.max(video.state.volume - 5, 0));
} else {
if (!menusOpen && video.state.volume !== null) {
onVolumeChangeRequested(video.state.volume + 5);
if (video.state.volume < 100) {
onVolumeChangeRequested(Math.min(video.state.volume + 5, 100));
}
}
};

View file

@ -47,6 +47,10 @@ const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, classNa
setSeason(parseInt(event.value));
}, []);
const seasonWatched = React.useMemo(() => {
return videos.every((video) => video.watched);
}, [videos]);
const onMarkVideoAsWatched = useCallback((video: Video, watched: boolean) => {
core.transport.dispatch({
action: 'Player',
@ -57,6 +61,16 @@ const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, classNa
});
}, []);
const onMarkSeasonAsWatched = (season: number, watched: boolean) => {
core.transport.dispatch({
action: 'Player',
args: {
action: 'MarkSeasonAsWatched',
args: [season, !watched]
}
});
};
const onMouseDown = (event: React.MouseEvent) => {
event.stopPropagation();
};
@ -95,14 +109,17 @@ const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, classNa
id={video.id}
title={video.title}
thumbnail={video.thumbnail}
season={video.season}
episode={video.episode}
released={video.released}
upcoming={video.upcoming}
watched={video.watched}
seasonWatched={seasonWatched}
progress={video.progress}
deepLinks={video.deepLinks}
scheduled={video.scheduled}
onMarkVideoAsWatched={onMarkVideoAsWatched}
onMarkSeasonAsWatched={onMarkSeasonAsWatched}
/>
))}
</div>

View file

@ -213,60 +213,62 @@ const SubtitlesMenu = React.memo((props) => {
}
</div>
<div className={styles['subtitles-settings-container']}>
<div className={styles['settings-header']}>Subtitles Settings</div>
<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)}
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}%` : '--'
<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)}
onChange={onSubtitlesDelayChanged}
/>
<DiscreteSelectInput
className={styles['discrete-input']}
label={t('SIZE')}
value={
typeof props.selectedSubtitlesTrackId === 'string' ?
props.subtitlesSize !== null && !isNaN(props.subtitlesSize) ? `${props.subtitlesSize}%` : '--'
:
'--'
}
disabled={
typeof props.selectedSubtitlesTrackId === 'string' ?
props.subtitlesSize === null || isNaN(props.subtitlesSize)
:
typeof props.selectedExtraSubtitlesTrackId === 'string' ?
props.extraSubtitlesSize === null || isNaN(props.extraSubtitlesSize)
typeof props.selectedExtraSubtitlesTrackId === 'string' ?
props.extraSubtitlesSize !== null && !isNaN(props.extraSubtitlesSize) ? `${props.extraSubtitlesSize}%` : '--'
:
'--'
}
disabled={
typeof props.selectedSubtitlesTrackId === 'string' ?
props.subtitlesSize === null || isNaN(props.subtitlesSize)
:
true
}
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}%` : '--'
typeof props.selectedExtraSubtitlesTrackId === 'string' ?
props.extraSubtitlesSize === null || isNaN(props.extraSubtitlesSize)
:
true
}
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}%` : '--'
:
'--'
}
disabled={
typeof props.selectedSubtitlesTrackId === 'string' ?
props.subtitlesOffset === null || isNaN(props.subtitlesOffset)
:
typeof props.selectedExtraSubtitlesTrackId === 'string' ?
props.extraSubtitlesOffset === null || isNaN(props.extraSubtitlesOffset)
typeof props.selectedExtraSubtitlesTrackId === 'string' ?
props.extraSubtitlesOffset !== null && !isNaN(props.extraSubtitlesOffset) ? `${props.extraSubtitlesOffset}%` : '--'
:
'--'
}
disabled={
typeof props.selectedSubtitlesTrackId === 'string' ?
props.subtitlesOffset === null || isNaN(props.subtitlesOffset)
:
true
}
onChange={onSubtitlesOffsetChanged}
/>
typeof props.selectedExtraSubtitlesTrackId === 'string' ?
props.extraSubtitlesOffset === null || isNaN(props.extraSubtitlesOffset)
:
true
}
onChange={onSubtitlesOffsetChanged}
/>
</div>
</div>
</div>
);

View file

@ -106,6 +106,10 @@
.subtitles-settings-container {
width: 17rem;
.settings-list {
overflow-y: scroll;
}
.spacing {
flex: 1;
}

View file

@ -4,7 +4,7 @@ const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const debounce = require('lodash.debounce');
const { useTranslation } = require('react-i18next');
const useTranslate = require('stremio/common/useTranslate');
const { default: Icon } = require('@stremio/stremio-icons/react');
const { withCoreSuspender, getVisibleChildrenRange } = require('stremio/common');
const { Image, MainNavBars, MetaItem, MetaRow } = require('stremio/components');
@ -14,7 +14,7 @@ const styles = require('./styles');
const THRESHOLD = 100;
const Search = ({ queryParams }) => {
const { t } = useTranslation();
const t = useTranslate();
const [search, loadSearchRows] = useSearch(queryParams);
const query = React.useMemo(() => {
return search.selected !== null ?
@ -52,24 +52,24 @@ const Search = ({ queryParams }) => {
query === null ?
<div className={classnames(styles['search-hints-wrapper'])}>
<div className={classnames(styles['search-hints-title-container'], 'animation-fade-in')}>
<div className={styles['search-hints-title']}>{t('SEARCH_ANYTHING')}</div>
<div className={styles['search-hints-title']}>{t.string('SEARCH_ANYTHING')}</div>
</div>
<div className={classnames(styles['search-hints-container'], 'animation-fade-in')}>
<div className={styles['search-hint-container']}>
<Icon className={styles['icon']} name={'trailer'} />
<div className={styles['label']}>{t('SEARCH_CATEGORIES')}</div>
<div className={styles['label']}>{t.string('SEARCH_CATEGORIES')}</div>
</div>
<div className={styles['search-hint-container']}>
<Icon className={styles['icon']} name={'actors'} />
<div className={styles['label']}>{t('SEARCH_PERSONS')}</div>
<div className={styles['label']}>{t.string('SEARCH_PERSONS')}</div>
</div>
<div className={styles['search-hint-container']}>
<Icon className={styles['icon']} name={'link'} />
<div className={styles['label']}>{t('SEARCH_PROTOCOLS')}</div>
<div className={styles['label']}>{t.string('SEARCH_PROTOCOLS')}</div>
</div>
<div className={styles['search-hint-container']}>
<Icon className={styles['icon']} name={'imdb-outline'} />
<div className={styles['label']}>{t('SEARCH_TYPES')}</div>
<div className={styles['label']}>{t.string('SEARCH_TYPES')}</div>
</div>
</div>
</div>
@ -81,7 +81,7 @@ const Search = ({ queryParams }) => {
src={require('/images/empty.png')}
alt={' '}
/>
<div className={styles['message-label']}>{ t('STREMIO_TV_SEARCH_NO_ADDONS') }</div>
<div className={styles['message-label']}>{ t.string('STREMIO_TV_SEARCH_NO_ADDONS') }</div>
</div>
:
search.catalogs.map((catalog, index) => {
@ -115,6 +115,7 @@ const Search = ({ queryParams }) => {
key={index}
className={classnames(styles['search-row'], styles['search-row-poster'], 'animation-fade-in')}
catalog={catalog}
title={t.catalogTitle(catalog)}
/>
);
}

View file

@ -41,6 +41,7 @@ const Settings = () => {
seekTimeDurationSelect,
seekShortTimeDurationSelect,
escExitFullscreenToggle,
quitOnCloseToggle,
playInExternalPlayerSelect,
nextVideoPopupDurationSelect,
bingeWatchingToggle,
@ -322,12 +323,25 @@ const Settings = () => {
{...interfaceLanguageSelect}
/>
</div>
{
shell.active &&
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_QUIT_ON_CLOSE') }</div>
</div>
<Toggle
className={classnames(styles['option-input-container'], styles['toggle-container'])}
tabIndex={-1}
{...quitOnCloseToggle}
/>
</div>
}
</div>
<div ref={playerSectionRef} className={styles['section-container']}>
<div className={styles['section-title']}>{ t('SETTINGS_NAV_PLAYER') }</div>
<div className={styles['section-category-container']}>
<Icon className={styles['icon']} name={'subtitles'} />
<div className={styles['label']}>{t('SETTINGS_SECTION_SUBTITLES')}</div>
<div className={styles['label']}>{t('SETTINGS_CLOSE_WINDOW')}</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>

View file

@ -31,6 +31,23 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const quitOnCloseToggle = React.useMemo(() => ({
checked: profile.settings.quitOnClose,
onClick: () => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'UpdateSettings',
args: {
...profile.settings,
quitOnClose: !profile.settings.quitOnClose
}
}
});
}
}), [profile.settings]);
const subtitlesLanguageSelect = React.useMemo(() => ({
options: Object.keys(languageNames).map((code) => ({
value: code,
@ -316,6 +333,7 @@ const useProfileSettingsInputs = (profile) => {
audioLanguageSelect,
surroundSoundToggle,
escExitFullscreenToggle,
quitOnCloseToggle,
seekTimeDurationSelect,
seekShortTimeDurationSelect,
playInExternalPlayerSelect,

22
src/types/global.d.ts vendored
View file

@ -1,15 +1,31 @@
/* eslint-disable no-var */
type QtTransportMessage = {
data: string;
};
interface QtTransport {
send: (message: string) => void,
onmessage: (message: QtTransportMessage) => void,
}
interface Qt {
webChannelTransport: QtTransport,
}
declare global {
var qt: Qt | undefined;
interface ChromeWebView {
addEventListener: (type: 'message', listenenr: (event: any) => void) => void,
removeEventListener: (type: 'message', listenenr: (event: any) => void) => void,
postMessage: (message: string) => void,
}
export { };
interface Chrome {
webview: ChromeWebView,
}
declare global {
var qt: Qt | undefined;
var chrome: Chrome | undefined;
}
export {};