diff --git a/images/library_placeholder.png b/images/library_placeholder.png new file mode 100644 index 000000000..07ac6dc4d Binary files /dev/null and b/images/library_placeholder.png differ diff --git a/package-lock.json b/package-lock.json index 642d0ea73..0824f882f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 9ccb0cee8..4cab1781f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App/App.js b/src/App/App.js index 6dc2d6e0b..d3a1ce188 100644 --- a/src/App/App.js +++ b/src/App/App.js @@ -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 ( diff --git a/src/common/index.js b/src/common/index.js index 4acf8b056..82f7a6a0c 100644 --- a/src/common/index.js +++ b/src/common/index.js @@ -24,6 +24,7 @@ const { default: useShell } = require('./useShell'); const useStreamingServer = require('./useStreamingServer'); const useTorrent = require('./useTorrent'); const useTranslate = require('./useTranslate'); +const { default: useOrientation } = require('./useOrientation'); module.exports = { FileDropProvider, @@ -55,4 +56,5 @@ module.exports = { useStreamingServer, useTorrent, useTranslate, + useOrientation, }; diff --git a/src/common/useOrientation.ts b/src/common/useOrientation.ts new file mode 100644 index 000000000..add8d1d40 --- /dev/null +++ b/src/common/useOrientation.ts @@ -0,0 +1,34 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +import { useState, useEffect, useMemo } from 'react'; + +type DeviceOrientation = 'landscape' | 'portrait'; + +const useOrientation = () => { + const [windowHeight, setWindowHeight] = useState(window.innerHeight); + const [windowWidth, setWindowWidth] = useState(window.innerWidth); + + const orientation: DeviceOrientation = useMemo(() => { + if (windowHeight > windowWidth) { + return 'portrait'; + } else { + return 'landscape'; + } + }, [windowWidth, windowHeight]); + + useEffect(() => { + const handleResize = () => { + setWindowHeight(window.innerHeight); + setWindowWidth(window.innerWidth); + }; + + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [window.innerWidth, window.innerHeight]); + + return orientation; +}; + +export default useOrientation; diff --git a/src/common/useShell.ts b/src/common/useShell.ts index 5e61bfe84..1a7bcb6ee 100644 --- a/src/common/useShell.ts +++ b/src/common/useShell.ts @@ -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, }; }; diff --git a/src/components/BottomSheet/BottomSheet.tsx b/src/components/BottomSheet/BottomSheet.tsx index 7ebfb79d8..d7dfe7130 100644 --- a/src/components/BottomSheet/BottomSheet.tsx +++ b/src/components/BottomSheet/BottomSheet.tsx @@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom'; import classNames from 'classnames'; import useBinaryState from 'stremio/common/useBinaryState'; +import useOrientation from 'stremio/common/useOrientation'; import styles from './BottomSheet.less'; const CLOSE_THRESHOLD = 100; @@ -17,6 +18,7 @@ type Props = { const BottomSheet = ({ children, title, show, onClose }: Props) => { const containerRef = useRef(null); + const orientation = useOrientation(); const [startOffset, setStartOffset] = useState(0); const [offset, setOffset] = useState(0); @@ -58,6 +60,10 @@ const BottomSheet = ({ children, title, show, onClose }: Props) => { !opened && onClose(); }, [opened]); + useEffect(() => { + opened && close(); + }, [orientation]); + return opened && createPortal((
diff --git a/src/components/Checkbox/Checkbox.less b/src/components/Checkbox/Checkbox.less index 718a7b129..a84244ce9 100644 --- a/src/components/Checkbox/Checkbox.less +++ b/src/components/Checkbox/Checkbox.less @@ -42,7 +42,7 @@ margin: 0 1rem 0 0.3rem; align-items: center; justify-content: center; - transition: all 0.2s ease-in-out; + transition: background-color 0.2s ease-in-out; cursor: pointer; outline: none; user-select: none; diff --git a/src/components/MainNavBars/MainNavBars.less b/src/components/MainNavBars/MainNavBars.less index a5495bc69..ca816a72f 100644 --- a/src/components/MainNavBars/MainNavBars.less +++ b/src/components/MainNavBars/MainNavBars.less @@ -29,8 +29,7 @@ .nav-content-container { position: absolute; - padding-top: calc(var(--horizontal-nav-bar-size) + var(--safe-area-inset-top)); - top: 0; + top: calc(var(--horizontal-nav-bar-size) + var(--safe-area-inset-top)); right: 0; bottom: 0; left: var(--vertical-nav-bar-size); @@ -43,7 +42,7 @@ .main-nav-bars-container { .nav-content-container { left: 0; - padding-bottom: var(--vertical-nav-bar-size); + bottom: var(--vertical-nav-bar-size); } .vertical-nav-bar { diff --git a/src/components/Video/Video.js b/src/components/Video/Video.js index efa1a6847..0bcb569a5 100644 --- a/src/components/Video/Video.js +++ b/src/components/Video/Video.js @@ -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 +
); - }, [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; diff --git a/src/routes/Board/Board.js b/src/routes/Board/Board.js index 9e721edf2..13acb4a86 100644 --- a/src/routes/Board/Board.js +++ b/src/routes/Board/Board.js @@ -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 ? { key={index} className={classnames(styles['board-row'], styles['board-row-poster'], 'animation-fade-in')} catalog={catalog} + title={t.catalogTitle(catalog)} /> ); } diff --git a/src/routes/Calendar/Calendar.less b/src/routes/Calendar/Calendar.less index 4763353f1..63168360d 100644 --- a/src/routes/Calendar/Calendar.less +++ b/src/routes/Calendar/Calendar.less @@ -13,7 +13,7 @@ gap: 0.5rem; width: 100%; height: 100%; - padding: 0 0 calc(1.5rem + var(--safe-area-inset-bottom)) 2rem; + padding: 0 0 1.5rem 1.5rem; .main { flex: auto; @@ -31,12 +31,4 @@ padding: 0; } } -} - -@media only screen and (max-width: @small) and (orientation: landscape) { - .calendar { - .content { - padding: 0 0 calc(1.5rem + var(--safe-area-inset-bottom)) 1rem; - } - } -} +} \ No newline at end of file diff --git a/src/routes/Calendar/Placeholder/Placeholder.less b/src/routes/Calendar/Placeholder/Placeholder.less index a509ff79e..456bcfd21 100644 --- a/src/routes/Calendar/Placeholder/Placeholder.less +++ b/src/routes/Calendar/Placeholder/Placeholder.less @@ -8,12 +8,11 @@ flex-direction: column; align-items: center; justify-content: center; - height: 100%; + min-height: 100%; width: 100%; overflow-y: auto; .title { - flex: none; font-size: 1.75rem; font-weight: 400; text-align: center; @@ -22,19 +21,22 @@ opacity: 0.5; } - .image { - flex: none; - height: 14rem; - margin: 1.5rem 0; + .image-container { + padding: 1.5rem 0; + + .image { + height: 100%; + max-height: 14rem; + object-fit: contain; + } } .overview { - flex: none; display: flex; flex-direction: row; align-items: center; gap: 4rem; - margin-bottom: 3rem; + margin-bottom: 1rem; .point { display: flex; @@ -61,21 +63,47 @@ } } - .button { - flex: none; - justify-content: center; - height: 4rem; - line-height: 4rem; - padding: 0 5rem; - font-size: 1.1rem; - color: var(--primary-foreground-color); - text-align: center; - border-radius: 3.5rem; - background-color: var(--overlay-color); + .button-container { + margin: 1rem 0; + + .button { + display: flex; + justify-content: center; + height: 4rem; + line-height: 4rem; + padding: 0 5rem; + font-size: 1.1rem; + color: var(--primary-foreground-color); + text-align: center; + border-radius: 3.5rem; + background-color: var(--overlay-color); - &:hover { - outline: var(--focus-outline-size) solid var(--primary-foreground-color); - background-color: transparent; + &:hover { + outline: var(--focus-outline-size) solid var(--primary-foreground-color); + background-color: transparent; + } + } + } +} + +@media only screen and (max-width: @xsmall) { + .placeholder { + padding: 1rem 2rem; + + .title { + margin-bottom: 0; + } + + .image-container { + padding: 1rem; + + .image { + max-height: 10rem; + } + } + + .button-container { + margin: 1rem 0 0; } } } @@ -84,16 +112,21 @@ .placeholder { padding: 1rem 2rem; - .image { - height: 10rem; - } - .overview { flex-direction: column; + gap: 1rem; + + .point { + .text { + font-size: 1rem; + } + } } - .button { - width: 100%; + .button-container { + .button { + width: 100%; + } } } } \ No newline at end of file diff --git a/src/routes/Calendar/Placeholder/Placeholder.tsx b/src/routes/Calendar/Placeholder/Placeholder.tsx index 4b48ba3f2..c84e7a1b8 100644 --- a/src/routes/Calendar/Placeholder/Placeholder.tsx +++ b/src/routes/Calendar/Placeholder/Placeholder.tsx @@ -14,11 +14,13 @@ const Placeholder = () => {
{t('CALENDAR_NOT_LOGGED_IN')}
- {' +
+ {' +
@@ -33,9 +35,11 @@ const Placeholder = () => {
- +
+ +
); }; diff --git a/src/routes/Intro/styles.less b/src/routes/Intro/styles.less index 61a62aff1..935b0fad1 100644 --- a/src/routes/Intro/styles.less +++ b/src/routes/Intro/styles.less @@ -252,7 +252,7 @@ } } -@media only screen and (max-width: @minimum) { +@media only screen and (max-width: @xsmall) { .intro-container { justify-content: initial; padding: 3rem 1.5rem; @@ -279,6 +279,21 @@ .content-container { flex-direction: column-reverse; + .form-container, .options-container { + width: 50%; + margin: 0 auto; + } + + .options-container { + margin-bottom: 4rem; + } + } + } +} + +@media only screen and (max-width: @minimum) { + .intro-container { + .content-container { .form-container, .options-container { width: 100%; margin: 0; diff --git a/src/routes/Library/Library.js b/src/routes/Library/Library.js index 3329624fd..f6310470b 100644 --- a/src/routes/Library/Library.js +++ b/src/routes/Library/Library.js @@ -5,7 +5,8 @@ const PropTypes = require('prop-types'); const classnames = require('classnames'); const NotFound = require('stremio/routes/NotFound'); const { useProfile, useNotifications, routesRegexp, useOnScrollToBottom, withCoreSuspender } = require('stremio/common'); -const { Button, DelayedRenderer, Chips, Image, MainNavBars, Multiselect, LibItem } = require('stremio/components'); +const { DelayedRenderer, Chips, Image, MainNavBars, Multiselect, LibItem } = require('stremio/components'); +const { default: Placeholder } = require('./Placeholder'); const useLibrary = require('./useLibrary'); const useSelectableInputs = require('./useSelectableInputs'); const styles = require('./styles'); @@ -58,7 +59,7 @@ const Library = ({ model, urlParams, queryParams }) => { }, [hasNextPage, loadNextPage]); const onScroll = useOnScrollToBottom(onScrollToBottom, SCROLL_TO_BOTTOM_TRESHOLD); React.useLayoutEffect(() => { - if (profile.auth !== null && library.selected && library.selected.request.page === 1 && library.catalog.length !== 0 ) { + if (scrollContainerRef.current !== null && library.selected && library.selected.request.page === 1 && library.catalog.length !== 0) { scrollContainerRef.current.scrollTop = 0; } }, [profile.auth, library.selected]); @@ -69,59 +70,48 @@ const Library = ({ model, urlParams, queryParams }) => { }, [typeSelect.selected, library.selected]); return ( -
- { - model === 'continue_watching' || profile.auth !== null ? + { + profile.auth !== null ? +
- : - null - } - { - model === 'library' && profile.auth === null ? -
- {' -
Library is only available for logged in users!
- -
- : - library.selected === null ? - -
- {' -
{model === 'library' ? 'Library' : 'Continue Watching'} not loaded!
-
-
- : - library.catalog.length === 0 ? -
- {' -
Empty {model === 'library' ? 'Library' : 'Continue Watching'}
-
+ { + library.selected === null ? + +
+ {' +
{model === 'library' ? 'Library' : 'Continue Watching'} not loaded!
+
+
: -
- {library.catalog.map((libItem, index) => ( - - ))} -
- } -
+ library.catalog.length === 0 ? +
+ {' +
Empty {model === 'library' ? 'Library' : 'Continue Watching'}
+
+ : +
+ { + library.catalog.map((libItem, index) => ( + + )) + } +
+ } +
+ : + + }
); }; diff --git a/src/routes/Library/Placeholder/Placeholder.less b/src/routes/Library/Placeholder/Placeholder.less new file mode 100644 index 000000000..55de0356a --- /dev/null +++ b/src/routes/Library/Placeholder/Placeholder.less @@ -0,0 +1,132 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +@import (reference) '~stremio/common/screen-sizes.less'; + +.placeholder { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100%; + width: 100%; + overflow-y: auto; + + .title { + font-size: 1.75rem; + font-weight: 400; + text-align: center; + color: var(--primary-foreground-color); + margin-bottom: 1rem; + opacity: 0.5; + } + + .image-container { + padding: 1.5rem 0; + + .image { + height: 100%; + max-height: 14rem; + object-fit: contain; + } + } + + .overview { + display: flex; + flex-direction: row; + align-items: center; + gap: 4rem; + margin-bottom: 1rem; + + .point { + display: flex; + flex-direction: row; + align-items: center; + gap: 1.5rem; + width: 18rem; + + .icon { + flex: none; + height: 3.25rem; + width: 3.25rem; + color: var(--primary-foreground-color); + opacity: 0.3; + } + + .text { + flex: auto; + font-size: 1.1rem; + font-size: 500; + color: var(--primary-foreground-color); + opacity: 0.9; + } + } + } + + .button-container { + margin: 1rem 0; + + .button { + display: flex; + justify-content: center; + height: 4rem; + line-height: 4rem; + padding: 0 5rem; + font-size: 1.1rem; + color: var(--primary-foreground-color); + text-align: center; + border-radius: 3.5rem; + background-color: var(--overlay-color); + + &:hover { + outline: var(--focus-outline-size) solid var(--primary-foreground-color); + background-color: transparent; + } + } + } +} + +@media only screen and (max-width: @xsmall) { + .placeholder { + padding: 1rem 2rem; + + .title { + margin-bottom: 0; + } + + .image-container { + padding: 1rem; + + .image { + max-height: 10rem; + } + } + + .button-container { + margin: 1rem 0 0; + } + } +} + +@media only screen and (max-width: @minimum) { + .placeholder { + padding: 1rem 2rem; + + .overview { + flex-direction: column; + gap: 1rem; + + .point { + .text { + font-size: 1rem; + } + } + } + + .button-container { + .button { + width: 100%; + } + } + } +} \ No newline at end of file diff --git a/src/routes/Library/Placeholder/Placeholder.tsx b/src/routes/Library/Placeholder/Placeholder.tsx new file mode 100644 index 000000000..d854a2d54 --- /dev/null +++ b/src/routes/Library/Placeholder/Placeholder.tsx @@ -0,0 +1,47 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import Icon from '@stremio/stremio-icons/react'; +import { Button, Image } from 'stremio/components'; +import styles from './Placeholder.less'; + +const Placeholder = () => { + const { t } = useTranslation(); + + return ( +
+
+ {t('LIBRARY_NOT_LOGGED_IN')} +
+
+ {' +
+
+
+ +
+ {t('NOT_LOGGED_IN_CLOUD')} +
+
+
+ +
+ {t('NOT_LOGGED_IN_RECOMMENDATIONS')} +
+
+
+
+ +
+
+ ); +}; + +export default Placeholder; diff --git a/src/routes/Library/Placeholder/index.ts b/src/routes/Library/Placeholder/index.ts new file mode 100644 index 000000000..b068f608e --- /dev/null +++ b/src/routes/Library/Placeholder/index.ts @@ -0,0 +1,5 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +import Placeholder from './Placeholder'; + +export default Placeholder; diff --git a/src/routes/Library/styles.less b/src/routes/Library/styles.less index 2bdbc13ec..76a16940e 100644 --- a/src/routes/Library/styles.less +++ b/src/routes/Library/styles.less @@ -66,38 +66,6 @@ padding: 4rem; } - &.no-user-message-container { - .login-button-container { - flex: none; - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - width: 20rem; - height: 3.5rem; - border-radius: 3.5rem; - padding: 0.5rem 1rem; - margin-bottom: 1rem; - background-color: var(--secondary-accent-color); - - &:hover { - outline: var(--focus-outline-size) solid var(--secondary-accent-color); - background-color: transparent; - } - - .label { - flex-grow: 0; - flex-shrink: 1; - flex-basis: auto; - max-height: 4.8em; - font-size: 1.2rem; - font-weight: 700; - color: var(--primary-foreground-color); - text-align: center; - } - } - } - .image { flex: none; width: 12rem; diff --git a/src/routes/MetaDetails/VideosList/VideosList.js b/src/routes/MetaDetails/VideosList/VideosList.js index 999a1a1ae..58614c9d9 100644 --- a/src/routes/MetaDetails/VideosList/VideosList.js +++ b/src/routes/MetaDetails/VideosList/VideosList.js @@ -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 (
{ @@ -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} /> )) } diff --git a/src/routes/Player/SideDrawer/SideDrawer.tsx b/src/routes/Player/SideDrawer/SideDrawer.tsx index 9ed713879..cb94e24e5 100644 --- a/src/routes/Player/SideDrawer/SideDrawer.tsx +++ b/src/routes/Player/SideDrawer/SideDrawer.tsx @@ -47,6 +47,10 @@ const SideDrawer = memo(forwardRef(({ 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(({ 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(({ 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} /> ))}
diff --git a/src/routes/Search/Search.js b/src/routes/Search/Search.js index 4b052ed46..58e6e834b 100644 --- a/src/routes/Search/Search.js +++ b/src/routes/Search/Search.js @@ -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 ?
-
{t('SEARCH_ANYTHING')}
+
{t.string('SEARCH_ANYTHING')}
-
{t('SEARCH_CATEGORIES')}
+
{t.string('SEARCH_CATEGORIES')}
-
{t('SEARCH_PERSONS')}
+
{t.string('SEARCH_PERSONS')}
-
{t('SEARCH_PROTOCOLS')}
+
{t.string('SEARCH_PROTOCOLS')}
-
{t('SEARCH_TYPES')}
+
{t.string('SEARCH_TYPES')}
@@ -81,7 +81,7 @@ const Search = ({ queryParams }) => { src={require('/images/empty.png')} alt={' '} /> -
{ t('STREMIO_TV_SEARCH_NO_ADDONS') }
+
{ t.string('STREMIO_TV_SEARCH_NO_ADDONS') }
: 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)} /> ); } diff --git a/src/routes/Settings/Settings.js b/src/routes/Settings/Settings.js index 6ad15163a..f238c1d02 100644 --- a/src/routes/Settings/Settings.js +++ b/src/routes/Settings/Settings.js @@ -41,6 +41,7 @@ const Settings = () => { seekTimeDurationSelect, seekShortTimeDurationSelect, escExitFullscreenToggle, + quitOnCloseToggle, playInExternalPlayerSelect, nextVideoPopupDurationSelect, bingeWatchingToggle, @@ -322,12 +323,25 @@ const Settings = () => { {...interfaceLanguageSelect} /> + { + shell.active && +
+
+
{ t('SETTINGS_QUIT_ON_CLOSE') }
+
+ +
+ }
{ t('SETTINGS_NAV_PLAYER') }
-
{t('SETTINGS_SECTION_SUBTITLES')}
+
{t('SETTINGS_CLOSE_WINDOW')}
diff --git a/src/routes/Settings/useProfileSettingsInputs.js b/src/routes/Settings/useProfileSettingsInputs.js index d36b169f9..c193c6eaf 100644 --- a/src/routes/Settings/useProfileSettingsInputs.js +++ b/src/routes/Settings/useProfileSettingsInputs.js @@ -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, diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 5effeffd4..3849b8914 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -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 {};