Merge branch 'development' into feat/player-mobile-slide-volume

This commit is contained in:
Timothy Z. 2025-03-05 12:03:40 +01:00
commit 89a15b0ad4
12 changed files with 214 additions and 39 deletions

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

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

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

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

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

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

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