Merge branch 'development' into fix/discover-page-remove-duplicated-filters

This commit is contained in:
Timothy Z. 2025-04-15 13:00:52 +03:00
commit a8eb284379
61 changed files with 1240 additions and 172 deletions

View file

@ -0,0 +1,67 @@
{
"applinks": {
"apps": [],
"details": [
{
"appID": "9EWRZ4QP3J.com.stremio.one",
"paths": [
"/",
"/#/player/*",
"/#/discover/*",
"/#/detail/*",
"/#/library/*",
"/#/addons/*",
"/#/settings",
"/#/search/*"
],
"components": [
{
"/": "/",
"#": "/player/*",
"comment": "Matches deep link for player"
},
{
"/": "/",
"#": "/discover/*",
"comment": "Matches deep link for discover"
},
{
"/": "/",
"#": "/detail/*",
"comment": "Matches deep link for detail"
},
{
"/": "/",
"#": "/library/*",
"comment": "Matches deep link for library"
},
{
"/": "/",
"#": "/addons/*",
"comment": "Matches deep link for addons"
},
{
"/": "/",
"#": "/settings",
"comment": "Matches deep link for settings"
},
{
"/": "/",
"#": "/search/*",
"comment": "Matches deep link for search"
}
]
}
]
},
"activitycontinuation": {
"apps": [
"9EWRZ4QP3J.com.stremio.one"
]
},
"webcredentials": {
"apps": [
"9EWRZ4QP3J.com.stremio.one"
]
}
}

39
package-lock.json generated
View file

@ -1,20 +1,20 @@
{
"name": "stremio",
"version": "5.0.0-beta.20",
"version": "5.0.0-beta.21",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stremio",
"version": "5.0.0-beta.20",
"version": "5.0.0-beta.21",
"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.49.0",
"@stremio/stremio-core-web": "0.49.3",
"@stremio/stremio-icons": "5.4.1",
"@stremio/stremio-video": "0.0.53",
"@stremio/stremio-video": "0.0.60",
"a-color-picker": "1.2.1",
"bowser": "2.11.0",
"buffer": "6.0.3",
@ -23,6 +23,7 @@
"filter-invalid-dom-props": "3.0.1",
"hat": "^0.0.3",
"i18next": "^24.0.5",
"jwt-decode": "^4.0.0",
"langs": "github:Stremio/nodejs-langs",
"lodash.debounce": "4.0.8",
"lodash.intersection": "4.4.0",
@ -36,7 +37,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#62bcc6e8f44258203c7375af59210771efb6f634",
"stremio-translations": "github:Stremio/stremio-translations#a6be0425573917c2e82b66d28968c1a4d444cb96",
"url": "0.11.4",
"use-long-press": "^3.2.0"
},
@ -3371,9 +3372,9 @@
"integrity": "sha512-dYlPgu9W/H7c9s1zmW5tiDnRenaUa4Hg1QCyOg1lhOcgSfM/bVTi5nnqX+IfvGTTUNA0zgzh8hI3o3miwnZxTg=="
},
"node_modules/@stremio/stremio-core-web": {
"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==",
"version": "0.49.3",
"resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.49.3.tgz",
"integrity": "sha512-Ql/08LbwU99IUL6fOLy+v1Iv75boHXpunEPScKgXJALdq/OV5tZLG/IycN0O+5+50Nc/NHrI6HslnMNLTWA8JQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "7.24.1"
@ -3409,9 +3410,10 @@
]
},
"node_modules/@stremio/stremio-video": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@stremio/stremio-video/-/stremio-video-0.0.53.tgz",
"integrity": "sha512-hSlk8GqMdk4N8VbcdvduYqWVZsQLgHyU7GfFmd1k+t0pSpDKAhI3C6dohG5Sr09CKCjHa8D1rls+CwMNPXLSGw==",
"version": "0.0.60",
"resolved": "https://registry.npmjs.org/@stremio/stremio-video/-/stremio-video-0.0.60.tgz",
"integrity": "sha512-RbmSi+Lk+3pb6f2ZkGVCnoMoJoujvVvSLDHiLGkXnzQwjYf2B2022NKlAQmHRuHN1sjD+VEsKD8foQH4hXGG1A==",
"license": "MIT",
"dependencies": {
"buffer": "6.0.3",
"color": "4.2.3",
@ -10233,6 +10235,15 @@
"node": ">=4.0"
}
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -13372,9 +13383,9 @@
}
},
"node_modules/stremio-translations": {
"version": "1.44.9",
"resolved": "git+ssh://git@github.com/Stremio/stremio-translations.git#62bcc6e8f44258203c7375af59210771efb6f634",
"integrity": "sha512-8Sc5Qvd4IiObwGXkmj1XFXFavUc15My5po6G48HHDBbp42SVc5I/t7h+1yxW1A81byyBCXbL23a9iU9v49vpQA==",
"version": "1.44.10",
"resolved": "git+ssh://git@github.com/Stremio/stremio-translations.git#a6be0425573917c2e82b66d28968c1a4d444cb96",
"integrity": "sha512-77kVE/eos/SA16kzeK7TTWmqoLF0mLPCJXjITwVIVzMHr8XyBPZFOfmiVEg4M6W1W7qYqA+dHhzicyLs7hJhlw==",
"license": "MIT"
},
"node_modules/string_decoder": {

View file

@ -1,7 +1,7 @@
{
"name": "stremio",
"displayName": "Stremio",
"version": "5.0.0-beta.20",
"version": "5.0.0-beta.21",
"author": "Smart Code OOD",
"private": true,
"license": "gpl-2.0",
@ -16,9 +16,9 @@
"@babel/runtime": "7.26.0",
"@sentry/browser": "8.42.0",
"@stremio/stremio-colors": "5.2.0",
"@stremio/stremio-core-web": "0.49.0",
"@stremio/stremio-core-web": "0.49.3",
"@stremio/stremio-icons": "5.4.1",
"@stremio/stremio-video": "0.0.53",
"@stremio/stremio-video": "0.0.60",
"a-color-picker": "1.2.1",
"bowser": "2.11.0",
"buffer": "6.0.3",
@ -27,6 +27,7 @@
"filter-invalid-dom-props": "3.0.1",
"hat": "^0.0.3",
"i18next": "^24.0.5",
"jwt-decode": "^4.0.0",
"langs": "github:Stremio/nodejs-langs",
"lodash.debounce": "4.0.8",
"lodash.intersection": "4.4.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#62bcc6e8f44258203c7375af59210771efb6f634",
"stremio-translations": "github:Stremio/stremio-translations#a6be0425573917c2e82b66d28968c1a4d444cb96",
"url": "0.11.4",
"use-long-press": "^3.2.0"
},

View file

@ -21,7 +21,6 @@ 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;
}, []);
@ -100,14 +99,23 @@ const App = () => {
};
}, []);
// Handle shell window visibility changed event
// Handle shell events
React.useEffect(() => {
const onWindowVisibilityChanged = (state) => {
setWindowHidden(state.visible === false && state.visibility === 0);
const onOpenMedia = (data) => {
if (data.startsWith('stremio:///')) return;
if (data.startsWith('stremio://')) {
const transportUrl = data.replace('stremio://', 'https://');
if (URL.canParse(transportUrl)) {
window.location.href = `#/addons?addon=${encodeURIComponent(transportUrl)}`;
}
}
};
shell.on('win-visibility-changed', onWindowVisibilityChanged);
return () => shell.off('win-visibility-changed', onWindowVisibilityChanged);
shell.on('open-media', onOpenMedia);
return () => {
shell.off('open-media', onOpenMedia);
};
}, []);
React.useEffect(() => {
@ -118,7 +126,7 @@ const App = () => {
i18n.changeLanguage(args.settings.interfaceLanguage);
}
if (args?.settings?.quitOnClose && windowHidden) {
if (args?.settings?.quitOnClose && shell.windowClosed) {
shell.send('quit');
}
@ -131,7 +139,7 @@ const App = () => {
i18n.changeLanguage(state.profile.settings.interfaceLanguage);
}
if (state?.profile?.settings?.quitOnClose && windowHidden) {
if (state?.profile?.settings?.quitOnClose && shell.windowClosed) {
shell.send('quit');
}
};
@ -176,7 +184,7 @@ const App = () => {
services.core.transport.off('CoreEvent', onCoreEvent);
}
};
}, [initialized, windowHidden]);
}, [initialized, shell.windowClosed]);
return (
<React.StrictMode>
<ServicesProvider services={services}>

View file

@ -151,14 +151,13 @@ svg {
html {
width: @html-width;
height: @html-height;
font-family: 'PlusJakartaSans', 'sans-serif';
font-family: 'PlusJakartaSans', 'Arial', 'Helvetica', 'sans-serif';
overflow: auto;
overscroll-behavior: none;
user-select: none;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
@media (display-mode: standalone) {
width: @html-standalone-width;
height: @html-standalone-height;
@ -168,6 +167,7 @@ html {
width: 100%;
height: 100%;
background: linear-gradient(41deg, var(--primary-background-color) 0%, var(--secondary-background-color) 100%);
-webkit-font-smoothing: antialiased;
:global(#app) {
position: relative;

View file

@ -14,12 +14,13 @@ const languages = require('./languages');
const routesRegexp = require('./routesRegexp');
const useAnimationFrame = require('./useAnimationFrame');
const useBinaryState = require('./useBinaryState');
const useFullscreen = require('./useFullscreen');
const { default: useFullscreen } = require('./useFullscreen');
const useLiveRef = require('./useLiveRef');
const useModelState = require('./useModelState');
const useNotifications = require('./useNotifications');
const useOnScrollToBottom = require('./useOnScrollToBottom');
const useProfile = require('./useProfile');
const { default: useSettings } = require('./useSettings');
const { default: useShell } = require('./useShell');
const useStreamingServer = require('./useStreamingServer');
const useTorrent = require('./useTorrent');
@ -52,6 +53,7 @@ module.exports = {
useNotifications,
useOnScrollToBottom,
useProfile,
useSettings,
useShell,
useStreamingServer,
useTorrent,

View file

@ -1,32 +0,0 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const useFullscreen = () => {
const [fullscreen, setFullscreen] = React.useState(document.fullscreenElement === document.documentElement);
const requestFullscreen = React.useCallback(() => {
document.documentElement.requestFullscreen();
}, []);
const exitFullscreen = React.useCallback(() => {
document.exitFullscreen();
}, []);
const toggleFullscreen = React.useCallback(() => {
if (fullscreen) {
exitFullscreen();
} else {
requestFullscreen();
}
}, [fullscreen]);
React.useEffect(() => {
const onFullscreenChange = () => {
setFullscreen(document.fullscreenElement === document.documentElement);
};
document.addEventListener('fullscreenchange', onFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', onFullscreenChange);
};
}, []);
return [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen];
};
module.exports = useFullscreen;

View file

@ -0,0 +1,66 @@
// Copyright (C) 2017-2023 Smart code 203358507
import { useCallback, useEffect, useState } from 'react';
import useShell, { type WindowVisibility } from './useShell';
import useSettings from './useSettings';
const useFullscreen = () => {
const shell = useShell();
const [settings] = useSettings();
const [fullscreen, setFullscreen] = useState(false);
const requestFullscreen = useCallback(() => {
if (shell.active) {
shell.send('win-set-visibility', { fullscreen: true });
} else {
document.documentElement.requestFullscreen();
}
}, []);
const exitFullscreen = useCallback(() => {
if (shell.active) {
shell.send('win-set-visibility', { fullscreen: false });
} else {
document.exitFullscreen();
}
}, []);
const toggleFullscreen = useCallback(() => {
fullscreen ? exitFullscreen() : requestFullscreen();
}, [fullscreen]);
useEffect(() => {
const onWindowVisibilityChanged = (state: WindowVisibility) => {
setFullscreen(state.isFullscreen === true);
};
const onFullscreenChange = () => {
setFullscreen(document.fullscreenElement === document.documentElement);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.code === 'Escape' && settings.escExitFullscreen) {
exitFullscreen();
}
if (event.code === 'F11' && shell.active) {
toggleFullscreen();
}
};
shell.on('win-visibility-changed', onWindowVisibilityChanged);
document.addEventListener('keydown', onKeyDown);
document.addEventListener('fullscreenchange', onFullscreenChange);
return () => {
shell.off('win-visibility-changed', onWindowVisibilityChanged);
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('fullscreenchange', onFullscreenChange);
};
}, [settings.escExitFullscreen]);
return [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen];
};
export default useFullscreen;

View file

@ -1,13 +1,14 @@
// Copyright (C) 2017-2023 Smart code 203358507
// Copyright (C) 2017-2025 Smart code 203358507
const React = require('react');
const { useServices } = require('stremio/services');
const { useProfile } = require('stremio/common');
import { useCallback } from 'react';
import { useServices } from 'stremio/services';
import useProfile from './useProfile';
const useSettings = () => {
const useSettings = (): [Settings, (settings: Settings) => void] => {
const { core } = useServices();
const profile = useProfile();
const updateSettings = React.useCallback((settings) => {
const updateSettings = useCallback((settings: Settings) => {
core.transport.dispatch({
action: 'Ctx',
args: {
@ -19,7 +20,8 @@ const useSettings = () => {
}
});
}, [profile]);
return [profile.settings, updateSettings];
};
module.exports = useSettings;
export default useSettings;

View file

@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import EventEmitter from 'eventemitter3';
const SHELL_EVENT_OBJECT = 'transport';
@ -17,9 +17,22 @@ type ShellEvent = {
args: string[];
};
export type WindowVisibility = {
visible: boolean;
visibility: number;
isFullscreen: boolean;
};
export type WindowState = {
state: number;
};
const createId = () => Math.floor(Math.random() * 9999) + 1;
const useShell = () => {
const [windowClosed, setWindowClosed] = useState(false);
const [windowHidden, setWindowHidden] = useState(false);
const on = (name: string, listener: (arg: any) => void) => {
events.on(name, listener);
};
@ -28,7 +41,7 @@ const useShell = () => {
events.off(name, listener);
};
const send = (method: string, ...args: (string | number)[]) => {
const send = (method: string, ...args: (string | number | object)[]) => {
try {
transport?.postMessage(JSON.stringify({
id: createId(),
@ -42,6 +55,24 @@ const useShell = () => {
}
};
useEffect(() => {
const onWindowVisibilityChanged = (data: WindowVisibility) => {
setWindowClosed(data.visible === false && data.visibility === 0);
};
const onWindowStateChanged = (data: WindowState) => {
setWindowHidden(data.state === 9);
};
on('win-visibility-changed', onWindowVisibilityChanged);
on('win-state-changed', onWindowStateChanged);
return () => {
off('win-visibility-changed', onWindowVisibilityChanged);
off('win-state-changed', onWindowStateChanged);
};
}, []);
useEffect(() => {
if (!transport) return;
@ -66,6 +97,8 @@ const useShell = () => {
send,
on,
off,
windowClosed,
windowHidden,
};
};

View file

@ -0,0 +1,17 @@
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
.context-menu-container {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
.context-menu {
position: fixed;
border-radius: var(--border-radius);
background-color: var(--modal-background-color);
box-shadow: 0 1.35rem 2.7rem @color-background-dark5-40,
0 1.1rem 0.85rem @color-background-dark5-20;
}
}

View file

@ -0,0 +1,101 @@
import React, { memo, RefObject, useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import styles from './ContextMenu.less';
const PADDING = 8;
type Coordinates = [number, number];
type Size = [number, number];
type Props = {
children: React.ReactNode,
on: RefObject<HTMLElement>[],
autoClose: boolean,
};
const ContextMenu = ({ children, on, autoClose }: Props) => {
const [active, setActive] = useState(false);
const [position, setPosition] = useState<Coordinates>([0, 0]);
const [containerSize, setContainerSize] = useState<Size>([0, 0]);
const ref = useCallback((element: HTMLDivElement) => {
element && setContainerSize([element.offsetWidth, element.offsetHeight]);
}, []);
const style = useMemo(() => {
const [viewportWidth, viewportHeight] = [window.innerWidth, window.innerHeight];
const [containerWidth, containerHeight] = containerSize;
const [x, y] = position;
const left = Math.max(
PADDING,
Math.min(
x + containerWidth > viewportWidth - PADDING ? x - containerWidth : x,
viewportWidth - containerWidth - PADDING
)
);
const top = Math.max(
PADDING,
Math.min(
y + containerHeight > viewportHeight - PADDING ? y - containerHeight : y,
viewportHeight - containerHeight - PADDING
)
);
return { top, left };
}, [position, containerSize]);
const close = () => {
setPosition([0, 0]);
setActive(false);
};
const stopPropagation = (event: React.MouseEvent | React.TouchEvent) => {
event.stopPropagation();
};
const onContextMenu = (event: MouseEvent) => {
event.preventDefault();
setPosition([event.clientX, event.clientY]);
setActive(true);
};
const handleKeyDown = useCallback((event: KeyboardEvent) => event.key === 'Escape' && close(), []);
const onClick = useCallback(() => {
autoClose && close();
}, [autoClose]);
useEffect(() => {
on.forEach((ref) => ref.current && ref.current.addEventListener('contextmenu', onContextMenu));
document.addEventListener('keydown', handleKeyDown);
return () => {
on.forEach((ref) => ref.current && ref.current.removeEventListener('contextmenu', onContextMenu));
document.removeEventListener('keydown', handleKeyDown);
};
}, [on]);
return active && createPortal((
<div
className={styles['context-menu-container']}
onMouseDown={close}
onTouchStart={close}
>
<div
ref={ref}
className={styles['context-menu']}
style={style}
onMouseDown={stopPropagation}
onTouchStart={stopPropagation}
onClick={onClick}
>
{children}
</div>
</div>
), document.body);
};
export default memo(ContextMenu);

View file

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

View file

@ -34,7 +34,7 @@
bottom: 0;
left: var(--vertical-nav-bar-size);
z-index: 0;
overflow: scroll;
overflow: hidden;
}
}

View file

@ -107,7 +107,9 @@
display: flex;
flex-direction: row;
align-items: center;
border-radius: 2.5rem;
border-radius: 0.5rem;
border: var(--focus-outline-size) solid transparent;
padding: 0rem 0.5rem;
&:focus {
outline: none;

View file

@ -5,7 +5,7 @@ const PropTypes = require('prop-types');
const classnames = require('classnames');
const { default: Icon } = require('@stremio/stremio-icons/react');
const { Button, Image } = require('stremio/components');
const useFullscreen = require('stremio/common/useFullscreen');
const { default: useFullscreen } = require('stremio/common/useFullscreen');
const usePWA = require('stremio/common/usePWA');
const SearchBar = require('./SearchBar');
const NavMenu = require('./NavMenu');

View file

@ -7,7 +7,7 @@ const { useTranslation } = require('react-i18next');
const { default: Icon } = require('@stremio/stremio-icons/react');
const { useServices } = require('stremio/services');
const { Button } = require('stremio/components');
const useFullscreen = require('stremio/common/useFullscreen');
const { default: useFullscreen } = require('stremio/common/useFullscreen');
const useProfile = require('stremio/common/useProfile');
const usePWA = require('stremio/common/usePWA');
const useTorrent = require('stremio/common/useTorrent');

View file

@ -0,0 +1,65 @@
// Copyright (C) 2017-2025 Smart code 203358507
.number-input {
user-select: text;
display: flex;
max-width: 14rem;
height: 3.5rem;
margin-bottom: 1rem;
color: var(--primary-foreground-color);
background: var(--overlay-color);
border-radius: 3.5rem;
.button {
flex: none;
width: 3.5rem;
height: 3.5rem;
padding: 1rem;
background: var(--overlay-color);
border: none;
border-radius: 100%;
cursor: pointer;
z-index: 1;
.icon {
width: 100%;
height: 100%;
}
}
.number-display {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 0 1rem;
&::-moz-focus-inner {
border: none;
}
.label {
font-size: 0.8rem;
font-weight: 400;
opacity: 0.7;
}
.value {
font-size: 1.2rem;
display: flex;
justify-content: center;
width: 100%;
color: var(--primary-foreground-color);
text-align: center;
appearance: none;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
}
}

View file

@ -0,0 +1,113 @@
// Copyright (C) 2017-2025 Smart code 203358507
import Icon from '@stremio/stremio-icons/react';
import React, { ChangeEvent, forwardRef, memo, useCallback, useState } from 'react';
import { type KeyboardEvent, type InputHTMLAttributes } from 'react';
import classnames from 'classnames';
import styles from './NumberInput.less';
import Button from '../Button';
type Props = InputHTMLAttributes<HTMLInputElement> & {
containerClassName?: string;
className?: string;
disabled?: boolean;
showButtons?: boolean;
defaultValue?: number;
label?: string;
min?: number;
max?: number;
value?: number;
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
onSubmit?: (event: KeyboardEvent<HTMLInputElement>) => void;
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
};
const NumberInput = forwardRef<HTMLInputElement, Props>(({ defaultValue = 0, showButtons, onKeyDown, onSubmit, min, max, onChange, ...props }, ref) => {
const [value, setValue] = useState(defaultValue);
const displayValue = props.value ?? value;
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
onKeyDown?.(event);
if (event.key === 'Enter') {
onSubmit?.(event);
}
}, [onKeyDown, onSubmit]);
const handleValueChange = (newValue: number) => {
if (props.value === undefined) {
setValue(newValue);
}
onChange?.({ target: { value: newValue.toString() }} as ChangeEvent<HTMLInputElement>);
};
const handleIncrement = () => {
handleValueChange(clampValueToRange((displayValue || 0) + 1));
};
const handleDecrement = () => {
handleValueChange(clampValueToRange((displayValue || 0) - 1));
};
const clampValueToRange = (value: number): number => {
const minValue = min ?? 0;
if (value < minValue) {
return minValue;
}
if (max !== undefined && value > max) {
return max;
}
return value;
};
const handleInputChange = useCallback(({ target: { valueAsNumber }}: ChangeEvent<HTMLInputElement>) => {
handleValueChange(clampValueToRange(valueAsNumber || 0));
}, []);
return (
<div className={classnames(props.containerClassName, styles['number-input'])}>
{
showButtons ?
<Button
className={styles['button']}
onClick={handleDecrement}
disabled={props.disabled || (min !== undefined ? displayValue <= min : false)}>
<Icon className={styles['icon']} name={'remove'} />
</Button>
: null
}
<div className={classnames(styles['number-display'], { [styles['buttons-container']]: showButtons })}>
{
props.label ?
<div className={styles['label']}>{props.label}</div>
: null
}
<input
ref={ref}
type={'number'}
tabIndex={0}
value={displayValue}
{...props}
className={classnames(props.className, styles['value'], { [styles.disabled]: props.disabled })}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
/>
</div>
{
showButtons ?
<Button
className={styles['button']} onClick={handleIncrement} disabled={props.disabled || (max !== undefined ? displayValue >= max : false)}>
<Icon className={styles['icon']} name={'add'} />
</Button>
: null
}
</div>
);
});
NumberInput.displayName = 'NumberInput';
export default memo(NumberInput);

View file

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

View file

@ -31,14 +31,18 @@ const Slider = ({ className, value, buffered, minimumValue, maximumValue, disabl
const retainThumb = React.useCallback(() => {
window.addEventListener('blur', onBlur);
window.addEventListener('mouseup', onMouseUp);
window.addEventListener('touchend', onTouchEnd);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('touchmove', onTouchMove);
document.documentElement.className = classnames(document.documentElement.className, styles['active-slider-within']);
}, []);
const releaseThumb = React.useCallback(() => {
cancelThumbAnimation();
window.removeEventListener('blur', onBlur);
window.removeEventListener('mouseup', onMouseUp);
window.removeEventListener('touchend', onTouchEnd);
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('touchmove', onTouchMove);
const classList = document.documentElement.className.split(' ');
const classIndex = classList.indexOf(styles['active-slider-within']);
if (classIndex !== -1) {
@ -85,6 +89,36 @@ const Slider = ({ className, value, buffered, minimumValue, maximumValue, disabl
retainThumb();
}, []);
const onTouchStart = React.useCallback((event) => {
const touch = event.touches[0];
const value = calculateValueForMouseX(touch.clientX);
if (typeof onSlideRef.current === 'function') {
onSlideRef.current(value);
}
retainThumb();
event.preventDefault();
}, []);
const onTouchMove = React.useCallback((event) => {
requestThumbAnimation(() => {
const touch = event.touches[0];
const value = calculateValueForMouseX(touch.clientX);
if (typeof onSlideRef.current === 'function') {
onSlideRef.current(value);
}
});
event.preventDefault();
}, []);
const onTouchEnd = React.useCallback((event) => {
const touch = event.changedTouches[0];
const value = calculateValueForMouseX(touch.clientX);
if (typeof onCompleteRef.current === 'function') {
onCompleteRef.current(value);
}
releaseThumb();
}, []);
React.useLayoutEffect(() => {
if (!routeFocused || disabled) {
releaseThumb();
@ -98,7 +132,7 @@ const Slider = ({ className, value, buffered, minimumValue, maximumValue, disabl
const thumbPosition = Math.max(0, Math.min(1, (valueRef.current - minimumValueRef.current) / (maximumValueRef.current - minimumValueRef.current)));
const bufferedPosition = Math.max(0, Math.min(1, (bufferedRef.current - minimumValueRef.current) / (maximumValueRef.current - minimumValueRef.current)));
return (
<div ref={sliderContainerRef} className={classnames(className, styles['slider-container'], { 'disabled': disabled })} onMouseDown={onMouseDown}>
<div ref={sliderContainerRef} className={classnames(className, styles['slider-container'], { 'disabled': disabled })} onMouseDown={onMouseDown} onTouchStart={onTouchStart}>
<div className={styles['layer']}>
<div className={classnames(styles['track'], { [styles['audio-boost']]: audioBoost })} />
</div>

View file

@ -46,7 +46,8 @@ html.active-slider-within {
width: 100%;
height: var(--track-size);
border-radius: var(--track-size);
background-color: var(--overlay-color);
background-color: var(--primary-accent-color);
opacity: 0.2;
&.audio-boost {
opacity: 0.3;

View file

@ -8,11 +8,13 @@ const { useRouteFocused } = require('stremio-router');
const { default: Icon } = require('@stremio/stremio-icons/react');
const { Button, Image, Popup } = require('stremio/components');
const useBinaryState = require('stremio/common/useBinaryState');
const useProfile = require('stremio/common/useProfile');
const VideoPlaceholder = require('./VideoPlaceholder');
const styles = require('./styles');
const Video = ({ className, id, title, thumbnail, season, episode, released, upcoming, watched, progress, scheduled, seasonWatched, deepLinks, onMarkVideoAsWatched, onMarkSeasonAsWatched, ...props }) => {
const routeFocused = useRouteFocused();
const profile = useProfile();
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
const popupLabelOnMouseUp = React.useCallback((event) => {
if (!event.nativeEvent.togglePopupPrevented) {
@ -66,13 +68,14 @@ const Video = ({ className, id, title, thumbnail, season, episode, released, upc
}
}, [deepLinks]);
const renderLabel = React.useMemo(() => function renderLabel({ className, id, title, thumbnail, episode, released, upcoming, watched, progress, scheduled, children, ...props }) {
const blurThumbnail = profile.settings.hideSpoilers && season && episode && !watched;
return (
<Button {...props} className={classnames(className, styles['video-container'])} title={title}>
{
typeof thumbnail === 'string' && thumbnail.length > 0 ?
<div className={styles['thumbnail-container']}>
<Image
className={styles['thumbnail']}
className={classnames(styles['thumbnail'], { [styles['blurred']]: blurThumbnail })}
src={thumbnail}
alt={' '}
renderFallback={() => (

View file

@ -41,6 +41,11 @@
object-position: center;
opacity: 0.9;
background-color: var(--overlay-color);
&.blurred {
filter: blur(0.5rem);
-webkit-filter: blur(0.5rem);
}
}
.placeholder-icon {

View file

@ -4,6 +4,7 @@ import Button from './Button';
import Checkbox from './Checkbox';
import Chips from './Chips';
import ColorInput from './ColorInput';
import ContextMenu from './ContextMenu';
import ContinueWatchingItem from './ContinueWatchingItem';
import DelayedRenderer from './DelayedRenderer';
import EventModal from './EventModal';
@ -18,6 +19,7 @@ import ModalDialog from './ModalDialog';
import Multiselect from './Multiselect';
import MultiselectMenu from './MultiselectMenu';
import { HorizontalNavBar, VerticalNavBar } from './NavBar';
import NumberInput from './NumberInput';
import Popup from './Popup';
import RadioButton from './RadioButton';
import SearchBar from './SearchBar';
@ -35,6 +37,7 @@ export {
Checkbox,
Chips,
ColorInput,
ContextMenu,
ContinueWatchingItem,
DelayedRenderer,
EventModal,
@ -50,6 +53,7 @@ export {
MultiselectMenu,
HorizontalNavBar,
VerticalNavBar,
NumberInput,
Popup,
RadioButton,
SearchBar,

View file

@ -15,6 +15,7 @@
<div id="app"></div>
<%= htmlWebpackPlugin.tags.bodyTags %>
<script src="//www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script>
<script async src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"></script>
</body>
</html>

View file

@ -82,6 +82,46 @@
}
}
&.placeholder {
opacity: 0.7;
pointer-events: none;
.text {
width: 8rem;
height: 1.2rem;
background-color: var(--overlay-color);
border-radius: 0.2rem;
}
.video {
flex: none;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1rem;
height: 3rem;
padding: 0 1rem;
.name {
flex: auto;
width: 12rem;
height: 1.2rem;
background-color: var(--overlay-color);
border-radius: 0.2rem;
}
.info {
flex: none;
width: 4rem;
height: 1.2rem;
background-color: var(--overlay-color);
border-radius: 0.2rem;
}
}
}
&.today {
.heading {
background-color: var(--primary-accent-color);

View file

@ -0,0 +1,23 @@
// Copyright (C) 2017-2025 Smart code 203358507
import React from 'react';
import classNames from 'classnames';
import styles from './Item.less';
const ItemPlaceholder = () => {
return (
<div className={classNames(styles['item'], styles['placeholder'])}>
<div className={styles['heading']}>
<div className={styles['text']} />
</div>
<div className={styles['body']}>
<div className={styles['video']}>
<div className={styles['name']} />
<div className={styles['info']} />
</div>
</div>
</div>
);
};
export default ItemPlaceholder;

View file

@ -1,5 +1,6 @@
// Copyright (C) 2017-2024 Smart code 203358507
import Item from './Item';
import ItemPlaceholder from './ItemPlaceholder';
export default Item;
export { Item, ItemPlaceholder };

View file

@ -1,7 +1,7 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React, { useMemo } from 'react';
import Item from './Item';
import { Item, ItemPlaceholder } from './Item';
import styles from './List.less';
type Props = {
@ -20,16 +20,21 @@ const List = ({ items, selected, monthInfo, profile, onChange }: Props) => {
return (
<div className={styles['list']}>
{
filteredItems.map((item) => (
<Item
key={item.date.day}
{...item}
selected={selected}
monthInfo={monthInfo}
profile={profile}
onClick={onChange}
/>
))
items.length === 0 ?
[1, 2, 3].map((index) => (
<ItemPlaceholder key={index} />
))
:
filteredItems.map((item) => (
<Item
key={item.date.day}
{...item}
selected={selected}
monthInfo={monthInfo}
profile={profile}
onClick={onChange}
/>
))
}
</div>
);

View file

@ -11,7 +11,7 @@ const useDiscover = require('./useDiscover');
const useSelectableInputs = require('./useSelectableInputs');
const styles = require('./styles');
const SCROLL_TO_BOTTOM_TRESHOLD = 400;
const SCROLL_TO_BOTTOM_THRESHOLD = 400;
const Discover = ({ urlParams, queryParams }) => {
const { core } = useServices();
@ -26,6 +26,15 @@ const Discover = ({ urlParams, queryParams }) => {
metasContainerRef.current.scrollTop = 0;
}
}, [discover.catalog]);
React.useEffect(() => {
if (hasNextPage && metasContainerRef.current) {
const containerHeight = metasContainerRef.current.scrollHeight;
const viewportHeight = metasContainerRef.current.clientHeight;
if (containerHeight <= viewportHeight + SCROLL_TO_BOTTOM_THRESHOLD) {
loadNextPage();
}
}
}, [hasNextPage, loadNextPage]);
const selectedMetaItem = React.useMemo(() => {
return discover.catalog !== null &&
discover.catalog.content.type === 'Ready' &&
@ -76,7 +85,7 @@ const Discover = ({ urlParams, queryParams }) => {
loadNextPage();
}
}, [hasNextPage, loadNextPage]);
const onScroll = useOnScrollToBottom(onScrollToBottom, SCROLL_TO_BOTTOM_TRESHOLD);
const onScroll = useOnScrollToBottom(onScrollToBottom, SCROLL_TO_BOTTOM_THRESHOLD);
React.useEffect(() => {
closeInputsModal();
closeAddonModal();

View file

@ -12,6 +12,8 @@ const { Button, Image, Checkbox } = require('stremio/components');
const CredentialsTextInput = require('./CredentialsTextInput');
const PasswordResetModal = require('./PasswordResetModal');
const useFacebookLogin = require('./useFacebookLogin');
const { default: useAppleLogin } = require('./useAppleLogin');
const styles = require('./styles');
const SIGNUP_FORM = 'signup';
@ -22,6 +24,7 @@ const Intro = ({ queryParams }) => {
const { t } = useTranslation();
const routeFocused = useRouteFocused();
const [startFacebookLogin, stopFacebookLogin] = useFacebookLogin();
const [startAppleLogin, stopAppleLogin] = useAppleLogin();
const emailRef = React.useRef(null);
const passwordRef = React.useRef(null);
const confirmPasswordRef = React.useRef(null);
@ -106,6 +109,37 @@ const Intro = ({ queryParams }) => {
stopFacebookLogin();
closeLoaderModal();
}, []);
const loginWithApple = React.useCallback(() => {
openLoaderModal();
startAppleLogin()
.then(({ email, token, sub, name }) => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'Authenticate',
args: {
type: 'Apple',
token,
sub,
email,
name
}
}
});
})
.catch((error) => {
closeLoaderModal();
if (error.error === 'popup_closed_by_user') {
dispatch({ type: 'error', error: 'Apple login popup was closed.' });
} else {
dispatch({ type: 'error', error: error.error });
}
});
}, []);
const cancelLoginWithApple = React.useCallback(() => {
stopAppleLogin();
closeLoaderModal();
}, []);
const loginWithEmail = React.useCallback(() => {
if (typeof state.email !== 'string' || state.email.length === 0 || !emailRef.current.validity.valid) {
dispatch({ type: 'error', error: 'Invalid email' });
@ -336,7 +370,7 @@ const Intro = ({ queryParams }) => {
</div>
}
{
state.error.length > 0 ?
state.error && state.error.length > 0 ?
<div ref={errorRef} className={styles['error-message']}>{state.error}</div>
:
null
@ -350,6 +384,10 @@ const Intro = ({ queryParams }) => {
<Icon className={styles['icon']} name={'facebook'} />
<div className={styles['label']}>Continue with Facebook</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>
</Button>
{
state.form === SIGNUP_FORM ?
<Button className={classnames(styles['form-button'], styles['login-form-button'])} onClick={switchFormOnClick}>
@ -388,7 +426,7 @@ const Intro = ({ queryParams }) => {
<div className={styles['loader-container']}>
<Icon className={styles['icon']} name={'person'} />
<div className={styles['label']}>Authenticating...</div>
<Button className={styles['button']} onClick={cancelLoginWithFacebook}>
<Button className={styles['button']} onClick={cancelLoginWithFacebook && cancelLoginWithApple}>
{t('BUTTON_CANCEL')}
</Button>
</div>

View file

@ -175,15 +175,43 @@
position: relative;
width: 22rem;
margin-left: 2rem;
display: flex;
flex-direction: column;
.facebook-button {
background: var(--color-facebook);
margin-bottom: 1rem;
&:hover, &:focus {
outline: var(--focus-outline-size) solid var(--color-facebook);
background-color: transparent;
}
}
.apple-button {
background: var(--primary-foreground-color);
.icon {
color: var(--primary-background-color);
}
.label {
color: var(--primary-background-color);
}
&:hover, &:focus {
outline: var(--focus-outline-size) solid var(--primary-foreground-color);
background-color: transparent;
.icon {
color: var(--primary-foreground-color);
}
.label {
color: var(--primary-foreground-color);
}
}
}
}
}
}

View file

@ -0,0 +1,104 @@
import { useCallback, useEffect, useRef } from 'react';
import { jwtDecode, JwtPayload } from 'jwt-decode';
type AppleLoginResponse = {
token: string;
sub: string;
email: string;
name: string;
};
type AppleSignInResponse = {
authorization: {
code?: string;
id_token: string;
state?: string;
};
email?: string;
fullName?: {
firstName?: string;
lastName?: string;
};
};
type CustomJWTPayload = JwtPayload & {
email?: string;
};
const CLIENT_ID = 'com.stremio.services';
const useAppleLogin = (): [() => Promise<AppleLoginResponse>, () => void] => {
const started = useRef(false);
const start = useCallback((): Promise<AppleLoginResponse> => {
return new Promise((resolve, reject) => {
if (typeof window.AppleID === 'undefined') {
reject(new Error('Apple Sign-In not loaded'));
return;
}
if (started.current) {
reject(new Error('Apple login already in progress'));
return;
}
started.current = true;
window.AppleID.auth.init({
clientId: CLIENT_ID,
scope: 'name email',
redirectURI: 'https://web.stremio.com/',
state: 'signin',
usePopup: true,
});
window.AppleID.auth.signIn().then((response: AppleSignInResponse) => {
if (response.authorization) {
try {
const idToken = response.authorization.id_token;
const payload: CustomJWTPayload = jwtDecode(idToken);
const sub = payload.sub;
const email = payload.email ?? response.email ?? '';
let name = '';
if (response.fullName) {
const firstName = response.fullName.firstName || '';
const lastName = response.fullName.lastName || '';
name = [firstName, lastName].filter(Boolean).join(' ');
}
if (!sub) {
reject(new Error('No sub token received from Apple'));
return;
}
resolve({
token: idToken,
sub: sub,
email: email,
name: name,
});
} catch (error) {
reject(new Error(`Failed to parse id_token: ${error}`));
}
} else {
reject(new Error('No authorization received from Apple'));
}
}).catch((error) => {
reject(error);
});
});
}, []);
const stop = useCallback(() => {
started.current = false;
}, []);
useEffect(() => {
return () => stop();
}, []);
return [start, stop];
};
export default useAppleLogin;

View file

@ -63,6 +63,11 @@ const Library = ({ model, urlParams, queryParams }) => {
scrollContainerRef.current.scrollTop = 0;
}
}, [profile.auth, library.selected]);
React.useEffect(() => {
if (!library.selected?.type && typeSelect.selected) {
window.location = typeSelect.selected[0];
}
}, [typeSelect.selected, library.selected]);
return (
<MainNavBars className={styles['library-container']} route={model}>
{

View file

@ -4,6 +4,8 @@ const React = require('react');
const { useTranslate } = require('stremio/common');
const mapSelectableInputs = (library, t) => {
const selectedType = library.selectable.types
.filter(({ selected }) => selected).map(({ deepLinks }) => deepLinks.library);
const typeSelect = {
title: t.string('SELECT_TYPE'),
options: library.selectable.types
@ -11,9 +13,9 @@ const mapSelectableInputs = (library, t) => {
value: deepLinks.library,
label: type === null ? t.string('TYPE_ALL') : t.stringWithPrefix(type, 'TYPE_')
})),
selected: library.selectable.types
.filter(({ selected }) => selected)
.map(({ deepLinks }) => deepLinks.library),
selected: selectedType.length
? selectedType
: [library.selectable.types[0]].map(({ deepLinks }) => deepLinks.library),
onSelect: (event) => {
window.location = event.value;
}

View file

@ -0,0 +1,29 @@
// Copyright (C) 2017-2025 Smart code 203358507
.button-container {
flex: none;
align-self: stretch;
display: flex;
align-items: center;
justify-content: center;
border: var(--focus-outline-size) solid var(--primary-accent-color);
background-color: var(--primary-accent-color);
height: 4rem;
padding: 0 2rem;
margin: 1rem auto;
border-radius: 2rem;
&:hover {
background-color: transparent;
}
.label {
flex: 0 1 auto;
font-size: 1rem;
font-weight: 700;
max-height: 3.5rem;
text-align: center;
color: var(--primary-foreground-color);
margin-bottom: 0;
}
}

View file

@ -0,0 +1,71 @@
// Copyright (C) 2017-2025 Smart code 203358507
import React, { useCallback, useMemo, useState, ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, NumberInput } from 'stremio/components';
import styles from './EpisodePicker.less';
type Props = {
className?: string,
seriesId: string;
onSubmit: (season: number, episode: number) => void;
};
const EpisodePicker = ({ className, onSubmit }: Props) => {
const { t } = useTranslation();
const { initialSeason, initialEpisode } = useMemo(() => {
const splitPath = window.location.hash.split('/');
const videoId = decodeURIComponent(splitPath[splitPath.length - 1]);
const [, pathSeason, pathEpisode] = videoId ? videoId.split(':') : [];
return {
initialSeason: parseInt(pathSeason) || 0,
initialEpisode: parseInt(pathEpisode) || 1
};
}, []);
const [season, setSeason] = useState(initialSeason);
const [episode, setEpisode] = useState(initialEpisode);
const handleSeasonChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setSeason(parseInt(event.target.value));
}, []);
const handleEpisodeChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setEpisode(parseInt(event.target.value));
}, []);
const handleSubmit = () => {
onSubmit(season, episode);
};
const disabled = season === initialSeason && episode === initialEpisode;
return (
<div className={className}>
<NumberInput
min={0}
label={t('SEASON')}
defaultValue={season}
onChange={handleSeasonChange}
showButtons
/>
<NumberInput
min={1}
label={t('EPISODE')}
defaultValue={episode}
onChange={handleEpisodeChange}
showButtons
/>
<Button
className={styles['button-container']}
onClick={handleSubmit}
disabled={disabled}
>
<div className={styles['label']}>{t('SIDEBAR_SHOW_STREAMS')}</div>
</Button>
</div>
);
};
export default EpisodePicker;

View file

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

View file

@ -76,6 +76,13 @@ const MetaDetails = ({ urlParams, queryParams }) => {
const seasonOnSelect = React.useCallback((event) => {
setSeason(event.value);
}, [setSeason]);
const handleEpisodeSearch = React.useCallback((season, episode) => {
const searchVideoHash = encodeURIComponent(`${urlParams.id}:${season}:${episode}`);
const url = window.location.hash;
const searchVideoPath = url.replace(encodeURIComponent(urlParams.videoId), searchVideoHash);
window.location = searchVideoPath;
}, [urlParams, window.location]);
const renderBackgroundImageFallback = React.useCallback(() => null, []);
const renderBackground = React.useMemo(() => !!(
metaPath &&
@ -129,7 +136,7 @@ const MetaDetails = ({ urlParams, queryParams }) => {
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 ware requested for this meta!</div>
<div className={styles['message-label']}>No addons were requested for this meta!</div>
</div>
:
metaDetails.metaItem.content.type === 'Err' ?
@ -169,6 +176,8 @@ const MetaDetails = ({ urlParams, queryParams }) => {
className={styles['streams-list']}
streams={metaDetails.streams}
video={video}
type={streamPath.type}
onEpisodeSearch={handleEpisodeSearch}
/>
:
metaPath !== null ?

View file

@ -10,10 +10,11 @@ const { useServices } = require('stremio/services');
const Stream = require('./Stream');
const styles = require('./styles');
const { usePlatform, useProfile } = require('stremio/common');
const { default: SeasonEpisodePicker } = require('../EpisodePicker');
const ALL_ADDONS_KEY = 'ALL';
const StreamsList = ({ className, video, ...props }) => {
const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
const { t } = useTranslation();
const { core } = useServices();
const platform = usePlatform();
@ -25,8 +26,8 @@ const StreamsList = ({ className, video, ...props }) => {
setSelectedAddon(event.value);
}, [platform]);
const showInstallAddonsButton = React.useMemo(() => {
return !profile || profile.auth === null || profile.auth?.user?.isNewUser === true;
}, [profile]);
return !profile || profile.auth === null || profile.auth?.user?.isNewUser === true && !video?.upcoming;
}, [profile, video]);
const backButtonOnClick = React.useCallback(() => {
if (video.deepLinks && typeof video.deepLinks.metaDetailsVideos === 'string') {
window.location.replace(video.deepLinks.metaDetailsVideos + (
@ -93,6 +94,11 @@ const StreamsList = ({ className, video, ...props }) => {
onSelect: onAddonSelected
};
}, [streamsByAddon, selectedAddon]);
const handleEpisodePicker = React.useCallback((season, episode) => {
onEpisodeSearch(season, episode);
}, [onEpisodeSearch]);
return (
<div className={classnames(className, styles['streams-list-container'])}>
<div className={styles['select-choices-wrapper']}>
@ -122,12 +128,27 @@ const StreamsList = ({ className, video, ...props }) => {
{
props.streams.length === 0 ?
<div className={styles['message-container']}>
{
type === 'series' ?
<SeasonEpisodePicker className={styles['search']} onSubmit={handleEpisodePicker} />
: null
}
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
<div className={styles['label']}>No addons were requested for streams!</div>
</div>
:
props.streams.every((streams) => streams.content.type === 'Err') ?
<div className={styles['message-container']}>
{
type === 'series' ?
<SeasonEpisodePicker className={styles['search']} onSubmit={handleEpisodePicker} />
: null
}
{
video?.upcoming ?
<div className={styles['label']}>{t('UPCOMING')}...</div>
: null
}
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
<div className={styles['label']}>{t('NO_STREAM')}</div>
{
@ -193,7 +214,9 @@ const StreamsList = ({ className, video, ...props }) => {
StreamsList.propTypes = {
className: PropTypes.string,
streams: PropTypes.arrayOf(PropTypes.object).isRequired,
video: PropTypes.object
video: PropTypes.object,
type: PropTypes.string,
onEpisodeSearch: PropTypes.func
};
module.exports = StreamsList;

View file

@ -22,6 +22,10 @@
padding: 1rem;
overflow-y: auto;
.search {
flex: none;
}
.image {
flex: none;
width: 10rem;
@ -38,6 +42,7 @@
font-size: 1.4rem;
text-align: center;
color: var(--primary-foreground-color);
margin-bottom: 2rem;
}
}
@ -171,6 +176,7 @@
max-height: 3.6em;
text-align: center;
color: var(--primary-foreground-color);
margin-bottom: 0;
}
}
}

View file

@ -7,6 +7,7 @@ const { t } = require('i18next');
const { useServices } = require('stremio/services');
const { Image, SearchBar, Toggle, Video } = require('stremio/components');
const SeasonsBar = require('./SeasonsBar');
const { default: EpisodePicker } = require('../EpisodePicker');
const styles = require('./styles');
const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect, toggleNotifications }) => {
@ -92,6 +93,15 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
});
};
const onSeasonSearch = (value) => {
if (value) {
seasonOnSelect({
type: 'select',
value,
});
}
};
return (
<div className={classnames(className, styles['videos-list-container'])}>
{
@ -110,6 +120,7 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
:
metaItem.content.type === 'Err' || videosForSeason.length === 0 ?
<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>

View file

@ -13,10 +13,13 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
overflow-y: auto;
.episode-picker {
margin-bottom: 2rem;
}
.image {
flex: none;
width: 10rem;

View file

@ -6,9 +6,9 @@ const classnames = require('classnames');
const { Image } = require('stremio/components');
const styles = require('./styles');
const BufferingLoader = ({ className, logo }) => {
const BufferingLoader = React.forwardRef(({ className, logo }, ref) => {
return (
<div className={classnames(className, styles['buffering-loader-container'])}>
<div ref={ref} className={classnames(className, styles['buffering-loader-container'])}>
<Image
className={styles['buffering-loader']}
src={logo}
@ -17,11 +17,11 @@ const BufferingLoader = ({ className, logo }) => {
/>
</div>
);
};
});
BufferingLoader.propTypes = {
className: PropTypes.string,
logo: PropTypes.string
logo: PropTypes.string,
};
module.exports = BufferingLoader;

View file

@ -9,7 +9,7 @@ const { useServices } = require('stremio/services');
const SeekBar = require('./SeekBar');
const VolumeSlider = require('./VolumeSlider');
const styles = require('./styles');
const { useBinaryState } = require('stremio/common');
const { useBinaryState, usePlatform } = require('stremio/common');
const { t } = require('i18next');
const ControlBar = ({
@ -40,9 +40,11 @@ const ControlBar = ({
onToggleSideDrawer,
onToggleOptionsMenu,
onToggleStatisticsMenu,
onTouchEnd,
...props
}) => {
const { chromecast } = useServices();
const platform = usePlatform();
const [chromecastServiceActive, setChromecastServiceActive] = React.useState(() => chromecast.active);
const [buttonsMenuOpen, , , toggleButtonsMenu] = useBinaryState(false);
const onSubtitlesButtonMouseDown = React.useCallback((event) => {
@ -103,7 +105,7 @@ const ControlBar = ({
};
}, []);
return (
<div {...props} className={classnames(className, styles['control-bar-container'])}>
<div {...props} onTouchStart={props.onMouseOver} onTouchMove={props.onMouseMove} onTouchEnd={onTouchEnd} className={classnames(className, styles['control-bar-container'])}>
<SeekBar
className={styles['seek-bar']}
time={time}
@ -129,18 +131,23 @@ const ControlBar = ({
name={
(typeof muted === 'boolean' && muted) ? 'volume-mute' :
(volume === null || isNaN(volume)) ? 'volume-off' :
volume < 30 ? 'volume-low' :
volume < 70 ? 'volume-medium' :
'volume-high'
volume === 0 ? 'volume-mute' :
volume < 30 ? 'volume-low' :
volume < 70 ? 'volume-medium' :
'volume-high'
}
/>
</Button>
<VolumeSlider
className={styles['volume-slider']}
volume={volume}
muted={muted}
onVolumeChangeRequested={onVolumeChangeRequested}
/>
{
!platform.isMobile ?
<VolumeSlider
className={styles['volume-slider']}
volume={volume}
muted={muted}
onVolumeChangeRequested={onVolumeChangeRequested}
/>
: null
}
<div className={styles['spacing']} />
<Button className={styles['control-bar-buttons-menu-button']} onClick={toggleButtonsMenu}>
<Icon className={styles['icon']} name={'more-vertical'} />
@ -169,7 +176,7 @@ const ControlBar = ({
:
null
}
<Button className={styles['control-bar-button']} tabIndex={-1} onMouseDown={onOptionsButtonMouseDown} onClick={onToggleOptionsMenu}>
<Button className={classnames(styles['control-bar-button'], { 'disabled': !stream })} tabIndex={-1} onMouseDown={onOptionsButtonMouseDown} onClick={onToggleOptionsMenu}>
<Icon className={styles['icon']} name={'more-horizontal'} />
</Button>
</div>
@ -206,6 +213,9 @@ ControlBar.propTypes = {
onToggleSideDrawer: PropTypes.func,
onToggleOptionsMenu: PropTypes.func,
onToggleStatisticsMenu: PropTypes.func,
onMouseOver: PropTypes.func,
onMouseMove: PropTypes.func,
onTouchEnd: PropTypes.func,
};
module.exports = ControlBar;

View file

@ -8,7 +8,7 @@ const { default: Icon } = require('@stremio/stremio-icons/react');
const { Button } = require('stremio/components');
const styles = require('./styles');
const Error = ({ className, code, message, stream }) => {
const Error = React.forwardRef(({ className, code, message, stream }, ref) => {
const { t } = useTranslation();
const [playlist, fileName] = React.useMemo(() => {
@ -19,7 +19,7 @@ const Error = ({ className, code, message, stream }) => {
}, [stream]);
return (
<div className={classNames(className, styles['error'])}>
<div ref={ref} className={classNames(className, styles['error'])}>
<div className={styles['error-label']} title={message}>{message}</div>
{
code === 2 ?
@ -44,7 +44,7 @@ const Error = ({ className, code, message, stream }) => {
}
</div>
);
};
});
Error.propTypes = {
className: PropTypes.string,

View file

@ -4,11 +4,13 @@ const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { default: Icon } = require('@stremio/stremio-icons/react');
const { CONSTANTS } = require('stremio/common');
const { CONSTANTS, useProfile } = require('stremio/common');
const { Button, Image } = require('stremio/components');
const styles = require('./styles');
const NextVideoPopup = ({ className, metaItem, nextVideo, onDismiss, onNextVideoRequested }) => {
const profile = useProfile();
const blurPosterImage = profile.settings.hideSpoilers && metaItem.type === 'series';
const watchNowButtonRef = React.useRef(null);
const [animationEnded, setAnimationEnded] = React.useState(false);
const videoName = React.useMemo(() => {
@ -51,7 +53,7 @@ const NextVideoPopup = ({ className, metaItem, nextVideo, onDismiss, onNextVideo
<div className={classnames(className, styles['next-video-popup-container'])} onAnimationEnd={onAnimationEnd}>
<div className={styles['poster-container']}>
<Image
className={styles['poster-image']}
className={classnames(styles['poster-image'], { [styles['blurred']]: blurPosterImage })}
src={nextVideo?.thumbnail}
alt={' '}
fallbackSrc={metaItem?.poster}

View file

@ -35,6 +35,11 @@
height: 100%;
object-position: center;
object-fit: cover;
&.blurred {
filter: blur(0.5rem);
-webkit-filter: blur(0.5rem);
}
}
.placeholder-icon {

View file

@ -69,6 +69,7 @@ const OptionsMenu = ({ className, stream, playbackDevices }) => {
const onMouseDown = React.useCallback((event) => {
event.nativeEvent.optionsMenuClosePrevented = true;
}, []);
return (
<div className={classnames(className, styles['options-menu-container'])} onMouseDown={onMouseDown}>
{
@ -112,7 +113,7 @@ const OptionsMenu = ({ className, stream, playbackDevices }) => {
OptionsMenu.propTypes = {
className: PropTypes.string,
stream: PropTypes.object,
playbackDevices: PropTypes.array
playbackDevices: PropTypes.array,
};
module.exports = OptionsMenu;

View file

@ -8,8 +8,8 @@ const langs = require('langs');
const { useTranslation } = require('react-i18next');
const { useRouteFocused } = require('stremio-router');
const { useServices } = require('stremio/services');
const { onFileDrop, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS } = require('stremio/common');
const { HorizontalNavBar, Transition } = require('stremio/components');
const { onFileDrop, useSettings, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS, useShell } = require('stremio/common');
const { HorizontalNavBar, Transition, ContextMenu } = require('stremio/components');
const BufferingLoader = require('./BufferingLoader');
const VolumeChangeIndicator = require('./VolumeChangeIndicator');
const Error = require('./Error');
@ -23,7 +23,6 @@ const SpeedMenu = require('./SpeedMenu');
const { default: SideDrawerButton } = require('./SideDrawerButton');
const { default: SideDrawer } = require('./SideDrawer');
const usePlayer = require('./usePlayer');
const useSettings = require('./useSettings');
const useStatistics = require('./useStatistics');
const useVideo = require('./useVideo');
const styles = require('./styles');
@ -31,7 +30,8 @@ const Video = require('./Video');
const Player = ({ urlParams, queryParams }) => {
const { t } = useTranslation();
const { chromecast, shell, core } = useServices();
const services = useServices();
const shell = useShell();
const forceTranscoding = React.useMemo(() => {
return queryParams.has('forceTranscoding');
}, [queryParams]);
@ -47,8 +47,12 @@ const Player = ({ urlParams, queryParams }) => {
const [seeking, setSeeking] = React.useState(false);
const [casting, setCasting] = React.useState(() => {
return chromecast.active && chromecast.transport.getCastState() === cast.framework.CastState.CONNECTED;
return services.chromecast.active && services.chromecast.transport.getCastState() === cast.framework.CastState.CONNECTED;
});
const playbackDevices = React.useMemo(() => streamingServer.playbackDevices !== null && streamingServer.playbackDevices.type === 'Ready' ? streamingServer.playbackDevices.content : [], [streamingServer]);
const bufferingRef = React.useRef();
const errorRef = React.useRef();
const [immersed, setImmersed] = React.useState(true);
const setImmersedDebounced = React.useCallback(debounce(setImmersed, 3000), []);
@ -317,8 +321,8 @@ const Player = ({ urlParams, queryParams }) => {
null,
seriesInfo: player.seriesInfo,
}, {
chromecastTransport: chromecast.active ? chromecast.transport : null,
shellTransport: shell.active ? shell.transport : null,
chromecastTransport: services.chromecast.active ? services.chromecast.transport : null,
shellTransport: services.shell.active ? services.shell.transport : null,
});
}
}, [streamingServer.baseUrl, player.selected, forceTranscoding, casting]);
@ -439,12 +443,12 @@ const Player = ({ urlParams, queryParams }) => {
const toastFilter = (item) => item?.dataset?.type === 'CoreEvent';
toast.addFilter(toastFilter);
const onCastStateChange = () => {
setCasting(chromecast.active && chromecast.transport.getCastState() === cast.framework.CastState.CONNECTED);
setCasting(services.chromecast.active && services.chromecast.transport.getCastState() === cast.framework.CastState.CONNECTED);
};
const onChromecastServiceStateChange = () => {
onCastStateChange();
if (chromecast.active) {
chromecast.transport.on(
if (services.chromecast.active) {
services.chromecast.transport.on(
cast.framework.CastContextEventType.CAST_STATE_CHANGED,
onCastStateChange
);
@ -455,15 +459,15 @@ const Player = ({ urlParams, queryParams }) => {
onPauseRequested();
}
};
chromecast.on('stateChanged', onChromecastServiceStateChange);
core.transport.on('CoreEvent', onCoreEvent);
services.chromecast.on('stateChanged', onChromecastServiceStateChange);
services.core.transport.on('CoreEvent', onCoreEvent);
onChromecastServiceStateChange();
return () => {
toast.removeFilter(toastFilter);
chromecast.off('stateChanged', onChromecastServiceStateChange);
core.transport.off('CoreEvent', onCoreEvent);
if (chromecast.active) {
chromecast.transport.off(
services.chromecast.off('stateChanged', onChromecastServiceStateChange);
services.core.transport.off('CoreEvent', onCoreEvent);
if (services.chromecast.active) {
services.chromecast.transport.off(
cast.framework.CastContextEventType.CAST_STATE_CHANGED,
onCastStateChange
);
@ -471,6 +475,12 @@ const Player = ({ urlParams, queryParams }) => {
};
}, []);
React.useEffect(() => {
if (settings.pauseOnMinimize && (shell.windowClosed || shell.windowHidden)) {
onPauseRequested();
}
}, [settings.pauseOnMinimize, shell.windowClosed, shell.windowHidden]);
React.useLayoutEffect(() => {
const onKeyDown = (event) => {
switch (event.code) {
@ -561,6 +571,7 @@ const Player = ({ urlParams, queryParams }) => {
}
case 'Escape': {
closeMenus();
!settings.escExitFullscreen && window.history.back();
break;
}
}
@ -591,7 +602,7 @@ const Player = ({ urlParams, queryParams }) => {
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('wheel', onWheel);
};
}, [player.metaItem, player.selected, streamingServer.statistics, settings.seekTimeDuration, settings.seekShortTimeDuration, routeFocused, menusOpen, nextVideoPopupOpen, video.state.paused, video.state.time, video.state.volume, video.state.audioTracks, video.state.subtitlesTracks, video.state.extraSubtitlesTracks, video.state.playbackSpeed, toggleSubtitlesMenu, toggleStatisticsMenu, toggleSideDrawer]);
}, [player.metaItem, player.selected, streamingServer.statistics, settings.seekTimeDuration, settings.seekShortTimeDuration, settings.escExitFullscreen, routeFocused, menusOpen, nextVideoPopupOpen, video.state.paused, video.state.time, video.state.volume, video.state.audioTracks, video.state.subtitlesTracks, video.state.extraSubtitlesTracks, video.state.playbackSpeed, toggleSubtitlesMenu, toggleStatisticsMenu, toggleSideDrawer]);
React.useEffect(() => {
video.events.on('error', onError);
@ -626,7 +637,7 @@ const Player = ({ urlParams, queryParams }) => {
onMouseOver={onContainerMouseMove}
onMouseLeave={onContainerMouseLeave}>
<Video
ref={video.containerElement}
ref={video.containerRef}
className={styles['layer']}
onClick={onVideoClick}
onDoubleClick={onVideoDoubleClick}
@ -641,13 +652,18 @@ const Player = ({ urlParams, queryParams }) => {
}
{
(video.state.buffering || !video.state.loaded) && !error ?
<BufferingLoader className={classnames(styles['layer'], styles['buffering-layer'])} logo={player?.metaItem?.content?.logo} />
<BufferingLoader
ref={bufferingRef}
className={classnames(styles['layer'], styles['buffering-layer'])}
logo={player?.metaItem?.content?.logo}
/>
:
null
}
{
error !== null ?
<Error
ref={errorRef}
className={classnames(styles['layer'], styles['error-layer'])}
stream={video.state.stream}
{...error}
@ -670,6 +686,13 @@ const Player = ({ urlParams, queryParams }) => {
:
null
}
<ContextMenu on={[video.containerRef, bufferingRef, errorRef]} autoClose>
<OptionsMenu
className={classnames(styles['layer'], styles['menu-layer'])}
stream={player?.selected?.stream}
playbackDevices={playbackDevices}
/>
</ContextMenu>
<HorizontalNavBar
className={classnames(styles['layer'], styles['nav-bar-layer'])}
title={player.title !== null ? player.title : ''}
@ -717,6 +740,7 @@ const Player = ({ urlParams, queryParams }) => {
onToggleSideDrawer={toggleSideDrawer}
onMouseMove={onBarMouseMove}
onMouseOver={onBarMouseMove}
onTouchEnd={onContainerMouseLeave}
/>
{
nextVideoPopupOpen ?
@ -797,7 +821,7 @@ const Player = ({ urlParams, queryParams }) => {
<OptionsMenu
className={classnames(styles['layer'], styles['menu-layer'])}
stream={player.selected.stream}
playbackDevices={streamingServer.playbackDevices !== null && streamingServer.playbackDevices.type === 'Ready' ? streamingServer.playbackDevices.content : []}
playbackDevices={playbackDevices}
/>
:
null

View file

@ -14,11 +14,12 @@ const VolumeChangeIndicator = React.memo(({ muted, volume }) => {
const prevVolume = React.useRef(volume);
const iconName = React.useMemo(() => {
return typeof muted === 'boolean' && muted ? 'volume-mute' :
return (typeof muted === 'boolean' && muted) ? 'volume-mute' :
volume === null || isNaN(volume) ? 'volume-off' :
volume < 30 ? 'volume-low' :
volume < 70 ? 'volume-medium' :
'volume-high';
volume === 0 ? 'volume-mute' :
volume < 30 ? 'volume-low' :
volume < 70 ? 'volume-medium' :
'volume-high';
}, [muted, volume]);
React.useEffect(() => {

View file

@ -1,2 +0,0 @@
declare const useSettings: () => [Settings, (settings: any) => void];
export = useSettings;

View file

@ -8,7 +8,7 @@ const events = new EventEmitter();
const useVideo = () => {
const video = React.useRef(null);
const containerElement = React.useRef(null);
const containerRef = React.useRef(null);
const [state, setState] = React.useState({
manifest: null,
@ -42,11 +42,11 @@ const useVideo = () => {
});
const dispatch = (action, options) => {
if (video.current && containerElement.current) {
if (video.current && containerRef.current) {
try {
video.current.dispatch(action, {
...options,
containerElement: containerElement.current,
containerElement: containerRef.current,
});
} catch (error) {
console.error('Video:', error);
@ -157,7 +157,7 @@ const useVideo = () => {
return {
events,
containerElement,
containerRef,
state,
load,
unload,

View file

@ -31,6 +31,7 @@ const Settings = () => {
const toast = useToast();
const {
interfaceLanguageSelect,
hideSpoilersToggle,
subtitlesLanguageSelect,
subtitlesSizeSelect,
subtitlesTextColorInput,
@ -47,6 +48,7 @@ const Settings = () => {
bingeWatchingToggle,
playInBackgroundToggle,
hardwareDecodingToggle,
pauseOnMinimizeToggle,
} = useProfileSettingsInputs(profile);
const {
streamingServerRemoteUrlInput,
@ -181,7 +183,12 @@ const Settings = () => {
{ t('SETTINGS_NAV_SHORTCUTS') }
</Button>
<div className={styles['spacing']} />
<div className={styles['version-info-label']} title={process.env.VERSION}>App Version: {process.env.VERSION}</div>
<div className={styles['version-info-label']} title={process.env.VERSION}>
App Version: {process.env.VERSION}
</div>
<div className={styles['version-info-label']} title={process.env.COMMIT_HASH}>
Build Version: {process.env.COMMIT_HASH}
</div>
{
streamingServer.settings !== null && streamingServer.settings.type === 'Ready' ?
<div className={styles['version-info-label']} title={streamingServer.settings.content.serverVersion}>Server Version: {streamingServer.settings.content.serverVersion}</div>
@ -336,12 +343,34 @@ const Settings = () => {
/>
</div>
}
{
shell.active &&
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_FULLSCREEN_EXIT') }</div>
</div>
<Toggle
className={classnames(styles['option-input-container'], styles['toggle-container'])}
{...escExitFullscreenToggle}
/>
</div>
}
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_BLUR_UNWATCHED_IMAGE') }</div>
</div>
<Toggle
className={classnames(styles['option-input-container'], styles['toggle-container'])}
tabIndex={-1}
{...hideSpoilersToggle}
/>
</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_CLOSE_WINDOW')}</div>
<div className={styles['label']}>{t('SETTINGS_SECTION_SUBTITLES')}</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
@ -352,20 +381,6 @@ const Settings = () => {
{...subtitlesLanguageSelect}
/>
</div>
{
shell.active ?
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_FULLSCREEN_EXIT') }</div>
</div>
<Toggle
className={classnames(styles['option-input-container'], styles['toggle-container'])}
{...escExitFullscreenToggle}
/>
</div>
:
null
}
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SUBTITLES_SIZE') }</div>
@ -515,6 +530,18 @@ const Settings = () => {
/>
</div>
}
{
shell.active &&
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_PAUSE_MINIMIZED') }</div>
</div>
<Toggle
className={classnames(styles['option-input-container'], styles['toggle-container'])}
{...pauseOnMinimizeToggle}
/>
</div>
}
</div>
<div ref={streamingServerSectionRef} className={styles['section-container']}>
<div className={styles['section-title']}>{ t('SETTINGS_NAV_STREAMING') }</div>
@ -722,6 +749,18 @@ const Settings = () => {
</div>
</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>
Build Version
</div>
</div>
<div className={classnames(styles['option-input-container'], styles['info-container'])}>
<div className={styles['label']}>
{process.env.COMMIT_HASH}
</div>
</div>
</div>
{
streamingServer.settings !== null && streamingServer.settings.type === 'Ready' ?
<div className={styles['option-container']}>

View file

@ -46,7 +46,7 @@ const URLsManager = () => {
}
</div>
<div className={styles['footer']}>
<Button label={'Add URL'} className={styles['add-url']} onClick={onAdd}>
<Button title={'Add URL'} className={styles['add-url']} onClick={onAdd}>
<Icon name={'add'} className={styles['icon']} />
{t('SETTINGS_SERVER_ADD_URL')}
</Button>

View file

@ -64,8 +64,11 @@
.version-info-label {
flex: 0 1 auto;
margin: 0.5rem 0;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--primary-foreground-color);
opacity: 0.3;
overflow: hidden;
}
}
@ -242,6 +245,8 @@
flex-shrink: 1;
flex-basis: auto;
line-height: 1.5rem;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--primary-foreground-color);
}

View file

@ -32,6 +32,22 @@ const useProfileSettingsInputs = (profile) => {
}
}), [profile.settings]);
const hideSpoilersToggle = React.useMemo(() => ({
checked: profile.settings.hideSpoilers,
onClick: () => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'UpdateSettings',
args: {
...profile.settings,
hideSpoilers: !profile.settings.hideSpoilers
}
}
});
}
}), [profile.settings]);
const quitOnCloseToggle = React.useMemo(() => ({
checked: profile.settings.quitOnClose,
onClick: () => {
@ -323,8 +339,24 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const pauseOnMinimizeToggle = React.useMemo(() => ({
checked: profile.settings.pauseOnMinimize,
onClick: () => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'UpdateSettings',
args: {
...profile.settings,
pauseOnMinimize: !profile.settings.pauseOnMinimize,
}
}
});
}
}), [profile.settings]);
return {
interfaceLanguageSelect,
hideSpoilersToggle,
subtitlesLanguageSelect,
subtitlesSizeSelect,
subtitlesTextColorInput,
@ -341,6 +373,7 @@ const useProfileSettingsInputs = (profile) => {
bingeWatchingToggle,
playInBackgroundToggle,
hardwareDecodingToggle,
pauseOnMinimizeToggle,
};
};

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

@ -26,6 +26,31 @@ interface Chrome {
declare global {
var qt: Qt | undefined;
var chrome: Chrome | undefined;
interface Window {
AppleID: {
auth: {
init: (config: {
clientId: string;
scope: string;
redirectURI: string;
state: string;
usePopup: boolean;
}) => void;
signIn: () => Promise<{
authorization: {
code?: string;
id_token: string;
state?: string;
};
email?: string;
fullName?: {
firstName?: string;
lastName?: string;
};
}>;
};
};
}
}
export {};

View file

@ -21,6 +21,7 @@ type Settings = {
hardwareDecoding: boolean,
escExitFullscreen: boolean,
interfaceLanguage: string,
hideSpoilers: boolean,
nextVideoNotificationDuration: number,
playInBackground: boolean,
playerType: string | null,

View file

@ -234,6 +234,7 @@ module.exports = (env, argv) => ({
{ from: 'favicons', to: 'favicons' },
{ from: 'images', to: 'images' },
{ from: 'screenshots/*.webp', to: './' },
{ from: '.well-known', to: '.well-known' },
]
}),
new MiniCssExtractPlugin({