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 4477c0b4f..0824f882f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,20 @@ { "name": "stremio", - "version": "5.0.0-beta.17", + "version": "5.0.0-beta.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stremio", - "version": "5.0.0-beta.17", + "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.48", + "@stremio/stremio-video": "0.0.53", "a-color-picker": "1.2.1", "bowser": "2.11.0", "buffer": "6.0.3", @@ -36,7 +36,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#a0f50634202f748a57907b645d2cd92fbaa479dd", + "stremio-translations": "github:Stremio/stremio-translations#62bcc6e8f44258203c7375af59210771efb6f634", "url": "0.11.4", "use-long-press": "^3.2.0" }, @@ -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" @@ -3409,10 +3409,9 @@ ] }, "node_modules/@stremio/stremio-video": { - "version": "0.0.48", - "resolved": "https://registry.npmjs.org/@stremio/stremio-video/-/stremio-video-0.0.48.tgz", - "integrity": "sha512-6ALGXCZC4NPsfhPcrwFWQzvH6UMMRsgSkHetnOhv9WmZ5ubiyUdbBzj9atGiGuuQz8pRcze66ztrub+dsaQbpw==", - "license": "MIT", + "version": "0.0.53", + "resolved": "https://registry.npmjs.org/@stremio/stremio-video/-/stremio-video-0.0.53.tgz", + "integrity": "sha512-hSlk8GqMdk4N8VbcdvduYqWVZsQLgHyU7GfFmd1k+t0pSpDKAhI3C6dohG5Sr09CKCjHa8D1rls+CwMNPXLSGw==", "dependencies": { "buffer": "6.0.3", "color": "4.2.3", @@ -13374,8 +13373,8 @@ }, "node_modules/stremio-translations": { "version": "1.44.9", - "resolved": "git+ssh://git@github.com/Stremio/stremio-translations.git#a0f50634202f748a57907b645d2cd92fbaa479dd", - "integrity": "sha512-JJpd1JJet3T6/VTNdZ2NZ7uvHJ4zkuyqo5BnTcDGqLVNO/OpicGqKhZjE4WGSgmuhsfPBU8T0ICCfzKu2xpvKg==", + "resolved": "git+ssh://git@github.com/Stremio/stremio-translations.git#62bcc6e8f44258203c7375af59210771efb6f634", + "integrity": "sha512-8Sc5Qvd4IiObwGXkmj1XFXFavUc15My5po6G48HHDBbp42SVc5I/t7h+1yxW1A81byyBCXbL23a9iU9v49vpQA==", "license": "MIT" }, "node_modules/string_decoder": { diff --git a/package.json b/package.json index 9587f9530..4cab1781f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stremio", "displayName": "Stremio", - "version": "5.0.0-beta.17", + "version": "5.0.0-beta.20", "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.48.5", + "@stremio/stremio-core-web": "0.49.0", "@stremio/stremio-icons": "5.4.1", - "@stremio/stremio-video": "0.0.48", + "@stremio/stremio-video": "0.0.53", "a-color-picker": "1.2.1", "bowser": "2.11.0", "buffer": "6.0.3", @@ -40,7 +40,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#a0f50634202f748a57907b645d2cd92fbaa479dd", + "stremio-translations": "github:Stremio/stremio-translations#62bcc6e8f44258203c7375af59210771efb6f634", "url": "0.11.4", "use-long-press": "^3.2.0" }, diff --git a/src/App/App.js b/src/App/App.js index 730e75f28..d3a1ce188 100644 --- a/src/App/App.js +++ b/src/App/App.js @@ -6,10 +6,11 @@ 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 { 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'); +const { default: UpdaterBanner } = require('./UpdaterBanner'); const ErrorDialog = require('./ErrorDialog'); const withProtectedRoutes = require('./withProtectedRoutes'); const routerViewsConfig = require('./routerViewsConfig'); @@ -19,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; }, []); @@ -96,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) { @@ -103,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; } } @@ -111,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({ @@ -145,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) { @@ -153,7 +176,7 @@ const App = () => { services.core.transport.off('CoreEvent', onCoreEvent); } }; - }, [initialized]); + }, [initialized, windowHidden]); return ( @@ -165,14 +188,17 @@ const App = () => { - - - - + + + + + + + diff --git a/src/App/UpdaterBanner/UpdaterBanner.less b/src/App/UpdaterBanner/UpdaterBanner.less new file mode 100644 index 000000000..9928fb493 --- /dev/null +++ b/src/App/UpdaterBanner/UpdaterBanner.less @@ -0,0 +1,46 @@ +.updater-banner { + height: 4rem; + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 0 1rem; + font-size: 1rem; + font-weight: bold; + color: var(--primary-foreground-color); + background-color: var(--primary-accent-color); + + .button { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + height: 2.5rem; + padding: 0 1rem; + border-radius: var(--border-radius); + color: var(--primary-background-color); + background-color: var(--primary-foreground-color); + transition: all 0.1s ease-out; + + &:hover { + color: var(--primary-foreground-color); + background-color: transparent; + box-shadow: inset 0 0 0 0.15rem var(--primary-foreground-color); + } + } + + .close { + position: absolute; + right: 0; + height: 4rem; + width: 4rem; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + .icon { + height: 2rem; + } + } +} \ No newline at end of file diff --git a/src/App/UpdaterBanner/UpdaterBanner.tsx b/src/App/UpdaterBanner/UpdaterBanner.tsx new file mode 100644 index 000000000..3d421deb5 --- /dev/null +++ b/src/App/UpdaterBanner/UpdaterBanner.tsx @@ -0,0 +1,50 @@ +import React, { useEffect } from 'react'; +import Icon from '@stremio/stremio-icons/react'; +import { useTranslation } from 'react-i18next'; +import { useServices } from 'stremio/services'; +import { useBinaryState, useShell } from 'stremio/common'; +import { Button, Transition } from 'stremio/components'; +import styles from './UpdaterBanner.less'; + +type Props = { + className: string, +}; + +const UpdaterBanner = ({ className }: Props) => { + const { t } = useTranslation(); + const { shell } = useServices(); + const shellTransport = useShell(); + const [visible, show, hide] = useBinaryState(false); + + const onInstallClick = () => { + shellTransport.send('autoupdater-notif-clicked'); + }; + + useEffect(() => { + shell.transport && shell.transport.on('autoupdater-show-notif', show); + + return () => { + shell.transport && shell.transport.off('autoupdater-show-notif', show); + }; + }, []); + + return ( +
+ +
+
+ { t('UPDATER_TITLE') } +
+ + +
+
+
+ ); +}; + +export default UpdaterBanner; diff --git a/src/App/UpdaterBanner/index.ts b/src/App/UpdaterBanner/index.ts new file mode 100644 index 000000000..e4306ecb2 --- /dev/null +++ b/src/App/UpdaterBanner/index.ts @@ -0,0 +1,2 @@ +import UpdaterBanner from './UpdaterBanner'; +export default UpdaterBanner; diff --git a/src/App/styles.less b/src/App/styles.less index a413ed163..50819f883 100644 --- a/src/App/styles.less +++ b/src/App/styles.less @@ -63,6 +63,8 @@ --overlay-color: rgba(255, 255, 255, 0.05); --modal-background-color: rgba(15, 13, 32, 1); --outer-glow: 0px 0px 15px rgba(123, 91, 245, 0.37); + --warning-accent-color: rgba(255, 165, 0, 1); + --danger-accent-color: rgba(220, 38, 38, 1); --border-radius: 0.75rem; --top-overlay-size: @top-overlay-size; --bottom-overlay-size: @bottom-overlay-size; @@ -202,12 +204,32 @@ html { background-color: var(--modal-background-color); box-shadow: var(--outer-glow); transition: opacity 0.1s ease-out; + } + + .file-drop-container { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + border-radius: 1rem; + border: 0.5rem dashed transparent; + pointer-events: none; + transition: border-color 0.25s ease-out; &:global(.active) { - transition-delay: 0.25s; + border-color: var(--primary-accent-color); } } + .updater-banner-container { + z-index: 1; + position: absolute; + left: 0; + right: 0; + bottom: 0; + } + .router { width: 100%; height: 100%; diff --git a/src/common/CONSTANTS.js b/src/common/CONSTANTS.js index 727c152fa..8e4e3efdc 100644 --- a/src/common/CONSTANTS.js +++ b/src/common/CONSTANTS.js @@ -41,6 +41,16 @@ const ICON_FOR_TYPE = new Map([ ['other', 'movies'], ]); +const MIME_SIGNATURES = { + 'application/x-subrip': ['310D0A', '310A'], + 'text/vtt': ['574542565454'], +}; + +const SUPPORTED_LOCAL_SUBTITLES = [ + 'application/x-subrip', + 'text/vtt', +]; + const EXTERNAL_PLAYERS = [ { label: 'EXTERNAL_PLAYER_DISABLED', @@ -113,6 +123,8 @@ module.exports = { WRITERS_LINK_CATEGORY, TYPE_PRIORITIES, ICON_FOR_TYPE, + MIME_SIGNATURES, + SUPPORTED_LOCAL_SUBTITLES, EXTERNAL_PLAYERS, WHITELISTED_HOSTS, }; diff --git a/src/common/FileDrop/FileDrop.tsx b/src/common/FileDrop/FileDrop.tsx new file mode 100644 index 000000000..aae4e146b --- /dev/null +++ b/src/common/FileDrop/FileDrop.tsx @@ -0,0 +1,91 @@ +import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { isFileType } from './utils'; + +export type FileType = string; +export type FileDropListener = (filename: string, buffer: ArrayBuffer) => void; + +type FileDropContext = { + on: (type: FileType, listener: FileDropListener) => void, + off: (type: FileType, listener: FileDropListener) => void, +}; + +const FileDropContext = createContext({} as FileDropContext); + +type Props = { + className: string, + children: JSX.Element, +}; + +const FileDropProvider = ({ className, children }: Props) => { + const [listeners, setListeners] = useState<[FileType, FileDropListener][]>([]); + const [active, setActive] = useState(false); + + const onDragOver = (event: DragEvent) => { + event.preventDefault(); + setActive(true); + }; + + const onDragLeave = () => { + setActive(false); + }; + + const onDrop = useCallback((event: DragEvent) => { + event.preventDefault(); + const { dataTransfer } = event; + + if (dataTransfer && dataTransfer?.files.length > 0) { + const file = dataTransfer.files[0]; + + file + .arrayBuffer() + .then((buffer) => { + listeners + .filter(([type]) => file.type ? type === file.type : isFileType(buffer, type)) + .forEach(([, listerner]) => listerner(file.name, buffer)); + }); + } + + setActive(false); + }, [listeners]); + + const on = (type: FileType, listener: FileDropListener) => { + setListeners((listeners) => { + return [...listeners, [type, listener]]; + }); + }; + + const off = (type: FileType, listener: FileDropListener) => { + setListeners((listeners) => { + return listeners.filter(([key, value]) => key !== type && value !== listener); + }); + }; + + useEffect(() => { + window.addEventListener('dragover', onDragOver); + window.addEventListener('dragleave', onDragLeave); + window.addEventListener('drop', onDrop); + + return () => { + window.removeEventListener('dragover', onDragOver); + window.removeEventListener('dragleave', onDragLeave); + window.removeEventListener('drop', onDrop); + }; + }, [onDrop]); + + return ( + + { children } +
+ + ); +}; + +const useFileDrop = () => { + return useContext(FileDropContext); +}; + +export { + FileDropProvider, + useFileDrop, +}; diff --git a/src/common/FileDrop/index.ts b/src/common/FileDrop/index.ts new file mode 100644 index 000000000..41acfbfd7 --- /dev/null +++ b/src/common/FileDrop/index.ts @@ -0,0 +1,8 @@ +import { FileDropProvider, useFileDrop } from './FileDrop'; +import onFileDrop from './onFileDrop'; + +export { + FileDropProvider, + useFileDrop, + onFileDrop, +}; diff --git a/src/common/FileDrop/onFileDrop.ts b/src/common/FileDrop/onFileDrop.ts new file mode 100644 index 000000000..339b0219b --- /dev/null +++ b/src/common/FileDrop/onFileDrop.ts @@ -0,0 +1,14 @@ +import { useEffect } from 'react'; +import { type FileType, type FileDropListener, useFileDrop } from './FileDrop'; + +const onFileDrop = (types: FileType[], listener: FileDropListener) => { + const { on, off } = useFileDrop(); + + useEffect(() => { + types.forEach((type) => on(type, listener)); + + return () => types.forEach((type) => off(type, listener)); + }, []); +}; + +export default onFileDrop; diff --git a/src/common/FileDrop/utils.ts b/src/common/FileDrop/utils.ts new file mode 100644 index 000000000..f9996aed3 --- /dev/null +++ b/src/common/FileDrop/utils.ts @@ -0,0 +1,19 @@ +import { MIME_SIGNATURES } from 'stremio/common/CONSTANTS'; + +const SIGNATURES = MIME_SIGNATURES as Record; + +const isFileType = (buffer: ArrayBuffer, type: string) => { + const signatures = SIGNATURES[type]; + + return signatures.some((signature) => { + const array = new Uint8Array(buffer); + const signatureBuffer = Buffer.from(signature, 'hex'); + const bufferToCompare = array.subarray(0, signatureBuffer.length); + + return Buffer.compare(signatureBuffer, bufferToCompare) === 0; + }); +}; + +export { + isFileType, +}; diff --git a/src/common/Platform/Platform.tsx b/src/common/Platform/Platform.tsx index 41e74b24f..2212303e4 100644 --- a/src/common/Platform/Platform.tsx +++ b/src/common/Platform/Platform.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext } from 'react'; import { WHITELISTED_HOSTS } from 'stremio/common/CONSTANTS'; -import useShell from './useShell'; +import useShell from 'stremio/common/useShell'; import { name, isMobile } from './device'; interface PlatformContext { diff --git a/src/common/Platform/useShell.ts b/src/common/Platform/useShell.ts deleted file mode 100644 index 5e61bfe84..000000000 --- a/src/common/Platform/useShell.ts +++ /dev/null @@ -1,22 +0,0 @@ -const createId = () => Math.floor(Math.random() * 9999) + 1; - -const useShell = () => { - const transport = globalThis?.qt?.webChannelTransport; - - const send = (method: string, ...args: (string | number)[]) => { - transport?.send(JSON.stringify({ - id: createId(), - type: 6, - object: 'transport', - method: 'onEvent', - args: [method, ...args], - })); - }; - - return { - active: !!transport, - send, - }; -}; - -export default useShell; diff --git a/src/common/animations.less b/src/common/animations.less index c7a30d2fb..91dbe386d 100644 --- a/src/common/animations.less +++ b/src/common/animations.less @@ -69,6 +69,19 @@ transform: translateX(100%); } +.slide-up-enter { + transform: translateY(100%); +} + +.slide-up-active { + transform: translateY(0%); + transition: transform 0.3s cubic-bezier(0.32, 0, 0.67, 0); +} + +.slide-up-exit { + transform: translateY(100%); +} + @keyframes fade-in-no-motion { 0% { opacity: 0; diff --git a/src/common/index.js b/src/common/index.js index 4c514dfbe..82f7a6a0c 100644 --- a/src/common/index.js +++ b/src/common/index.js @@ -1,5 +1,6 @@ // Copyright (C) 2017-2023 Smart code 203358507 +const { FileDropProvider, onFileDrop } = require('./FileDrop'); const { PlatformProvider, usePlatform } = require('./Platform'); const { ToastProvider, useToast } = require('./Toast'); const { TooltipProvider, Tooltip } = require('./Tooltips'); @@ -19,11 +20,15 @@ const useModelState = require('./useModelState'); const useNotifications = require('./useNotifications'); const useOnScrollToBottom = require('./useOnScrollToBottom'); const useProfile = require('./useProfile'); +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, + onFileDrop, PlatformProvider, usePlatform, ToastProvider, @@ -47,7 +52,9 @@ module.exports = { useNotifications, useOnScrollToBottom, useProfile, + useShell, 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 new file mode 100644 index 000000000..1a7bcb6ee --- /dev/null +++ b/src/common/useShell.ts @@ -0,0 +1,72 @@ +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 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)[]) => { + 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, + }; +}; + +export default useShell; 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/Button/Button.tsx b/src/components/Button/Button.tsx index f4b059bd3..f97e1ba82 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -8,6 +8,7 @@ import styles from './Button.less'; type Props = { className?: string, href?: string, + target?: string title?: string, disabled?: boolean, tabIndex?: number, diff --git a/src/components/Checkbox/Checkbox.less b/src/components/Checkbox/Checkbox.less new file mode 100644 index 000000000..a84244ce9 --- /dev/null +++ b/src/components/Checkbox/Checkbox.less @@ -0,0 +1,83 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less'; + +.checkbox { + display: flex; + align-items: center; + overflow: visible; + + .label { + display: flex; + flex-direction: row; + align-items: center; + padding: 0.5rem 0; + cursor: pointer; + + span { + font-size: 0.9rem; + color: var(--primary-foreground-color); + opacity: 0.6; + } + + .link { + font-size: 0.9rem; + color: var(--primary-accent-color); + + &:hover { + text-decoration: underline; + } + } + } + + .checkbox-container { + position: relative; + width: 1.5rem; + height: 1.5rem; + border-radius: 0.3rem; + background-color: var(--overlay-color); + padding: 0.1rem; + display: flex; + flex: none; + margin: 0 1rem 0 0.3rem; + align-items: center; + justify-content: center; + transition: background-color 0.2s ease-in-out; + cursor: pointer; + outline: none; + user-select: none; + outline-width: var(--focus-outline-size); + outline-color: @color-surface-light5; + outline-offset: 2px; + + input[type='checkbox'] { + opacity: 0; + width: 0; + height: 0; + position: absolute; + cursor: pointer; + } + + .checkbox-icon { + width: 100%; + height: 100%; + color: var(--primary-foreground-color); + } + + &.disabled { + cursor: not-allowed; + } + + &.error { + border-color: var(--color-trakt); + } + + &.checked { + background-color: var(--primary-accent-color); + } + + &:hover, &:focus { + outline-style: solid; + } + } +} diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx new file mode 100644 index 000000000..da4ae33eb --- /dev/null +++ b/src/components/Checkbox/Checkbox.tsx @@ -0,0 +1,97 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +import React, { useCallback, ChangeEvent, KeyboardEvent, RefCallback } from 'react'; +import classNames from 'classnames'; +import styles from './Checkbox.less'; +import Button from '../Button'; +import Icon from '@stremio/stremio-icons/react'; + +type Props = { + ref?: RefCallback; + name: string; + disabled?: boolean; + checked?: boolean; + className?: string; + label?: string; + link?: string; + href?: string; + onChange?: (props: { + type: string; + checked: boolean; + reactEvent: KeyboardEvent | ChangeEvent; + nativeEvent: Event; + }) => void; + error?: string; +}; + +const Checkbox = React.forwardRef(({ name, disabled, className, label, href, link, onChange, error, checked }, ref) => { + + const handleSelect = useCallback((event: ChangeEvent) => { + if (!disabled && onChange) { + onChange({ + type: 'select', + checked: event.target.checked, + reactEvent: event, + nativeEvent: event.nativeEvent, + }); + } + }, [disabled, onChange]); + + const onKeyDown = useCallback((event: KeyboardEvent) => { + if ((event.key === 'Enter' || event.key === ' ') && !disabled) { + onChange && onChange({ + type: 'select', + checked: !checked, + reactEvent: event as KeyboardEvent, + nativeEvent: event.nativeEvent, + }); + } + }, [disabled, checked, onChange]); + + return ( +
+ +
+ ); +}); + +export default Checkbox; diff --git a/src/components/Checkbox/index.ts b/src/components/Checkbox/index.ts new file mode 100644 index 000000000..fa5739580 --- /dev/null +++ b/src/components/Checkbox/index.ts @@ -0,0 +1,5 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +import Checkbox from './Checkbox'; + +export default Checkbox; diff --git a/src/components/EventModal/EventModal.js b/src/components/EventModal/EventModal.js index 917190ba1..f4e46b199 100644 --- a/src/components/EventModal/EventModal.js +++ b/src/components/EventModal/EventModal.js @@ -2,7 +2,8 @@ const React = require('react'); const { useTranslation } = require('react-i18next'); -const { Button, ModalDialog } = require('stremio/components'); +const { default: Button } = require('stremio/components/Button'); +const ModalDialog = require('stremio/components/ModalDialog'); const useEvents = require('./useEvents'); const styles = require('./styles'); const { default: Icon } = require('@stremio/stremio-icons/react'); 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/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js b/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js index 832492acb..59946165a 100644 --- a/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js +++ b/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js @@ -12,15 +12,23 @@ const useProfile = require('stremio/common/useProfile'); const usePWA = require('stremio/common/usePWA'); const useTorrent = require('stremio/common/useTorrent'); const { withCoreSuspender } = require('stremio/common/CoreSuspender'); +const useStreamingServer = require('stremio/common/useStreamingServer'); const styles = require('./styles'); const NavMenuContent = ({ onClick }) => { const { t } = useTranslation(); const { core } = useServices(); const profile = useProfile(); + const streamingServer = useStreamingServer(); const { createTorrentFromMagnet } = useTorrent(); const [fullscreen, requestFullscreen, exitFullscreen] = useFullscreen(); const [isIOSPWA, isAndroidPWA] = usePWA(); + const streamingServerWarningDismissed = React.useMemo(() => { + return streamingServer.settings !== null && streamingServer.settings.type === 'Ready' || ( + !isNaN(profile.settings.streamingServerWarningDismissed.getTime()) && + profile.settings.streamingServerWarningDismissed.getTime() > Date.now() + ); + }, [profile.settings, streamingServer.settings]); const logoutButtonOnClick = React.useCallback(() => { core.transport.dispatch({ action: 'Ctx', @@ -38,7 +46,7 @@ const NavMenuContent = ({ onClick }) => { } }, []); return ( -
+
{ +const Slider = ({ className, value, buffered, minimumValue, maximumValue, disabled, onSlide, onComplete, audioBoost }) => { const minimumValueRef = useLiveRef(minimumValue !== null && !isNaN(minimumValue) ? minimumValue : 0); const maximumValueRef = useLiveRef(maximumValue !== null && !isNaN(maximumValue) ? maximumValue : 100); const valueRef = useLiveRef(value !== null && !isNaN(value) ? Math.min(maximumValueRef.current, Math.max(minimumValueRef.current, value)) : 0); @@ -100,13 +100,16 @@ const Slider = ({ className, value, buffered, minimumValue, maximumValue, disabl return (
-
+
-
+
@@ -123,7 +126,8 @@ Slider.propTypes = { maximumValue: PropTypes.number, disabled: PropTypes.bool, onSlide: PropTypes.func, - onComplete: PropTypes.func + onComplete: PropTypes.func, + audioBoost: PropTypes.bool }; module.exports = Slider; diff --git a/src/components/Slider/styles.less b/src/components/Slider/styles.less index 66ad267c7..2bdbbfe72 100644 --- a/src/components/Slider/styles.less +++ b/src/components/Slider/styles.less @@ -2,6 +2,12 @@ @import (reference) '~@stremio/stremio-colors/less/stremio-colors.less'; +@audio-boost-background: linear-gradient(to right, + var(--primary-foreground-color) 0%, + var(--primary-foreground-color) 50%, + var(--warning-accent-color) 75%, + var(--danger-accent-color) 100%) !important; + html.active-slider-within { cursor: grabbing; @@ -37,9 +43,15 @@ html.active-slider-within { .track { z-index: 0; flex: 1; + width: 100%; height: var(--track-size); border-radius: var(--track-size); background-color: var(--overlay-color); + + &.audio-boost { + opacity: 0.3; + background: @audio-boost-background; + } } .track-before { @@ -53,9 +65,19 @@ html.active-slider-within { .track-after { z-index: 2; flex: none; + width: 100%; height: var(--track-size); border-radius: var(--track-size); background-color: var(--primary-foreground-color); + mask-image: linear-gradient(to right, + black 0%, + black var(--mask-width), + transparent var(--mask-width) + ); + + &.audio-boost { + background: @audio-boost-background; + } } .thumb { 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/components/index.ts b/src/components/index.ts index db5210685..bd819f658 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,6 +1,7 @@ import AddonDetailsModal from './AddonDetailsModal'; import BottomSheet from './BottomSheet'; import Button from './Button'; +import Checkbox from './Checkbox'; import Chips from './Chips'; import ColorInput from './ColorInput'; import ContextMenu from './ContextMenu'; @@ -32,6 +33,7 @@ export { AddonDetailsModal, BottomSheet, Button, + Checkbox, Chips, ColorInput, ContextMenu, 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/List/Item/Item.less b/src/routes/Calendar/List/Item/Item.less index aba6bba09..b11feda2a 100644 --- a/src/routes/Calendar/List/Item/Item.less +++ b/src/routes/Calendar/List/Item/Item.less @@ -95,4 +95,4 @@ &:not(.active):hover { border-color: var(--overlay-color); } -} +} \ No newline at end of file diff --git a/src/routes/Calendar/List/List.less b/src/routes/Calendar/List/List.less index f63078680..9f2dfd774 100644 --- a/src/routes/Calendar/List/List.less +++ b/src/routes/Calendar/List/List.less @@ -10,6 +10,10 @@ width: 20rem; padding: 0 1rem; overflow-y: auto; + + @supports (scroll-padding-block-start: 0.15rem) { + scroll-padding-block-start: 0.15rem; + } } @media only screen and (max-width: @small) and (orientation: portrait) { @@ -34,4 +38,4 @@ .list { display: none; } -} +} \ 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/ConsentToggle/ConsentToggle.js b/src/routes/Intro/ConsentToggle/ConsentToggle.js deleted file mode 100644 index 9a0210607..000000000 --- a/src/routes/Intro/ConsentToggle/ConsentToggle.js +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (C) 2017-2023 Smart code 203358507 - -const React = require('react'); -const PropTypes = require('prop-types'); -const classnames = require('classnames'); -const { Button, Toggle } = require('stremio/components'); -const styles = require('./styles'); - -const ConsentToggle = React.forwardRef(({ className, label, link, href, onToggle, ...props }, ref) => { - const toggleOnClick = React.useCallback((event) => { - if (typeof props.onClick === 'function') { - props.onClick(event); - } - - if (!event.nativeEvent.togglePrevented && typeof onToggle === 'function') { - onToggle({ - type: 'toggle', - reactEvent: event, - nativeEvent: event.nativeEvent - }); - } - }, [onToggle, props.onClick]); - const linkOnClick = React.useCallback((event) => { - event.nativeEvent.togglePrevented = true; - }, []); - return ( - -
- {label} - {' '} - { - typeof link === 'string' && link.length > 0 && typeof href === 'string' && href.length > 0 ? - - : - null - } -
-
- ); -}); - -ConsentToggle.displayName = 'ConsentToggle'; - -ConsentToggle.propTypes = { - className: PropTypes.string, - checked: PropTypes.bool, - label: PropTypes.string, - link: PropTypes.string, - href: PropTypes.string, - onToggle: PropTypes.func, - onClick: PropTypes.func -}; - -module.exports = ConsentToggle; diff --git a/src/routes/Intro/ConsentToggle/index.js b/src/routes/Intro/ConsentToggle/index.js deleted file mode 100644 index 8edfe4a27..000000000 --- a/src/routes/Intro/ConsentToggle/index.js +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright (C) 2017-2023 Smart code 203358507 - -const ConsentToggle = require('./ConsentToggle'); - -module.exports = ConsentToggle; diff --git a/src/routes/Intro/ConsentToggle/styles.less b/src/routes/Intro/ConsentToggle/styles.less deleted file mode 100644 index e8229e244..000000000 --- a/src/routes/Intro/ConsentToggle/styles.less +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (C) 2017-2023 Smart code 203358507 - -@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less'; - -:import('~stremio/components/Toggle/styles.less') { - checkbox-icon: icon; -} - -.consent-toggle-container { - display: flex; - flex-direction: row; - align-items: center; - padding: 0.5rem 0; - border-radius: var(--border-radius); - - &:focus { - outline: none; - background-color: var(--overlay-color); - } - - &:global(.checked) { - .label { - opacity: 1; - } - } - - .label { - flex: 1; - margin-left: 1rem; - font-size: 0.9rem; - color: var(--primary-foreground-color); - opacity: 0.6; - - .link { - font-size: 0.9rem; - color: var(--primary-accent-color); - - &:hover { - text-decoration: underline; - } - } - } -} \ No newline at end of file diff --git a/src/routes/Intro/Intro.js b/src/routes/Intro/Intro.js index fc98fe5cf..989e6febd 100644 --- a/src/routes/Intro/Intro.js +++ b/src/routes/Intro/Intro.js @@ -8,9 +8,8 @@ const { default: Icon } = require('@stremio/stremio-icons/react'); const { Modal, useRouteFocused } = require('stremio-router'); const { useServices } = require('stremio/services'); const { useBinaryState } = require('stremio/common'); -const { Button, Image } = require('stremio/components'); +const { Button, Image, Checkbox } = require('stremio/components'); const CredentialsTextInput = require('./CredentialsTextInput'); -const ConsentToggle = require('./ConsentToggle'); const PasswordResetModal = require('./PasswordResetModal'); const useFacebookLogin = require('./useFacebookLogin'); const styles = require('./styles'); @@ -308,30 +307,27 @@ const Intro = ({ queryParams }) => { onChange={confirmPasswordOnChange} onSubmit={confirmPasswordOnSubmit} /> - - - : 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 2871a9b3f..f22a12236 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,65 +59,54 @@ const Library = ({ model, urlParams, queryParams }) => { }, [hasNextPage, loadNextPage]); const onScroll = useOnScrollToBottom(onScrollToBottom, SCROLL_TO_BOTTOM_TRESHOLD); React.useLayoutEffect(() => { - if (profile.auth !== null && library.selected && library.selected.request.page === 1 && library.catalog.length !== 0 ) { + if (scrollContainerRef.current !== null && library.selected && library.selected.request.page === 1 && library.catalog.length !== 0) { scrollContainerRef.current.scrollTop = 0; } }, [profile.auth, library.selected]); return ( -
- { - 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/StreamsList/Stream/Stream.js b/src/routes/MetaDetails/StreamsList/Stream/Stream.js index 768b8d40a..41f81b386 100644 --- a/src/routes/MetaDetails/StreamsList/Stream/Stream.js +++ b/src/routes/MetaDetails/StreamsList/Stream/Stream.js @@ -181,12 +181,17 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio const renderMenu = React.useMemo(() => function renderMenu() { return (
+
+ {description} +
{ streamLink && } diff --git a/src/routes/MetaDetails/StreamsList/Stream/styles.less b/src/routes/MetaDetails/StreamsList/Stream/styles.less index 0461c77ee..06720a879 100644 --- a/src/routes/MetaDetails/StreamsList/Stream/styles.less +++ b/src/routes/MetaDetails/StreamsList/Stream/styles.less @@ -111,12 +111,29 @@ background-color: var(--secondary-accent-color); } + .menu-icon { + flex: none; + width: 1.7rem; + height: 1.7rem; + margin-right: 1rem; + color: var(--color-placeholder); + } + .context-menu-container { max-width: calc(90% - 1.5rem); z-index: 2; .context-menu-content { --spatial-navigation-contain: contain; + + .context-menu-title { + font-size: 0.9rem; + padding: 1rem 1.5rem; + font-weight: 100; + border-bottom: 1px solid var(--color-placeholder); + color: var(--primary-foreground-color); + white-space: break-spaces; + } .context-menu-option-container { display: flex; @@ -131,8 +148,9 @@ .context-menu-option-label { font-size: 1rem; - font-weight: 500; - color:var(--primary-foreground-color); + font-weight: 300; + color: var(--primary-foreground-color); + text-transform: capitalize; } } } 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/ControlBar/ControlBar.js b/src/routes/Player/ControlBar/ControlBar.js index 13886b9f8..c81d0e38e 100644 --- a/src/routes/Player/ControlBar/ControlBar.js +++ b/src/routes/Player/ControlBar/ControlBar.js @@ -41,6 +41,8 @@ const ControlBar = ({ onToggleOptionsMenu, onToggleStatisticsMenu, onContextMenu, + onTouchStart, + onTouchEnd, ...props }) => { const { chromecast } = useServices(); @@ -104,7 +106,7 @@ const ControlBar = ({ }; }, []); return ( -
+
@@ -207,6 +210,8 @@ ControlBar.propTypes = { onToggleOptionsMenu: PropTypes.func, onToggleStatisticsMenu: PropTypes.func, onContextMenu: PropTypes.func, + onTouchStart: PropTypes.func, + onTouchEnd: PropTypes.func, }; module.exports = ControlBar; diff --git a/src/routes/Player/ControlBar/VolumeSlider/VolumeSlider.js b/src/routes/Player/ControlBar/VolumeSlider/VolumeSlider.js index 420f4ba41..1980b4943 100644 --- a/src/routes/Player/ControlBar/VolumeSlider/VolumeSlider.js +++ b/src/routes/Player/ControlBar/VolumeSlider/VolumeSlider.js @@ -5,13 +5,16 @@ const PropTypes = require('prop-types'); const classnames = require('classnames'); const debounce = require('lodash.debounce'); const { useRouteFocused } = require('stremio-router'); +const { useServices } = require('stremio/services'); const { Slider } = require('stremio/components'); const styles = require('./styles'); -const VolumeSlider = ({ className, volume, onVolumeChangeRequested }) => { +const VolumeSlider = ({ className, volume, onVolumeChangeRequested, muted }) => { + const { shell } = useServices(); const disabled = volume === null || isNaN(volume); const routeFocused = useRouteFocused(); const [slidingVolume, setSlidingVolume] = React.useState(null); + const maxVolume = shell.active ? 200: 100; const resetVolumeDebounced = React.useCallback(debounce(() => { setSlidingVolume(null); }, 100), []); @@ -45,15 +48,18 @@ const VolumeSlider = ({ className, volume, onVolumeChangeRequested }) => { className={classnames(className, styles['volume-slider'], { 'active': slidingVolume !== null })} value={ !disabled ? - slidingVolume !== null ? slidingVolume : volume + !muted ? + slidingVolume !== null ? slidingVolume : volume + : 0 : 100 } minimumValue={0} - maximumValue={100} + maximumValue={maxVolume} disabled={disabled} onSlide={onSlide} onComplete={onComplete} + audioBoost={!!shell.active} /> ); }; @@ -61,7 +67,8 @@ const VolumeSlider = ({ className, volume, onVolumeChangeRequested }) => { VolumeSlider.propTypes = { className: PropTypes.string, volume: PropTypes.number, - onVolumeChangeRequested: PropTypes.func + onVolumeChangeRequested: PropTypes.func, + muted: PropTypes.bool, }; module.exports = VolumeSlider; diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index d41e1687f..7c80a1297 100644 --- a/src/routes/Player/Player.js +++ b/src/routes/Player/Player.js @@ -8,7 +8,7 @@ const langs = require('langs'); const { useTranslation } = require('react-i18next'); const { useRouteFocused } = require('stremio-router'); const { useServices } = require('stremio/services'); -const { useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender } = require('stremio/common'); +const { onFileDrop, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS } = require('stremio/common'); const { HorizontalNavBar, Transition, ContextMenu } = require('stremio/components'); const BufferingLoader = require('./BufferingLoader'); const VolumeChangeIndicator = require('./VolumeChangeIndicator'); @@ -137,11 +137,20 @@ const Player = ({ urlParams, queryParams }) => { toast.show({ type: 'success', title: t('PLAYER_SUBTITLES_LOADED'), - message: track.exclusive ? t('PLAYER_SUBTITLES_LOADED_EXCLUSIVE') : t('PLAYER_SUBTITLES_LOADED_ORIGIN', { origin: track.origin }), + message: + track.exclusive ? t('PLAYER_SUBTITLES_LOADED_EXCLUSIVE') : + track.local ? t('PLAYER_SUBTITLES_LOADED_LOCAL') : + t('PLAYER_SUBTITLES_LOADED_ORIGIN', { origin: track.origin }), timeout: 3000 }); }, []); + const onExtraSubtitlesTrackAdded = React.useCallback((track) => { + if (track.local) { + video.setExtraSubtitlesTrack(track.id); + } + }, []); + const onPlayRequested = React.useCallback(() => { video.setProp('paused', false); setSeeking(false); @@ -176,13 +185,11 @@ const Player = ({ urlParams, queryParams }) => { }, []); const onSubtitlesTrackSelected = React.useCallback((id) => { - video.setProp('selectedSubtitlesTrackId', id); - video.setProp('selectedExtraSubtitlesTrackId', null); + video.setSubtitlesTrack(id); }, []); const onExtraSubtitlesTrackSelected = React.useCallback((id) => { - video.setProp('selectedSubtitlesTrackId', null); - video.setProp('selectedExtraSubtitlesTrackId', id); + video.setExtraSubtitlesTrack(id); }, []); const onAudioTrackSelected = React.useCallback((id) => { @@ -274,6 +281,10 @@ const Player = ({ urlParams, queryParams }) => { event.nativeEvent.immersePrevented = true; }, []); + onFileDrop(CONSTANTS.SUPPORTED_LOCAL_SUBTITLES, async (filename, buffer) => { + video.addLocalSubtitles(filename, buffer); + }); + React.useEffect(() => { setError(null); video.unload(); @@ -300,6 +311,7 @@ const Player = ({ urlParams, queryParams }) => { 0, forceTranscoding: forceTranscoding || casting, maxAudioChannels: settings.surroundSound ? 32 : 2, + hardwareDecoding: settings.hardwareDecoding, streamingServerURL: streamingServer.baseUrl ? casting ? streamingServer.baseUrl @@ -307,7 +319,7 @@ const Player = ({ urlParams, queryParams }) => { streamingServer.selected.transportUrl : null, - seriesInfo: player.seriesInfo + seriesInfo: player.seriesInfo, }, { chromecastTransport: chromecast.active ? chromecast.transport : null, shellTransport: shell.active ? shell.transport : null, @@ -498,14 +510,14 @@ const Player = ({ urlParams, queryParams }) => { } case 'ArrowUp': { if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) { - onVolumeChangeRequested(video.state.volume + 5); + onVolumeChangeRequested(Math.min(video.state.volume + 5, 200)); } break; } case 'ArrowDown': { if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) { - onVolumeChangeRequested(video.state.volume - 5); + onVolumeChangeRequested(Math.max(video.state.volume - 5, 0)); } break; @@ -563,13 +575,13 @@ const Player = ({ urlParams, queryParams }) => { } }; const onWheel = ({ deltaY }) => { + if (menusOpen || video.state.volume === null) return; + if (deltaY > 0) { - if (!menusOpen && video.state.volume !== null) { - onVolumeChangeRequested(video.state.volume - 5); - } + onVolumeChangeRequested(Math.max(video.state.volume - 5, 0)); } else { - if (!menusOpen && video.state.volume !== null) { - onVolumeChangeRequested(video.state.volume + 5); + if (video.state.volume < 100) { + onVolumeChangeRequested(Math.min(video.state.volume + 5, 100)); } } }; @@ -590,6 +602,7 @@ const Player = ({ urlParams, queryParams }) => { video.events.on('ended', onEnded); video.events.on('subtitlesTrackLoaded', onSubtitlesTrackLoaded); video.events.on('extraSubtitlesTrackLoaded', onExtraSubtitlesTrackLoaded); + video.events.on('extraSubtitlesTrackAdded', onExtraSubtitlesTrackAdded); video.events.on('implementationChanged', onImplementationChanged); return () => { @@ -597,6 +610,7 @@ const Player = ({ urlParams, queryParams }) => { video.events.off('ended', onEnded); video.events.off('subtitlesTrackLoaded', onSubtitlesTrackLoaded); video.events.off('extraSubtitlesTrackLoaded', onExtraSubtitlesTrackLoaded); + video.events.off('extraSubtitlesTrackAdded', onExtraSubtitlesTrackAdded); video.events.off('implementationChanged', onImplementationChanged); }; }, []); 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/Player/SideDrawerButton/SideDrawerButton.tsx b/src/routes/Player/SideDrawerButton/SideDrawerButton.tsx index 5f5590476..9d5897548 100644 --- a/src/routes/Player/SideDrawerButton/SideDrawerButton.tsx +++ b/src/routes/Player/SideDrawerButton/SideDrawerButton.tsx @@ -1,6 +1,6 @@ // Copyright (C) 2017-2024 Smart code 203358507 -import React from 'react'; +import React, { BaseSyntheticEvent } from 'react'; import classNames from 'classnames'; import Icon from '@stremio/stremio-icons/react'; import styles from './SideDrawerButton.less'; @@ -9,11 +9,13 @@ type Props = { className: string, onClick: () => void, onContextMenu: () => void, + onTouchStart: (event: BaseSyntheticEvent) => void, + onTouchEnd: () => void, }; -const SideDrawerButton = ({ className, onClick, onContextMenu }: Props) => { +const SideDrawerButton = ({ className, onClick, onContextMenu, onTouchStart, onTouchEnd }: Props) => { return ( -
+
); diff --git a/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js b/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js index 44cd20864..39bc771e6 100644 --- a/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js +++ b/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js @@ -10,11 +10,13 @@ const styles = require('./styles'); const { t } = require('i18next'); const ORIGIN_PRIORITIES = { + 'LOCAL': 3, 'EMBEDDED': 2, - 'EXCLUSIVE': 1 + 'EXCLUSIVE': 1, }; const LANGUAGE_PRIORITIES = { - 'eng': 1 + 'local': 2, + 'eng': 1, }; const SubtitlesMenu = React.memo((props) => { @@ -161,7 +163,11 @@ const SubtitlesMenu = React.memo((props) => { {subtitlesLanguages.map((lang, index) => (
); diff --git a/src/routes/Player/SubtitlesMenu/styles.less b/src/routes/Player/SubtitlesMenu/styles.less index d47d6ab75..71f1d5cb4 100644 --- a/src/routes/Player/SubtitlesMenu/styles.less +++ b/src/routes/Player/SubtitlesMenu/styles.less @@ -106,6 +106,10 @@ .subtitles-settings-container { width: 17rem; + .settings-list { + overflow-y: scroll; + } + .spacing { flex: 1; } diff --git a/src/routes/Player/useVideo.js b/src/routes/Player/useVideo.js index ef292bce6..41c33c573 100644 --- a/src/routes/Player/useVideo.js +++ b/src/routes/Player/useVideo.js @@ -79,10 +79,31 @@ const useVideo = () => { }); }; + const addLocalSubtitles = (filename, buffer) => { + dispatch({ + type: 'command', + commandName: 'addLocalSubtitles', + commandArgs: { + filename, + buffer, + }, + }); + }; + const setProp = (name, value) => { dispatch({ type: 'setProp', propName: name, propValue: value }); }; + const setSubtitlesTrack = (id) => { + setProp('selectedSubtitlesTrackId', id); + setProp('selectedExtraSubtitlesTrackId', null); + }; + + const setExtraSubtitlesTrack = (id) => { + setProp('selectedSubtitlesTrackId', null); + setProp('selectedExtraSubtitlesTrackId', id); + }; + const onError = (error) => { events.emit('error', error); }; @@ -99,6 +120,10 @@ const useVideo = () => { events.emit('extraSubtitlesTrackLoaded', track); }; + const onExtraSubtitlesTrackAdded = (track) => { + events.emit('extraSubtitlesTrackAdded', track); + }; + const onPropChanged = (name, value) => { setState((state) => ({ ...state, @@ -125,6 +150,7 @@ const useVideo = () => { video.current.on('implementationChanged', onImplementationChanged); video.current.on('subtitlesTrackLoaded', onSubtitlesTrackLoaded); video.current.on('extraSubtitlesTrackLoaded', onExtraSubtitlesTrackLoaded); + video.current.on('extraSubtitlesTrackAdded', onExtraSubtitlesTrackAdded); return () => video.current.destroy(); }, []); @@ -136,7 +162,10 @@ const useVideo = () => { load, unload, addExtraSubtitlesTracks, + addLocalSubtitles, setProp, + setSubtitlesTrack, + setExtraSubtitlesTrack, }; }; 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 24eaaaf5d..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')}
@@ -488,17 +502,19 @@ const Settings = () => { {...playInExternalPlayerSelect} />
-
-
-
{ t('SETTINGS_HWDEC') }
-
- -
+ { + shell.active && +
+
+
{ t('SETTINGS_HWDEC') }
+
+ +
+ }
{ t('SETTINGS_NAV_STREAMING') }
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/services/DragAndDrop/DragAndDrop.js b/src/services/DragAndDrop/DragAndDrop.js index bc54e2f35..1538c328e 100644 --- a/src/services/DragAndDrop/DragAndDrop.js +++ b/src/services/DragAndDrop/DragAndDrop.js @@ -36,6 +36,12 @@ function DragAndDrop({ core }) { } break; } + case 'application/x-subrip': + break; + case 'text/vtt': + break; + case '': + break; default: { events.emit('error', { message: 'Unsupported file', 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 {}; diff --git a/webpack.config.js b/webpack.config.js index 878d52f97..36f6b6e50 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -163,7 +163,7 @@ module.exports = (env, argv) => ({ exclude: /node_modules/, type: 'asset/resource', generator: { - filename: `${COMMIT_HASH}/images/[name][ext][query]` + filename: 'images/[name][ext][query]' } }, { @@ -231,9 +231,9 @@ module.exports = (env, argv) => ({ }), new CopyWebpackPlugin({ patterns: [ - { from: 'favicons', to: `${COMMIT_HASH}/favicons` }, - { from: 'images', to: `${COMMIT_HASH}/images` }, - { from: 'screenshots/*.webp', to: `${COMMIT_HASH}` }, + { from: 'favicons', to: 'favicons' }, + { from: 'images', to: 'images' }, + { from: 'screenshots/*.webp', to: './' }, ] }), new MiniCssExtractPlugin({ @@ -243,8 +243,8 @@ module.exports = (env, argv) => ({ template: './src/index.html', inject: false, scriptLoading: 'blocking', - faviconsPath: `${COMMIT_HASH}/favicons`, - imagesPath: `${COMMIT_HASH}/images`, + faviconsPath: 'favicons', + imagesPath: 'images', }), new WebpackPwaManifest({ name: 'Stremio Web', @@ -261,33 +261,33 @@ module.exports = (env, argv) => ({ icons: [ { src: 'images/icon.png', - destination: `${COMMIT_HASH}/icons`, + destination: 'icons', sizes: [196, 512], purpose: 'any' }, { src: 'images/maskable_icon.png', - destination: `${COMMIT_HASH}/maskable_icons`, + destination: 'maskable_icons', sizes: [196, 512], purpose: 'maskable', ios: true }, { src: 'favicons/favicon.ico', - destination: `${COMMIT_HASH}/favicons`, + destination: 'favicons', sizes: [256], } ], screenshots : [ { - src: `${COMMIT_HASH}/screenshots/board_wide.webp`, + src: 'screenshots/board_wide.webp', sizes: '1440x900', type: 'image/webp', form_factor: 'wide', label: 'Homescreen of Stremio' }, { - src: `${COMMIT_HASH}/screenshots/board_narrow.webp`, + src: 'screenshots/board_narrow.webp', sizes: '414x896', type: 'image/webp', form_factor: 'narrow',