Skip to content

Commit 89a15b0

Browse files
committedMar 5, 2025·
Merge branch 'development' into feat/player-mobile-slide-volume
2 parents eb19299 + 23819cc commit 89a15b0

File tree

12 files changed

+212
-37
lines changed

12 files changed

+212
-37
lines changed
 

‎package-lock.json

+6-6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "stremio",
33
"displayName": "Stremio",
4-
"version": "5.0.0-beta.18",
4+
"version": "5.0.0-beta.20",
55
"author": "Smart Code OOD",
66
"private": true,
77
"license": "gpl-2.0",
@@ -16,7 +16,7 @@
1616
"@babel/runtime": "7.26.0",
1717
"@sentry/browser": "8.42.0",
1818
"@stremio/stremio-colors": "5.2.0",
19-
"@stremio/stremio-core-web": "0.48.5",
19+
"@stremio/stremio-core-web": "0.49.0",
2020
"@stremio/stremio-icons": "5.4.1",
2121
"@stremio/stremio-video": "0.0.53",
2222
"a-color-picker": "1.2.1",

‎src/App/App.js

+25-3
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const { useTranslation } = require('react-i18next');
66
const { Router } = require('stremio-router');
77
const { Core, Shell, Chromecast, DragAndDrop, KeyboardShortcuts, ServicesProvider } = require('stremio/services');
88
const { NotFound } = require('stremio/routes');
9-
const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, CONSTANTS, withCoreSuspender } = require('stremio/common');
9+
const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, CONSTANTS, withCoreSuspender, useShell } = require('stremio/common');
1010
const ServicesToaster = require('./ServicesToaster');
1111
const DeepLinkHandler = require('./DeepLinkHandler');
1212
const SearchParamsHandler = require('./SearchParamsHandler');
@@ -20,6 +20,8 @@ const RouterWithProtectedRoutes = withCoreSuspender(withProtectedRoutes(Router))
2020

2121
const App = () => {
2222
const { i18n } = useTranslation();
23+
const shell = useShell();
24+
const [windowHidden, setWindowHidden] = React.useState(false);
2325
const onPathNotMatch = React.useCallback(() => {
2426
return NotFound;
2527
}, []);
@@ -97,13 +99,29 @@ const App = () => {
9799
services.chromecast.off('stateChanged', onChromecastStateChange);
98100
};
99101
}, []);
102+
103+
// Handle shell window visibility changed event
104+
React.useEffect(() => {
105+
const onWindowVisibilityChanged = (state) => {
106+
setWindowHidden(state.visible === false && state.visibility === 0);
107+
};
108+
109+
shell.on('win-visibility-changed', onWindowVisibilityChanged);
110+
return () => shell.off('win-visibility-changed', onWindowVisibilityChanged);
111+
}, []);
112+
100113
React.useEffect(() => {
101114
const onCoreEvent = ({ event, args }) => {
102115
switch (event) {
103116
case 'SettingsUpdated': {
104117
if (args && args.settings && typeof args.settings.interfaceLanguage === 'string') {
105118
i18n.changeLanguage(args.settings.interfaceLanguage);
106119
}
120+
121+
if (args?.settings?.quitOnClose && windowHidden) {
122+
shell.send('quit');
123+
}
124+
107125
break;
108126
}
109127
}
@@ -112,6 +130,10 @@ const App = () => {
112130
if (state && state.profile && state.profile.settings && typeof state.profile.settings.interfaceLanguage === 'string') {
113131
i18n.changeLanguage(state.profile.settings.interfaceLanguage);
114132
}
133+
134+
if (state?.profile?.settings?.quitOnClose && windowHidden) {
135+
shell.send('quit');
136+
}
115137
};
116138
const onWindowFocus = () => {
117139
services.core.transport.dispatch({
@@ -146,15 +168,15 @@ const App = () => {
146168
services.core.transport
147169
.getState('ctx')
148170
.then(onCtxState)
149-
.catch((e) => console.error(e));
171+
.catch(console.error);
150172
}
151173
return () => {
152174
if (services.core.active) {
153175
window.removeEventListener('focus', onWindowFocus);
154176
services.core.transport.off('CoreEvent', onCoreEvent);
155177
}
156178
};
157-
}, [initialized]);
179+
}, [initialized, windowHidden]);
158180
return (
159181
<React.StrictMode>
160182
<ServicesProvider services={services}>

‎src/common/useShell.ts

+58-8
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,71 @@
1+
import { useEffect } from 'react';
2+
import EventEmitter from 'eventemitter3';
3+
4+
const SHELL_EVENT_OBJECT = 'transport';
5+
const transport = globalThis?.chrome?.webview;
6+
const events = new EventEmitter();
7+
8+
enum ShellEventType {
9+
SIGNAL = 1,
10+
INVOKE_METHOD = 6,
11+
}
12+
13+
type ShellEvent = {
14+
id: number;
15+
type: ShellEventType;
16+
object: string;
17+
args: string[];
18+
};
19+
120
const createId = () => Math.floor(Math.random() * 9999) + 1;
221

322
const useShell = () => {
4-
const transport = globalThis?.qt?.webChannelTransport;
23+
const on = (name: string, listener: (arg: any) => void) => {
24+
events.on(name, listener);
25+
};
26+
27+
const off = (name: string, listener: (arg: any) => void) => {
28+
events.off(name, listener);
29+
};
530

631
const send = (method: string, ...args: (string | number)[]) => {
7-
transport?.send(JSON.stringify({
8-
id: createId(),
9-
type: 6,
10-
object: 'transport',
11-
method: 'onEvent',
12-
args: [method, ...args],
13-
}));
32+
try {
33+
transport?.postMessage(JSON.stringify({
34+
id: createId(),
35+
type: ShellEventType.INVOKE_METHOD,
36+
object: SHELL_EVENT_OBJECT,
37+
method: 'onEvent',
38+
args: [method, ...args],
39+
}));
40+
} catch (e) {
41+
console.error('Shell', 'Failed to send event', e);
42+
}
1443
};
1544

45+
useEffect(() => {
46+
if (!transport) return;
47+
48+
const onMessage = ({ data }: { data: string }) => {
49+
try {
50+
const { type, args } = JSON.parse(data) as ShellEvent;
51+
if (type === ShellEventType.SIGNAL) {
52+
const [methodName, methodArg] = args;
53+
events.emit(methodName, methodArg);
54+
}
55+
} catch (e) {
56+
console.error('Shell', 'Failed to handle event', e);
57+
}
58+
};
59+
60+
transport.addEventListener('message', onMessage);
61+
return () => transport.removeEventListener('message', onMessage);
62+
}, []);
63+
1664
return {
1765
active: !!transport,
1866
send,
67+
on,
68+
off,
1969
};
2070
};
2171

‎src/components/Video/Video.js

+14-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const useBinaryState = require('stremio/common/useBinaryState');
1111
const VideoPlaceholder = require('./VideoPlaceholder');
1212
const styles = require('./styles');
1313

14-
const Video = ({ className, id, title, thumbnail, episode, released, upcoming, watched, progress, scheduled, deepLinks, onMarkVideoAsWatched, ...props }) => {
14+
const Video = ({ className, id, title, thumbnail, season, episode, released, upcoming, watched, progress, scheduled, seasonWatched, deepLinks, onMarkVideoAsWatched, onMarkSeasonAsWatched, ...props }) => {
1515
const routeFocused = useRouteFocused();
1616
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
1717
const popupLabelOnMouseUp = React.useCallback((event) => {
@@ -50,6 +50,12 @@ const Video = ({ className, id, title, thumbnail, episode, released, upcoming, w
5050
closeMenu();
5151
onMarkVideoAsWatched({ id, released }, watched);
5252
}, [id, released, watched]);
53+
const toggleWatchedSeasonOnClick = React.useCallback((event) => {
54+
event.preventDefault();
55+
event.stopPropagation();
56+
closeMenu();
57+
onMarkSeasonAsWatched(season, seasonWatched);
58+
}, [season, seasonWatched, onMarkSeasonAsWatched]);
5359
const videoButtonOnClick = React.useCallback(() => {
5460
if (deepLinks) {
5561
if (typeof deepLinks.player === 'string') {
@@ -142,9 +148,12 @@ const Video = ({ className, id, title, thumbnail, episode, released, upcoming, w
142148
<Button className={styles['context-menu-option-container']} title={watched ? 'Mark as non-watched' : 'Mark as watched'} onClick={toggleWatchedOnClick}>
143149
<div className={styles['context-menu-option-label']}>{watched ? t('CTX_MARK_NON_WATCHED') : t('CTX_MARK_WATCHED')}</div>
144150
</Button>
151+
<Button className={styles['context-menu-option-container']} title={seasonWatched ? t('CTX_UNMARK_REST') : t('CTX_MARK_REST')} onClick={toggleWatchedSeasonOnClick}>
152+
<div className={styles['context-menu-option-label']}>{seasonWatched ? t('CTX_UNMARK_REST') : t('CTX_MARK_REST')}</div>
153+
</Button>
145154
</div>
146155
);
147-
}, [watched, toggleWatchedOnClick]);
156+
}, [watched, seasonWatched, toggleWatchedOnClick]);
148157
React.useEffect(() => {
149158
if (!routeFocused) {
150159
closeMenu();
@@ -182,17 +191,20 @@ Video.propTypes = {
182191
id: PropTypes.string,
183192
title: PropTypes.string,
184193
thumbnail: PropTypes.string,
194+
season: PropTypes.number,
185195
episode: PropTypes.number,
186196
released: PropTypes.instanceOf(Date),
187197
upcoming: PropTypes.bool,
188198
watched: PropTypes.bool,
189199
progress: PropTypes.number,
190200
scheduled: PropTypes.bool,
201+
seasonWatched: PropTypes.bool,
191202
deepLinks: PropTypes.shape({
192203
metaDetailsStreams: PropTypes.string,
193204
player: PropTypes.string
194205
}),
195206
onMarkVideoAsWatched: PropTypes.func,
207+
onMarkSeasonAsWatched: PropTypes.func,
196208
};
197209

198210
module.exports = Video;

‎src/routes/Board/Board.js

+4-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
const React = require('react');
44
const classnames = require('classnames');
55
const debounce = require('lodash.debounce');
6-
const { useTranslation } = require('react-i18next');
6+
const useTranslate = require('stremio/common/useTranslate');
77
const { useStreamingServer, useNotifications, withCoreSuspender, getVisibleChildrenRange, useProfile } = require('stremio/common');
88
const { ContinueWatchingItem, EventModal, MainNavBars, MetaItem, MetaRow } = require('stremio/components');
99
const useBoard = require('./useBoard');
@@ -14,7 +14,7 @@ const { default: StreamingServerWarning } = require('./StreamingServerWarning');
1414
const THRESHOLD = 5;
1515

1616
const Board = () => {
17-
const { t } = useTranslation();
17+
const t = useTranslate();
1818
const streamingServer = useStreamingServer();
1919
const continueWatchingPreview = useContinueWatchingPreview();
2020
const [board, loadBoardRows] = useBoard();
@@ -55,7 +55,7 @@ const Board = () => {
5555
continueWatchingPreview.items.length > 0 ?
5656
<MetaRow
5757
className={classnames(styles['board-row'], styles['continue-watching-row'], 'animation-fade-in')}
58-
title={t('BOARD_CONTINUE_WATCHING')}
58+
title={t.string('BOARD_CONTINUE_WATCHING')}
5959
catalog={continueWatchingPreview}
6060
itemComponent={ContinueWatchingItem}
6161
notifications={notifications}
@@ -94,6 +94,7 @@ const Board = () => {
9494
key={index}
9595
className={classnames(styles['board-row'], styles['board-row-poster'], 'animation-fade-in')}
9696
catalog={catalog}
97+
title={t.catalogTitle(catalog)}
9798
/>
9899
);
99100
}

‎src/routes/MetaDetails/VideosList/VideosList.js

+27-3
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,23 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
3636
return season;
3737
}
3838

39+
const video = videos?.find((video) => video.id === libraryItem?.state.video_id);
40+
41+
if (video && video.season && seasons.includes(video.season)) {
42+
return video.season;
43+
}
44+
3945
const nonSpecialSeasons = seasons.filter((season) => season !== 0);
4046
if (nonSpecialSeasons.length > 0) {
41-
return nonSpecialSeasons[nonSpecialSeasons.length - 1];
47+
return nonSpecialSeasons[0];
4248
}
4349

4450
if (seasons.length > 0) {
45-
return seasons[seasons.length - 1];
51+
return seasons[0];
4652
}
4753

4854
return null;
49-
}, [seasons, season]);
55+
}, [seasons, season, videos, libraryItem]);
5056
const videosForSeason = React.useMemo(() => {
5157
return videos
5258
.filter((video) => {
@@ -56,6 +62,11 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
5662
return a.episode - b.episode;
5763
});
5864
}, [videos, selectedSeason]);
65+
66+
const seasonWatched = React.useMemo(() => {
67+
return videosForSeason.every((video) => video.watched);
68+
}, [videosForSeason]);
69+
5970
const [search, setSearch] = React.useState('');
6071
const searchInputOnChange = React.useCallback((event) => {
6172
setSearch(event.currentTarget.value);
@@ -71,6 +82,16 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
7182
});
7283
};
7384

85+
const onMarkSeasonAsWatched = (season, watched) => {
86+
core.transport.dispatch({
87+
action: 'MetaDetails',
88+
args: {
89+
action: 'MarkSeasonAsWatched',
90+
args: [season, !watched]
91+
}
92+
});
93+
};
94+
7495
return (
7596
<div className={classnames(className, styles['videos-list-container'])}>
7697
{
@@ -135,14 +156,17 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
135156
id={video.id}
136157
title={video.title}
137158
thumbnail={video.thumbnail}
159+
season={video.season}
138160
episode={video.episode}
139161
released={video.released}
140162
upcoming={video.upcoming}
141163
watched={video.watched}
142164
progress={video.progress}
143165
deepLinks={video.deepLinks}
144166
scheduled={video.scheduled}
167+
seasonWatched={seasonWatched}
145168
onMarkVideoAsWatched={onMarkVideoAsWatched}
169+
onMarkSeasonAsWatched={onMarkSeasonAsWatched}
146170
/>
147171
))
148172
}

‎src/routes/Player/SideDrawer/SideDrawer.tsx

+17
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, classNa
4747
setSeason(parseInt(event.value));
4848
}, []);
4949

50+
const seasonWatched = React.useMemo(() => {
51+
return videos.every((video) => video.watched);
52+
}, [videos]);
53+
5054
const onMarkVideoAsWatched = useCallback((video: Video, watched: boolean) => {
5155
core.transport.dispatch({
5256
action: 'Player',
@@ -57,6 +61,16 @@ const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, classNa
5761
});
5862
}, []);
5963

64+
const onMarkSeasonAsWatched = (season: number, watched: boolean) => {
65+
core.transport.dispatch({
66+
action: 'Player',
67+
args: {
68+
action: 'MarkSeasonAsWatched',
69+
args: [season, !watched]
70+
}
71+
});
72+
};
73+
6074
const onMouseDown = (event: React.MouseEvent) => {
6175
event.stopPropagation();
6276
};
@@ -95,14 +109,17 @@ const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, classNa
95109
id={video.id}
96110
title={video.title}
97111
thumbnail={video.thumbnail}
112+
season={video.season}
98113
episode={video.episode}
99114
released={video.released}
100115
upcoming={video.upcoming}
101116
watched={video.watched}
117+
seasonWatched={seasonWatched}
102118
progress={video.progress}
103119
deepLinks={video.deepLinks}
104120
scheduled={video.scheduled}
105121
onMarkVideoAsWatched={onMarkVideoAsWatched}
122+
onMarkSeasonAsWatched={onMarkSeasonAsWatched}
106123
/>
107124
))}
108125
</div>

‎src/routes/Search/Search.js

+9-8
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const React = require('react');
44
const PropTypes = require('prop-types');
55
const classnames = require('classnames');
66
const debounce = require('lodash.debounce');
7-
const { useTranslation } = require('react-i18next');
7+
const useTranslate = require('stremio/common/useTranslate');
88
const { default: Icon } = require('@stremio/stremio-icons/react');
99
const { withCoreSuspender, getVisibleChildrenRange } = require('stremio/common');
1010
const { Image, MainNavBars, MetaItem, MetaRow } = require('stremio/components');
@@ -14,7 +14,7 @@ const styles = require('./styles');
1414
const THRESHOLD = 100;
1515

1616
const Search = ({ queryParams }) => {
17-
const { t } = useTranslation();
17+
const t = useTranslate();
1818
const [search, loadSearchRows] = useSearch(queryParams);
1919
const query = React.useMemo(() => {
2020
return search.selected !== null ?
@@ -52,24 +52,24 @@ const Search = ({ queryParams }) => {
5252
query === null ?
5353
<div className={classnames(styles['search-hints-wrapper'])}>
5454
<div className={classnames(styles['search-hints-title-container'], 'animation-fade-in')}>
55-
<div className={styles['search-hints-title']}>{t('SEARCH_ANYTHING')}</div>
55+
<div className={styles['search-hints-title']}>{t.string('SEARCH_ANYTHING')}</div>
5656
</div>
5757
<div className={classnames(styles['search-hints-container'], 'animation-fade-in')}>
5858
<div className={styles['search-hint-container']}>
5959
<Icon className={styles['icon']} name={'trailer'} />
60-
<div className={styles['label']}>{t('SEARCH_CATEGORIES')}</div>
60+
<div className={styles['label']}>{t.string('SEARCH_CATEGORIES')}</div>
6161
</div>
6262
<div className={styles['search-hint-container']}>
6363
<Icon className={styles['icon']} name={'actors'} />
64-
<div className={styles['label']}>{t('SEARCH_PERSONS')}</div>
64+
<div className={styles['label']}>{t.string('SEARCH_PERSONS')}</div>
6565
</div>
6666
<div className={styles['search-hint-container']}>
6767
<Icon className={styles['icon']} name={'link'} />
68-
<div className={styles['label']}>{t('SEARCH_PROTOCOLS')}</div>
68+
<div className={styles['label']}>{t.string('SEARCH_PROTOCOLS')}</div>
6969
</div>
7070
<div className={styles['search-hint-container']}>
7171
<Icon className={styles['icon']} name={'imdb-outline'} />
72-
<div className={styles['label']}>{t('SEARCH_TYPES')}</div>
72+
<div className={styles['label']}>{t.string('SEARCH_TYPES')}</div>
7373
</div>
7474
</div>
7575
</div>
@@ -81,7 +81,7 @@ const Search = ({ queryParams }) => {
8181
src={require('/images/empty.png')}
8282
alt={' '}
8383
/>
84-
<div className={styles['message-label']}>{ t('STREMIO_TV_SEARCH_NO_ADDONS') }</div>
84+
<div className={styles['message-label']}>{ t.string('STREMIO_TV_SEARCH_NO_ADDONS') }</div>
8585
</div>
8686
:
8787
search.catalogs.map((catalog, index) => {
@@ -115,6 +115,7 @@ const Search = ({ queryParams }) => {
115115
key={index}
116116
className={classnames(styles['search-row'], styles['search-row-poster'], 'animation-fade-in')}
117117
catalog={catalog}
118+
title={t.catalogTitle(catalog)}
118119
/>
119120
);
120121
}

‎src/routes/Settings/Settings.js

+15-1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const Settings = () => {
4141
seekTimeDurationSelect,
4242
seekShortTimeDurationSelect,
4343
escExitFullscreenToggle,
44+
quitOnCloseToggle,
4445
playInExternalPlayerSelect,
4546
nextVideoPopupDurationSelect,
4647
bingeWatchingToggle,
@@ -322,12 +323,25 @@ const Settings = () => {
322323
{...interfaceLanguageSelect}
323324
/>
324325
</div>
326+
{
327+
shell.active &&
328+
<div className={styles['option-container']}>
329+
<div className={styles['option-name-container']}>
330+
<div className={styles['label']}>{ t('SETTINGS_QUIT_ON_CLOSE') }</div>
331+
</div>
332+
<Toggle
333+
className={classnames(styles['option-input-container'], styles['toggle-container'])}
334+
tabIndex={-1}
335+
{...quitOnCloseToggle}
336+
/>
337+
</div>
338+
}
325339
</div>
326340
<div ref={playerSectionRef} className={styles['section-container']}>
327341
<div className={styles['section-title']}>{ t('SETTINGS_NAV_PLAYER') }</div>
328342
<div className={styles['section-category-container']}>
329343
<Icon className={styles['icon']} name={'subtitles'} />
330-
<div className={styles['label']}>{t('SETTINGS_SECTION_SUBTITLES')}</div>
344+
<div className={styles['label']}>{t('SETTINGS_CLOSE_WINDOW')}</div>
331345
</div>
332346
<div className={styles['option-container']}>
333347
<div className={styles['option-name-container']}>

‎src/routes/Settings/useProfileSettingsInputs.js

+18
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,23 @@ const useProfileSettingsInputs = (profile) => {
3131
});
3232
}
3333
}), [profile.settings]);
34+
35+
const quitOnCloseToggle = React.useMemo(() => ({
36+
checked: profile.settings.quitOnClose,
37+
onClick: () => {
38+
core.transport.dispatch({
39+
action: 'Ctx',
40+
args: {
41+
action: 'UpdateSettings',
42+
args: {
43+
...profile.settings,
44+
quitOnClose: !profile.settings.quitOnClose
45+
}
46+
}
47+
});
48+
}
49+
}), [profile.settings]);
50+
3451
const subtitlesLanguageSelect = React.useMemo(() => ({
3552
options: Object.keys(languageNames).map((code) => ({
3653
value: code,
@@ -316,6 +333,7 @@ const useProfileSettingsInputs = (profile) => {
316333
audioLanguageSelect,
317334
surroundSoundToggle,
318335
escExitFullscreenToggle,
336+
quitOnCloseToggle,
319337
seekTimeDurationSelect,
320338
seekShortTimeDurationSelect,
321339
playInExternalPlayerSelect,

‎src/types/global.d.ts

+17-1
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,31 @@
11
/* eslint-disable no-var */
22

3+
type QtTransportMessage = {
4+
data: string;
5+
};
6+
37
interface QtTransport {
48
send: (message: string) => void,
9+
onmessage: (message: QtTransportMessage) => void,
510
}
611

712
interface Qt {
813
webChannelTransport: QtTransport,
914
}
1015

16+
interface ChromeWebView {
17+
addEventListener: (type: 'message', listenenr: (event: any) => void) => void,
18+
removeEventListener: (type: 'message', listenenr: (event: any) => void) => void,
19+
postMessage: (message: string) => void,
20+
}
21+
22+
interface Chrome {
23+
webview: ChromeWebView,
24+
}
25+
1126
declare global {
1227
var qt: Qt | undefined;
28+
var chrome: Chrome | undefined;
1329
}
1430

15-
export { };
31+
export {};

0 commit comments

Comments
 (0)
Please sign in to comment.