diff --git a/.well-known/apple-app-site-association b/.well-known/apple-app-site-association new file mode 100644 index 000000000..54b0dd1bf --- /dev/null +++ b/.well-known/apple-app-site-association @@ -0,0 +1,67 @@ +{ + "applinks": { + "apps": [], + "details": [ + { + "appID": "9EWRZ4QP3J.com.stremio.one", + "paths": [ + "/", + "/#/player/*", + "/#/discover/*", + "/#/detail/*", + "/#/library/*", + "/#/addons/*", + "/#/settings", + "/#/search/*" + ], + "components": [ + { + "/": "/", + "#": "/player/*", + "comment": "Matches deep link for player" + }, + { + "/": "/", + "#": "/discover/*", + "comment": "Matches deep link for discover" + }, + { + "/": "/", + "#": "/detail/*", + "comment": "Matches deep link for detail" + }, + { + "/": "/", + "#": "/library/*", + "comment": "Matches deep link for library" + }, + { + "/": "/", + "#": "/addons/*", + "comment": "Matches deep link for addons" + }, + { + "/": "/", + "#": "/settings", + "comment": "Matches deep link for settings" + }, + { + "/": "/", + "#": "/search/*", + "comment": "Matches deep link for search" + } + ] + } + ] + }, + "activitycontinuation": { + "apps": [ + "9EWRZ4QP3J.com.stremio.one" + ] + }, + "webcredentials": { + "apps": [ + "9EWRZ4QP3J.com.stremio.one" + ] + } +} diff --git a/package-lock.json b/package-lock.json index 0824f882f..7f2e0b955 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,20 @@ { "name": "stremio", - "version": "5.0.0-beta.20", + "version": "5.0.0-beta.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stremio", - "version": "5.0.0-beta.20", + "version": "5.0.0-beta.21", "license": "gpl-2.0", "dependencies": { "@babel/runtime": "7.26.0", "@sentry/browser": "8.42.0", "@stremio/stremio-colors": "5.2.0", - "@stremio/stremio-core-web": "0.49.0", + "@stremio/stremio-core-web": "0.49.3", "@stremio/stremio-icons": "5.4.1", - "@stremio/stremio-video": "0.0.53", + "@stremio/stremio-video": "0.0.60", "a-color-picker": "1.2.1", "bowser": "2.11.0", "buffer": "6.0.3", @@ -23,6 +23,7 @@ "filter-invalid-dom-props": "3.0.1", "hat": "^0.0.3", "i18next": "^24.0.5", + "jwt-decode": "^4.0.0", "langs": "github:Stremio/nodejs-langs", "lodash.debounce": "4.0.8", "lodash.intersection": "4.4.0", @@ -36,7 +37,7 @@ "react-i18next": "^15.1.3", "react-is": "18.3.1", "spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6", - "stremio-translations": "github:Stremio/stremio-translations#62bcc6e8f44258203c7375af59210771efb6f634", + "stremio-translations": "github:Stremio/stremio-translations#a6be0425573917c2e82b66d28968c1a4d444cb96", "url": "0.11.4", "use-long-press": "^3.2.0" }, @@ -3371,9 +3372,9 @@ "integrity": "sha512-dYlPgu9W/H7c9s1zmW5tiDnRenaUa4Hg1QCyOg1lhOcgSfM/bVTi5nnqX+IfvGTTUNA0zgzh8hI3o3miwnZxTg==" }, "node_modules/@stremio/stremio-core-web": { - "version": "0.49.0", - "resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.49.0.tgz", - "integrity": "sha512-oxJRVAE6z6Eh1B0qomdz6L2CVaTkwt70kDNC1TmHyGNo+Hhp2RaMlygqBKvBLXyHUXi82R67Mc11gT/JqlmaMw==", + "version": "0.49.3", + "resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.49.3.tgz", + "integrity": "sha512-Ql/08LbwU99IUL6fOLy+v1Iv75boHXpunEPScKgXJALdq/OV5tZLG/IycN0O+5+50Nc/NHrI6HslnMNLTWA8JQ==", "license": "MIT", "dependencies": { "@babel/runtime": "7.24.1" @@ -3409,9 +3410,10 @@ ] }, "node_modules/@stremio/stremio-video": { - "version": "0.0.53", - "resolved": "https://registry.npmjs.org/@stremio/stremio-video/-/stremio-video-0.0.53.tgz", - "integrity": "sha512-hSlk8GqMdk4N8VbcdvduYqWVZsQLgHyU7GfFmd1k+t0pSpDKAhI3C6dohG5Sr09CKCjHa8D1rls+CwMNPXLSGw==", + "version": "0.0.60", + "resolved": "https://registry.npmjs.org/@stremio/stremio-video/-/stremio-video-0.0.60.tgz", + "integrity": "sha512-RbmSi+Lk+3pb6f2ZkGVCnoMoJoujvVvSLDHiLGkXnzQwjYf2B2022NKlAQmHRuHN1sjD+VEsKD8foQH4hXGG1A==", + "license": "MIT", "dependencies": { "buffer": "6.0.3", "color": "4.2.3", @@ -10233,6 +10235,15 @@ "node": ">=4.0" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -13372,9 +13383,9 @@ } }, "node_modules/stremio-translations": { - "version": "1.44.9", - "resolved": "git+ssh://git@github.com/Stremio/stremio-translations.git#62bcc6e8f44258203c7375af59210771efb6f634", - "integrity": "sha512-8Sc5Qvd4IiObwGXkmj1XFXFavUc15My5po6G48HHDBbp42SVc5I/t7h+1yxW1A81byyBCXbL23a9iU9v49vpQA==", + "version": "1.44.10", + "resolved": "git+ssh://git@github.com/Stremio/stremio-translations.git#a6be0425573917c2e82b66d28968c1a4d444cb96", + "integrity": "sha512-77kVE/eos/SA16kzeK7TTWmqoLF0mLPCJXjITwVIVzMHr8XyBPZFOfmiVEg4M6W1W7qYqA+dHhzicyLs7hJhlw==", "license": "MIT" }, "node_modules/string_decoder": { diff --git a/package.json b/package.json index 4cab1781f..c12a0b7e8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stremio", "displayName": "Stremio", - "version": "5.0.0-beta.20", + "version": "5.0.0-beta.21", "author": "Smart Code OOD", "private": true, "license": "gpl-2.0", @@ -16,9 +16,9 @@ "@babel/runtime": "7.26.0", "@sentry/browser": "8.42.0", "@stremio/stremio-colors": "5.2.0", - "@stremio/stremio-core-web": "0.49.0", + "@stremio/stremio-core-web": "0.49.3", "@stremio/stremio-icons": "5.4.1", - "@stremio/stremio-video": "0.0.53", + "@stremio/stremio-video": "0.0.60", "a-color-picker": "1.2.1", "bowser": "2.11.0", "buffer": "6.0.3", @@ -27,6 +27,7 @@ "filter-invalid-dom-props": "3.0.1", "hat": "^0.0.3", "i18next": "^24.0.5", + "jwt-decode": "^4.0.0", "langs": "github:Stremio/nodejs-langs", "lodash.debounce": "4.0.8", "lodash.intersection": "4.4.0", @@ -40,7 +41,7 @@ "react-i18next": "^15.1.3", "react-is": "18.3.1", "spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6", - "stremio-translations": "github:Stremio/stremio-translations#62bcc6e8f44258203c7375af59210771efb6f634", + "stremio-translations": "github:Stremio/stremio-translations#a6be0425573917c2e82b66d28968c1a4d444cb96", "url": "0.11.4", "use-long-press": "^3.2.0" }, diff --git a/src/App/App.js b/src/App/App.js index d3a1ce188..38be271ac 100644 --- a/src/App/App.js +++ b/src/App/App.js @@ -21,7 +21,6 @@ const RouterWithProtectedRoutes = withCoreSuspender(withProtectedRoutes(Router)) const App = () => { const { i18n } = useTranslation(); const shell = useShell(); - const [windowHidden, setWindowHidden] = React.useState(false); const onPathNotMatch = React.useCallback(() => { return NotFound; }, []); @@ -100,14 +99,23 @@ const App = () => { }; }, []); - // Handle shell window visibility changed event + // Handle shell events React.useEffect(() => { - const onWindowVisibilityChanged = (state) => { - setWindowHidden(state.visible === false && state.visibility === 0); + const onOpenMedia = (data) => { + if (data.startsWith('stremio:///')) return; + if (data.startsWith('stremio://')) { + const transportUrl = data.replace('stremio://', 'https://'); + if (URL.canParse(transportUrl)) { + window.location.href = `#/addons?addon=${encodeURIComponent(transportUrl)}`; + } + } }; - shell.on('win-visibility-changed', onWindowVisibilityChanged); - return () => shell.off('win-visibility-changed', onWindowVisibilityChanged); + shell.on('open-media', onOpenMedia); + + return () => { + shell.off('open-media', onOpenMedia); + }; }, []); React.useEffect(() => { @@ -118,7 +126,7 @@ const App = () => { i18n.changeLanguage(args.settings.interfaceLanguage); } - if (args?.settings?.quitOnClose && windowHidden) { + if (args?.settings?.quitOnClose && shell.windowClosed) { shell.send('quit'); } @@ -131,7 +139,7 @@ const App = () => { i18n.changeLanguage(state.profile.settings.interfaceLanguage); } - if (state?.profile?.settings?.quitOnClose && windowHidden) { + if (state?.profile?.settings?.quitOnClose && shell.windowClosed) { shell.send('quit'); } }; @@ -176,7 +184,7 @@ const App = () => { services.core.transport.off('CoreEvent', onCoreEvent); } }; - }, [initialized, windowHidden]); + }, [initialized, shell.windowClosed]); return ( diff --git a/src/App/styles.less b/src/App/styles.less index 50819f883..373b46900 100644 --- a/src/App/styles.less +++ b/src/App/styles.less @@ -151,14 +151,13 @@ svg { html { width: @html-width; height: @html-height; - font-family: 'PlusJakartaSans', 'sans-serif'; + font-family: 'PlusJakartaSans', 'Arial', 'Helvetica', 'sans-serif'; overflow: auto; overscroll-behavior: none; user-select: none; touch-action: manipulation; -webkit-tap-highlight-color: transparent; - @media (display-mode: standalone) { width: @html-standalone-width; height: @html-standalone-height; @@ -168,6 +167,7 @@ html { width: 100%; height: 100%; background: linear-gradient(41deg, var(--primary-background-color) 0%, var(--secondary-background-color) 100%); + -webkit-font-smoothing: antialiased; :global(#app) { position: relative; diff --git a/src/common/index.js b/src/common/index.js index 82f7a6a0c..55ccfe045 100644 --- a/src/common/index.js +++ b/src/common/index.js @@ -14,12 +14,13 @@ const languages = require('./languages'); const routesRegexp = require('./routesRegexp'); const useAnimationFrame = require('./useAnimationFrame'); const useBinaryState = require('./useBinaryState'); -const useFullscreen = require('./useFullscreen'); +const { default: useFullscreen } = require('./useFullscreen'); const useLiveRef = require('./useLiveRef'); const useModelState = require('./useModelState'); const useNotifications = require('./useNotifications'); const useOnScrollToBottom = require('./useOnScrollToBottom'); const useProfile = require('./useProfile'); +const { default: useSettings } = require('./useSettings'); const { default: useShell } = require('./useShell'); const useStreamingServer = require('./useStreamingServer'); const useTorrent = require('./useTorrent'); @@ -52,6 +53,7 @@ module.exports = { useNotifications, useOnScrollToBottom, useProfile, + useSettings, useShell, useStreamingServer, useTorrent, diff --git a/src/common/useFullscreen.js b/src/common/useFullscreen.js deleted file mode 100644 index caf7a0219..000000000 --- a/src/common/useFullscreen.js +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (C) 2017-2023 Smart code 203358507 - -const React = require('react'); - -const useFullscreen = () => { - const [fullscreen, setFullscreen] = React.useState(document.fullscreenElement === document.documentElement); - const requestFullscreen = React.useCallback(() => { - document.documentElement.requestFullscreen(); - }, []); - const exitFullscreen = React.useCallback(() => { - document.exitFullscreen(); - }, []); - const toggleFullscreen = React.useCallback(() => { - if (fullscreen) { - exitFullscreen(); - } else { - requestFullscreen(); - } - }, [fullscreen]); - React.useEffect(() => { - const onFullscreenChange = () => { - setFullscreen(document.fullscreenElement === document.documentElement); - }; - document.addEventListener('fullscreenchange', onFullscreenChange); - return () => { - document.removeEventListener('fullscreenchange', onFullscreenChange); - }; - }, []); - return [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen]; -}; - -module.exports = useFullscreen; diff --git a/src/common/useFullscreen.ts b/src/common/useFullscreen.ts new file mode 100644 index 000000000..b63fb9dd2 --- /dev/null +++ b/src/common/useFullscreen.ts @@ -0,0 +1,66 @@ +// Copyright (C) 2017-2023 Smart code 203358507 + +import { useCallback, useEffect, useState } from 'react'; +import useShell, { type WindowVisibility } from './useShell'; +import useSettings from './useSettings'; + +const useFullscreen = () => { + const shell = useShell(); + const [settings] = useSettings(); + + const [fullscreen, setFullscreen] = useState(false); + + const requestFullscreen = useCallback(() => { + if (shell.active) { + shell.send('win-set-visibility', { fullscreen: true }); + } else { + document.documentElement.requestFullscreen(); + } + }, []); + + const exitFullscreen = useCallback(() => { + if (shell.active) { + shell.send('win-set-visibility', { fullscreen: false }); + } else { + document.exitFullscreen(); + } + }, []); + + const toggleFullscreen = useCallback(() => { + fullscreen ? exitFullscreen() : requestFullscreen(); + }, [fullscreen]); + + useEffect(() => { + const onWindowVisibilityChanged = (state: WindowVisibility) => { + setFullscreen(state.isFullscreen === true); + }; + + const onFullscreenChange = () => { + setFullscreen(document.fullscreenElement === document.documentElement); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.code === 'Escape' && settings.escExitFullscreen) { + exitFullscreen(); + } + + if (event.code === 'F11' && shell.active) { + toggleFullscreen(); + } + }; + + shell.on('win-visibility-changed', onWindowVisibilityChanged); + document.addEventListener('keydown', onKeyDown); + document.addEventListener('fullscreenchange', onFullscreenChange); + + return () => { + shell.off('win-visibility-changed', onWindowVisibilityChanged); + document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('fullscreenchange', onFullscreenChange); + }; + }, [settings.escExitFullscreen]); + + return [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen]; +}; + +export default useFullscreen; diff --git a/src/routes/Player/useSettings.js b/src/common/useSettings.ts similarity index 54% rename from src/routes/Player/useSettings.js rename to src/common/useSettings.ts index e6976cd9d..16dc2cd2f 100644 --- a/src/routes/Player/useSettings.js +++ b/src/common/useSettings.ts @@ -1,13 +1,14 @@ -// Copyright (C) 2017-2023 Smart code 203358507 +// Copyright (C) 2017-2025 Smart code 203358507 -const React = require('react'); -const { useServices } = require('stremio/services'); -const { useProfile } = require('stremio/common'); +import { useCallback } from 'react'; +import { useServices } from 'stremio/services'; +import useProfile from './useProfile'; -const useSettings = () => { +const useSettings = (): [Settings, (settings: Settings) => void] => { const { core } = useServices(); const profile = useProfile(); - const updateSettings = React.useCallback((settings) => { + + const updateSettings = useCallback((settings: Settings) => { core.transport.dispatch({ action: 'Ctx', args: { @@ -19,7 +20,8 @@ const useSettings = () => { } }); }, [profile]); + return [profile.settings, updateSettings]; }; -module.exports = useSettings; +export default useSettings; diff --git a/src/common/useShell.ts b/src/common/useShell.ts index 1a7bcb6ee..0471e38ab 100644 --- a/src/common/useShell.ts +++ b/src/common/useShell.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import EventEmitter from 'eventemitter3'; const SHELL_EVENT_OBJECT = 'transport'; @@ -17,9 +17,22 @@ type ShellEvent = { args: string[]; }; +export type WindowVisibility = { + visible: boolean; + visibility: number; + isFullscreen: boolean; +}; + +export type WindowState = { + state: number; +}; + const createId = () => Math.floor(Math.random() * 9999) + 1; const useShell = () => { + const [windowClosed, setWindowClosed] = useState(false); + const [windowHidden, setWindowHidden] = useState(false); + const on = (name: string, listener: (arg: any) => void) => { events.on(name, listener); }; @@ -28,7 +41,7 @@ const useShell = () => { events.off(name, listener); }; - const send = (method: string, ...args: (string | number)[]) => { + const send = (method: string, ...args: (string | number | object)[]) => { try { transport?.postMessage(JSON.stringify({ id: createId(), @@ -42,6 +55,24 @@ const useShell = () => { } }; + useEffect(() => { + const onWindowVisibilityChanged = (data: WindowVisibility) => { + setWindowClosed(data.visible === false && data.visibility === 0); + }; + + const onWindowStateChanged = (data: WindowState) => { + setWindowHidden(data.state === 9); + }; + + on('win-visibility-changed', onWindowVisibilityChanged); + on('win-state-changed', onWindowStateChanged); + + return () => { + off('win-visibility-changed', onWindowVisibilityChanged); + off('win-state-changed', onWindowStateChanged); + }; + }, []); + useEffect(() => { if (!transport) return; @@ -66,6 +97,8 @@ const useShell = () => { send, on, off, + windowClosed, + windowHidden, }; }; diff --git a/src/components/ContextMenu/ContextMenu.less b/src/components/ContextMenu/ContextMenu.less new file mode 100644 index 000000000..e38b7bad2 --- /dev/null +++ b/src/components/ContextMenu/ContextMenu.less @@ -0,0 +1,17 @@ +@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less'; + +.context-menu-container { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + + .context-menu { + position: fixed; + border-radius: var(--border-radius); + background-color: var(--modal-background-color); + box-shadow: 0 1.35rem 2.7rem @color-background-dark5-40, + 0 1.1rem 0.85rem @color-background-dark5-20; + } +} \ No newline at end of file diff --git a/src/components/ContextMenu/ContextMenu.tsx b/src/components/ContextMenu/ContextMenu.tsx new file mode 100644 index 000000000..0f78454e8 --- /dev/null +++ b/src/components/ContextMenu/ContextMenu.tsx @@ -0,0 +1,101 @@ +import React, { memo, RefObject, useCallback, useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import styles from './ContextMenu.less'; + +const PADDING = 8; + +type Coordinates = [number, number]; +type Size = [number, number]; + +type Props = { + children: React.ReactNode, + on: RefObject[], + autoClose: boolean, +}; + +const ContextMenu = ({ children, on, autoClose }: Props) => { + const [active, setActive] = useState(false); + const [position, setPosition] = useState([0, 0]); + const [containerSize, setContainerSize] = useState([0, 0]); + + const ref = useCallback((element: HTMLDivElement) => { + element && setContainerSize([element.offsetWidth, element.offsetHeight]); + }, []); + + const style = useMemo(() => { + const [viewportWidth, viewportHeight] = [window.innerWidth, window.innerHeight]; + const [containerWidth, containerHeight] = containerSize; + const [x, y] = position; + + const left = Math.max( + PADDING, + Math.min( + x + containerWidth > viewportWidth - PADDING ? x - containerWidth : x, + viewportWidth - containerWidth - PADDING + ) + ); + + const top = Math.max( + PADDING, + Math.min( + y + containerHeight > viewportHeight - PADDING ? y - containerHeight : y, + viewportHeight - containerHeight - PADDING + ) + ); + + return { top, left }; + }, [position, containerSize]); + + const close = () => { + setPosition([0, 0]); + setActive(false); + }; + + const stopPropagation = (event: React.MouseEvent | React.TouchEvent) => { + event.stopPropagation(); + }; + + const onContextMenu = (event: MouseEvent) => { + event.preventDefault(); + + setPosition([event.clientX, event.clientY]); + setActive(true); + }; + + const handleKeyDown = useCallback((event: KeyboardEvent) => event.key === 'Escape' && close(), []); + + const onClick = useCallback(() => { + autoClose && close(); + }, [autoClose]); + + useEffect(() => { + on.forEach((ref) => ref.current && ref.current.addEventListener('contextmenu', onContextMenu)); + document.addEventListener('keydown', handleKeyDown); + + return () => { + on.forEach((ref) => ref.current && ref.current.removeEventListener('contextmenu', onContextMenu)); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [on]); + + return active && createPortal(( +
+
+ {children} +
+
+ ), document.body); +}; + +export default memo(ContextMenu); diff --git a/src/components/ContextMenu/index.ts b/src/components/ContextMenu/index.ts new file mode 100644 index 000000000..10ae5e5d7 --- /dev/null +++ b/src/components/ContextMenu/index.ts @@ -0,0 +1,2 @@ +import ContextMenu from './ContextMenu'; +export default ContextMenu; diff --git a/src/components/MainNavBars/MainNavBars.less b/src/components/MainNavBars/MainNavBars.less index ca816a72f..10538cd7c 100644 --- a/src/components/MainNavBars/MainNavBars.less +++ b/src/components/MainNavBars/MainNavBars.less @@ -34,7 +34,7 @@ bottom: 0; left: var(--vertical-nav-bar-size); z-index: 0; - overflow: scroll; + overflow: hidden; } } diff --git a/src/components/MetaPreview/styles.less b/src/components/MetaPreview/styles.less index 8006346f0..0725a384e 100644 --- a/src/components/MetaPreview/styles.less +++ b/src/components/MetaPreview/styles.less @@ -107,7 +107,9 @@ display: flex; flex-direction: row; align-items: center; - border-radius: 2.5rem; + border-radius: 0.5rem; + border: var(--focus-outline-size) solid transparent; + padding: 0rem 0.5rem; &:focus { outline: none; diff --git a/src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js b/src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js index 0cedd8638..6be35cd5d 100644 --- a/src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js +++ b/src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js @@ -5,7 +5,7 @@ const PropTypes = require('prop-types'); const classnames = require('classnames'); const { default: Icon } = require('@stremio/stremio-icons/react'); const { Button, Image } = require('stremio/components'); -const useFullscreen = require('stremio/common/useFullscreen'); +const { default: useFullscreen } = require('stremio/common/useFullscreen'); const usePWA = require('stremio/common/usePWA'); const SearchBar = require('./SearchBar'); const NavMenu = require('./NavMenu'); diff --git a/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js b/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js index 59946165a..de0a02212 100644 --- a/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js +++ b/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js @@ -7,7 +7,7 @@ const { useTranslation } = require('react-i18next'); const { default: Icon } = require('@stremio/stremio-icons/react'); const { useServices } = require('stremio/services'); const { Button } = require('stremio/components'); -const useFullscreen = require('stremio/common/useFullscreen'); +const { default: useFullscreen } = require('stremio/common/useFullscreen'); const useProfile = require('stremio/common/useProfile'); const usePWA = require('stremio/common/usePWA'); const useTorrent = require('stremio/common/useTorrent'); diff --git a/src/components/NumberInput/NumberInput.less b/src/components/NumberInput/NumberInput.less new file mode 100644 index 000000000..a88bc6d20 --- /dev/null +++ b/src/components/NumberInput/NumberInput.less @@ -0,0 +1,65 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +.number-input { + user-select: text; + display: flex; + max-width: 14rem; + height: 3.5rem; + margin-bottom: 1rem; + color: var(--primary-foreground-color); + background: var(--overlay-color); + border-radius: 3.5rem; + + .button { + flex: none; + width: 3.5rem; + height: 3.5rem; + padding: 1rem; + background: var(--overlay-color); + border: none; + border-radius: 100%; + cursor: pointer; + z-index: 1; + + .icon { + width: 100%; + height: 100%; + } + } + + .number-display { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 0 1rem; + + &::-moz-focus-inner { + border: none; + } + + .label { + font-size: 0.8rem; + font-weight: 400; + opacity: 0.7; + } + + .value { + font-size: 1.2rem; + display: flex; + justify-content: center; + width: 100%; + color: var(--primary-foreground-color); + text-align: center; + appearance: none; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + } + } +} \ No newline at end of file diff --git a/src/components/NumberInput/NumberInput.tsx b/src/components/NumberInput/NumberInput.tsx new file mode 100644 index 000000000..a286decf4 --- /dev/null +++ b/src/components/NumberInput/NumberInput.tsx @@ -0,0 +1,113 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +import Icon from '@stremio/stremio-icons/react'; +import React, { ChangeEvent, forwardRef, memo, useCallback, useState } from 'react'; +import { type KeyboardEvent, type InputHTMLAttributes } from 'react'; +import classnames from 'classnames'; +import styles from './NumberInput.less'; +import Button from '../Button'; + +type Props = InputHTMLAttributes & { + containerClassName?: string; + className?: string; + disabled?: boolean; + showButtons?: boolean; + defaultValue?: number; + label?: string; + min?: number; + max?: number; + value?: number; + onKeyDown?: (event: KeyboardEvent) => void; + onSubmit?: (event: KeyboardEvent) => void; + onChange?: (event: ChangeEvent) => void; +}; + +const NumberInput = forwardRef(({ defaultValue = 0, showButtons, onKeyDown, onSubmit, min, max, onChange, ...props }, ref) => { + const [value, setValue] = useState(defaultValue); + const displayValue = props.value ?? value; + + const handleKeyDown = useCallback((event: KeyboardEvent) => { + onKeyDown?.(event); + + if (event.key === 'Enter') { + onSubmit?.(event); + } + }, [onKeyDown, onSubmit]); + + const handleValueChange = (newValue: number) => { + if (props.value === undefined) { + setValue(newValue); + } + onChange?.({ target: { value: newValue.toString() }} as ChangeEvent); + }; + + const handleIncrement = () => { + handleValueChange(clampValueToRange((displayValue || 0) + 1)); + }; + + const handleDecrement = () => { + handleValueChange(clampValueToRange((displayValue || 0) - 1)); + }; + + const clampValueToRange = (value: number): number => { + const minValue = min ?? 0; + + if (value < minValue) { + return minValue; + } + + if (max !== undefined && value > max) { + return max; + } + + return value; + }; + + const handleInputChange = useCallback(({ target: { valueAsNumber }}: ChangeEvent) => { + handleValueChange(clampValueToRange(valueAsNumber || 0)); + }, []); + + return ( +
+ { + showButtons ? + + : null + } +
+ { + props.label ? +
{props.label}
+ : null + } + +
+ { + showButtons ? + + : null + } +
+ ); +}); + +NumberInput.displayName = 'NumberInput'; + +export default memo(NumberInput); diff --git a/src/components/NumberInput/index.ts b/src/components/NumberInput/index.ts new file mode 100644 index 000000000..4a25f86df --- /dev/null +++ b/src/components/NumberInput/index.ts @@ -0,0 +1,5 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +import NumberInput from './NumberInput'; + +export default NumberInput; diff --git a/src/components/Slider/Slider.js b/src/components/Slider/Slider.js index 0e5c96c97..d0d4721b8 100644 --- a/src/components/Slider/Slider.js +++ b/src/components/Slider/Slider.js @@ -31,14 +31,18 @@ const Slider = ({ className, value, buffered, minimumValue, maximumValue, disabl const retainThumb = React.useCallback(() => { window.addEventListener('blur', onBlur); window.addEventListener('mouseup', onMouseUp); + window.addEventListener('touchend', onTouchEnd); window.addEventListener('mousemove', onMouseMove); + window.addEventListener('touchmove', onTouchMove); document.documentElement.className = classnames(document.documentElement.className, styles['active-slider-within']); }, []); const releaseThumb = React.useCallback(() => { cancelThumbAnimation(); window.removeEventListener('blur', onBlur); window.removeEventListener('mouseup', onMouseUp); + window.removeEventListener('touchend', onTouchEnd); window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('touchmove', onTouchMove); const classList = document.documentElement.className.split(' '); const classIndex = classList.indexOf(styles['active-slider-within']); if (classIndex !== -1) { @@ -85,6 +89,36 @@ const Slider = ({ className, value, buffered, minimumValue, maximumValue, disabl retainThumb(); }, []); + const onTouchStart = React.useCallback((event) => { + const touch = event.touches[0]; + const value = calculateValueForMouseX(touch.clientX); + if (typeof onSlideRef.current === 'function') { + onSlideRef.current(value); + } + + retainThumb(); + event.preventDefault(); + }, []); + const onTouchMove = React.useCallback((event) => { + requestThumbAnimation(() => { + const touch = event.touches[0]; + const value = calculateValueForMouseX(touch.clientX); + if (typeof onSlideRef.current === 'function') { + onSlideRef.current(value); + } + }); + + event.preventDefault(); + }, []); + const onTouchEnd = React.useCallback((event) => { + const touch = event.changedTouches[0]; + const value = calculateValueForMouseX(touch.clientX); + if (typeof onCompleteRef.current === 'function') { + onCompleteRef.current(value); + } + + releaseThumb(); + }, []); React.useLayoutEffect(() => { if (!routeFocused || disabled) { releaseThumb(); @@ -98,7 +132,7 @@ const Slider = ({ className, value, buffered, minimumValue, maximumValue, disabl const thumbPosition = Math.max(0, Math.min(1, (valueRef.current - minimumValueRef.current) / (maximumValueRef.current - minimumValueRef.current))); const bufferedPosition = Math.max(0, Math.min(1, (bufferedRef.current - minimumValueRef.current) / (maximumValueRef.current - minimumValueRef.current))); return ( -
+
diff --git a/src/components/Slider/styles.less b/src/components/Slider/styles.less index 2bdbbfe72..41478924e 100644 --- a/src/components/Slider/styles.less +++ b/src/components/Slider/styles.less @@ -46,7 +46,8 @@ html.active-slider-within { width: 100%; height: var(--track-size); border-radius: var(--track-size); - background-color: var(--overlay-color); + background-color: var(--primary-accent-color); + opacity: 0.2; &.audio-boost { opacity: 0.3; diff --git a/src/components/Video/Video.js b/src/components/Video/Video.js index 0bcb569a5..94fbefcc7 100644 --- a/src/components/Video/Video.js +++ b/src/components/Video/Video.js @@ -8,11 +8,13 @@ const { useRouteFocused } = require('stremio-router'); const { default: Icon } = require('@stremio/stremio-icons/react'); const { Button, Image, Popup } = require('stremio/components'); const useBinaryState = require('stremio/common/useBinaryState'); +const useProfile = require('stremio/common/useProfile'); const VideoPlaceholder = require('./VideoPlaceholder'); const styles = require('./styles'); const Video = ({ className, id, title, thumbnail, season, episode, released, upcoming, watched, progress, scheduled, seasonWatched, deepLinks, onMarkVideoAsWatched, onMarkSeasonAsWatched, ...props }) => { const routeFocused = useRouteFocused(); + const profile = useProfile(); const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false); const popupLabelOnMouseUp = React.useCallback((event) => { if (!event.nativeEvent.togglePopupPrevented) { @@ -66,13 +68,14 @@ const Video = ({ className, id, title, thumbnail, season, episode, released, upc } }, [deepLinks]); const renderLabel = React.useMemo(() => function renderLabel({ className, id, title, thumbnail, episode, released, upcoming, watched, progress, scheduled, children, ...props }) { + const blurThumbnail = profile.settings.hideSpoilers && season && episode && !watched; return ( + { state.form === SIGNUP_FORM ?
diff --git a/src/routes/Intro/styles.less b/src/routes/Intro/styles.less index 935b0fad1..31a09d54c 100644 --- a/src/routes/Intro/styles.less +++ b/src/routes/Intro/styles.less @@ -175,15 +175,43 @@ position: relative; width: 22rem; margin-left: 2rem; + display: flex; + flex-direction: column; .facebook-button { background: var(--color-facebook); + margin-bottom: 1rem; &:hover, &:focus { outline: var(--focus-outline-size) solid var(--color-facebook); background-color: transparent; } } + + .apple-button { + background: var(--primary-foreground-color); + + .icon { + color: var(--primary-background-color); + } + + .label { + color: var(--primary-background-color); + } + + &:hover, &:focus { + outline: var(--focus-outline-size) solid var(--primary-foreground-color); + background-color: transparent; + + .icon { + color: var(--primary-foreground-color); + } + + .label { + color: var(--primary-foreground-color); + } + } + } } } } diff --git a/src/routes/Intro/useAppleLogin.ts b/src/routes/Intro/useAppleLogin.ts new file mode 100644 index 000000000..900944eb8 --- /dev/null +++ b/src/routes/Intro/useAppleLogin.ts @@ -0,0 +1,104 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { jwtDecode, JwtPayload } from 'jwt-decode'; + +type AppleLoginResponse = { + token: string; + sub: string; + email: string; + name: string; +}; + +type AppleSignInResponse = { + authorization: { + code?: string; + id_token: string; + state?: string; + }; + email?: string; + fullName?: { + firstName?: string; + lastName?: string; + }; +}; + +type CustomJWTPayload = JwtPayload & { + email?: string; +}; + +const CLIENT_ID = 'com.stremio.services'; + +const useAppleLogin = (): [() => Promise, () => void] => { + const started = useRef(false); + + const start = useCallback((): Promise => { + return new Promise((resolve, reject) => { + if (typeof window.AppleID === 'undefined') { + reject(new Error('Apple Sign-In not loaded')); + return; + } + + if (started.current) { + reject(new Error('Apple login already in progress')); + return; + } + + started.current = true; + + window.AppleID.auth.init({ + clientId: CLIENT_ID, + scope: 'name email', + redirectURI: 'https://web.stremio.com/', + state: 'signin', + usePopup: true, + }); + + window.AppleID.auth.signIn().then((response: AppleSignInResponse) => { + if (response.authorization) { + try { + const idToken = response.authorization.id_token; + const payload: CustomJWTPayload = jwtDecode(idToken); + const sub = payload.sub; + const email = payload.email ?? response.email ?? ''; + + let name = ''; + if (response.fullName) { + const firstName = response.fullName.firstName || ''; + const lastName = response.fullName.lastName || ''; + name = [firstName, lastName].filter(Boolean).join(' '); + } + + if (!sub) { + reject(new Error('No sub token received from Apple')); + return; + } + + resolve({ + token: idToken, + sub: sub, + email: email, + name: name, + }); + } catch (error) { + reject(new Error(`Failed to parse id_token: ${error}`)); + } + } else { + reject(new Error('No authorization received from Apple')); + } + }).catch((error) => { + reject(error); + }); + }); + }, []); + + const stop = useCallback(() => { + started.current = false; + }, []); + + useEffect(() => { + return () => stop(); + }, []); + + return [start, stop]; +}; + +export default useAppleLogin; diff --git a/src/routes/Library/Library.js b/src/routes/Library/Library.js index f22a12236..f6310470b 100644 --- a/src/routes/Library/Library.js +++ b/src/routes/Library/Library.js @@ -63,6 +63,11 @@ const Library = ({ model, urlParams, queryParams }) => { scrollContainerRef.current.scrollTop = 0; } }, [profile.auth, library.selected]); + React.useEffect(() => { + if (!library.selected?.type && typeSelect.selected) { + window.location = typeSelect.selected[0]; + } + }, [typeSelect.selected, library.selected]); return ( { diff --git a/src/routes/Library/useSelectableInputs.js b/src/routes/Library/useSelectableInputs.js index 6f24dc2c9..d173d663a 100644 --- a/src/routes/Library/useSelectableInputs.js +++ b/src/routes/Library/useSelectableInputs.js @@ -4,6 +4,8 @@ const React = require('react'); const { useTranslate } = require('stremio/common'); const mapSelectableInputs = (library, t) => { + const selectedType = library.selectable.types + .filter(({ selected }) => selected).map(({ deepLinks }) => deepLinks.library); const typeSelect = { title: t.string('SELECT_TYPE'), options: library.selectable.types @@ -11,9 +13,9 @@ const mapSelectableInputs = (library, t) => { value: deepLinks.library, label: type === null ? t.string('TYPE_ALL') : t.stringWithPrefix(type, 'TYPE_') })), - selected: library.selectable.types - .filter(({ selected }) => selected) - .map(({ deepLinks }) => deepLinks.library), + selected: selectedType.length + ? selectedType + : [library.selectable.types[0]].map(({ deepLinks }) => deepLinks.library), onSelect: (event) => { window.location = event.value; } diff --git a/src/routes/MetaDetails/EpisodePicker/EpisodePicker.less b/src/routes/MetaDetails/EpisodePicker/EpisodePicker.less new file mode 100644 index 000000000..260eb8ebe --- /dev/null +++ b/src/routes/MetaDetails/EpisodePicker/EpisodePicker.less @@ -0,0 +1,29 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +.button-container { + flex: none; + align-self: stretch; + display: flex; + align-items: center; + justify-content: center; + border: var(--focus-outline-size) solid var(--primary-accent-color); + background-color: var(--primary-accent-color); + height: 4rem; + padding: 0 2rem; + margin: 1rem auto; + border-radius: 2rem; + + &:hover { + background-color: transparent; + } + + .label { + flex: 0 1 auto; + font-size: 1rem; + font-weight: 700; + max-height: 3.5rem; + text-align: center; + color: var(--primary-foreground-color); + margin-bottom: 0; + } +} \ No newline at end of file diff --git a/src/routes/MetaDetails/EpisodePicker/EpisodePicker.tsx b/src/routes/MetaDetails/EpisodePicker/EpisodePicker.tsx new file mode 100644 index 000000000..256c827a9 --- /dev/null +++ b/src/routes/MetaDetails/EpisodePicker/EpisodePicker.tsx @@ -0,0 +1,71 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +import React, { useCallback, useMemo, useState, ChangeEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, NumberInput } from 'stremio/components'; +import styles from './EpisodePicker.less'; + +type Props = { + className?: string, + seriesId: string; + onSubmit: (season: number, episode: number) => void; +}; + +const EpisodePicker = ({ className, onSubmit }: Props) => { + const { t } = useTranslation(); + + const { initialSeason, initialEpisode } = useMemo(() => { + const splitPath = window.location.hash.split('/'); + const videoId = decodeURIComponent(splitPath[splitPath.length - 1]); + const [, pathSeason, pathEpisode] = videoId ? videoId.split(':') : []; + return { + initialSeason: parseInt(pathSeason) || 0, + initialEpisode: parseInt(pathEpisode) || 1 + }; + }, []); + + const [season, setSeason] = useState(initialSeason); + const [episode, setEpisode] = useState(initialEpisode); + + const handleSeasonChange = useCallback((event: ChangeEvent) => { + setSeason(parseInt(event.target.value)); + }, []); + + const handleEpisodeChange = useCallback((event: ChangeEvent) => { + setEpisode(parseInt(event.target.value)); + }, []); + + const handleSubmit = () => { + onSubmit(season, episode); + }; + + const disabled = season === initialSeason && episode === initialEpisode; + + return ( +
+ + + +
+ ); +}; + +export default EpisodePicker; diff --git a/src/routes/MetaDetails/EpisodePicker/index.ts b/src/routes/MetaDetails/EpisodePicker/index.ts new file mode 100644 index 000000000..623962334 --- /dev/null +++ b/src/routes/MetaDetails/EpisodePicker/index.ts @@ -0,0 +1,5 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +import SeasonEpisodePicker from './EpisodePicker'; + +export default SeasonEpisodePicker; diff --git a/src/routes/MetaDetails/MetaDetails.js b/src/routes/MetaDetails/MetaDetails.js index 24c904cfd..8a50b59c1 100644 --- a/src/routes/MetaDetails/MetaDetails.js +++ b/src/routes/MetaDetails/MetaDetails.js @@ -76,6 +76,13 @@ const MetaDetails = ({ urlParams, queryParams }) => { const seasonOnSelect = React.useCallback((event) => { setSeason(event.value); }, [setSeason]); + const handleEpisodeSearch = React.useCallback((season, episode) => { + const searchVideoHash = encodeURIComponent(`${urlParams.id}:${season}:${episode}`); + const url = window.location.hash; + const searchVideoPath = url.replace(encodeURIComponent(urlParams.videoId), searchVideoHash); + window.location = searchVideoPath; + }, [urlParams, window.location]); + const renderBackgroundImageFallback = React.useCallback(() => null, []); const renderBackground = React.useMemo(() => !!( metaPath && @@ -129,7 +136,7 @@ const MetaDetails = ({ urlParams, queryParams }) => { metaDetails.metaItem === null ?
{' -
No addons ware requested for this meta!
+
No addons were requested for this meta!
: metaDetails.metaItem.content.type === 'Err' ? @@ -169,6 +176,8 @@ const MetaDetails = ({ urlParams, queryParams }) => { className={styles['streams-list']} streams={metaDetails.streams} video={video} + type={streamPath.type} + onEpisodeSearch={handleEpisodeSearch} /> : metaPath !== null ? diff --git a/src/routes/MetaDetails/StreamsList/StreamsList.js b/src/routes/MetaDetails/StreamsList/StreamsList.js index bcb5cb015..82ba57a51 100644 --- a/src/routes/MetaDetails/StreamsList/StreamsList.js +++ b/src/routes/MetaDetails/StreamsList/StreamsList.js @@ -10,10 +10,11 @@ const { useServices } = require('stremio/services'); const Stream = require('./Stream'); const styles = require('./styles'); const { usePlatform, useProfile } = require('stremio/common'); +const { default: SeasonEpisodePicker } = require('../EpisodePicker'); const ALL_ADDONS_KEY = 'ALL'; -const StreamsList = ({ className, video, ...props }) => { +const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => { const { t } = useTranslation(); const { core } = useServices(); const platform = usePlatform(); @@ -25,8 +26,8 @@ const StreamsList = ({ className, video, ...props }) => { setSelectedAddon(event.value); }, [platform]); const showInstallAddonsButton = React.useMemo(() => { - return !profile || profile.auth === null || profile.auth?.user?.isNewUser === true; - }, [profile]); + return !profile || profile.auth === null || profile.auth?.user?.isNewUser === true && !video?.upcoming; + }, [profile, video]); const backButtonOnClick = React.useCallback(() => { if (video.deepLinks && typeof video.deepLinks.metaDetailsVideos === 'string') { window.location.replace(video.deepLinks.metaDetailsVideos + ( @@ -93,6 +94,11 @@ const StreamsList = ({ className, video, ...props }) => { onSelect: onAddonSelected }; }, [streamsByAddon, selectedAddon]); + + const handleEpisodePicker = React.useCallback((season, episode) => { + onEpisodeSearch(season, episode); + }, [onEpisodeSearch]); + return (
@@ -122,12 +128,27 @@ const StreamsList = ({ className, video, ...props }) => { { props.streams.length === 0 ?
+ { + type === 'series' ? + + : null + } {'
No addons were requested for streams!
: props.streams.every((streams) => streams.content.type === 'Err') ?
+ { + type === 'series' ? + + : null + } + { + video?.upcoming ? +
{t('UPCOMING')}...
+ : null + } {'
{t('NO_STREAM')}
{ @@ -193,7 +214,9 @@ const StreamsList = ({ className, video, ...props }) => { StreamsList.propTypes = { className: PropTypes.string, streams: PropTypes.arrayOf(PropTypes.object).isRequired, - video: PropTypes.object + video: PropTypes.object, + type: PropTypes.string, + onEpisodeSearch: PropTypes.func }; module.exports = StreamsList; diff --git a/src/routes/MetaDetails/StreamsList/styles.less b/src/routes/MetaDetails/StreamsList/styles.less index 0f9ab2a0a..0bffa8fcc 100644 --- a/src/routes/MetaDetails/StreamsList/styles.less +++ b/src/routes/MetaDetails/StreamsList/styles.less @@ -22,6 +22,10 @@ padding: 1rem; overflow-y: auto; + .search { + flex: none; + } + .image { flex: none; width: 10rem; @@ -38,6 +42,7 @@ font-size: 1.4rem; text-align: center; color: var(--primary-foreground-color); + margin-bottom: 2rem; } } @@ -171,6 +176,7 @@ max-height: 3.6em; text-align: center; color: var(--primary-foreground-color); + margin-bottom: 0; } } } diff --git a/src/routes/MetaDetails/VideosList/VideosList.js b/src/routes/MetaDetails/VideosList/VideosList.js index 58614c9d9..a47b2f517 100644 --- a/src/routes/MetaDetails/VideosList/VideosList.js +++ b/src/routes/MetaDetails/VideosList/VideosList.js @@ -7,6 +7,7 @@ const { t } = require('i18next'); const { useServices } = require('stremio/services'); const { Image, SearchBar, Toggle, Video } = require('stremio/components'); const SeasonsBar = require('./SeasonsBar'); +const { default: EpisodePicker } = require('../EpisodePicker'); const styles = require('./styles'); const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect, toggleNotifications }) => { @@ -92,6 +93,15 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect, }); }; + const onSeasonSearch = (value) => { + if (value) { + seasonOnSelect({ + type: 'select', + value, + }); + } + }; + return (
{ @@ -110,6 +120,7 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect, : metaItem.content.type === 'Err' || videosForSeason.length === 0 ?
+ {'
No videos found for this meta!
diff --git a/src/routes/MetaDetails/VideosList/styles.less b/src/routes/MetaDetails/VideosList/styles.less index 9f9a7edee..22d51116c 100644 --- a/src/routes/MetaDetails/VideosList/styles.less +++ b/src/routes/MetaDetails/VideosList/styles.less @@ -13,10 +13,13 @@ display: flex; flex-direction: column; align-items: center; - justify-content: center; padding: 2rem; overflow-y: auto; + .episode-picker { + margin-bottom: 2rem; + } + .image { flex: none; width: 10rem; diff --git a/src/routes/Player/BufferingLoader/BufferingLoader.js b/src/routes/Player/BufferingLoader/BufferingLoader.js index 2fea2f4a0..3d5664ae4 100644 --- a/src/routes/Player/BufferingLoader/BufferingLoader.js +++ b/src/routes/Player/BufferingLoader/BufferingLoader.js @@ -6,9 +6,9 @@ const classnames = require('classnames'); const { Image } = require('stremio/components'); const styles = require('./styles'); -const BufferingLoader = ({ className, logo }) => { +const BufferingLoader = React.forwardRef(({ className, logo }, ref) => { return ( -
+
{ />
); -}; +}); BufferingLoader.propTypes = { className: PropTypes.string, - logo: PropTypes.string + logo: PropTypes.string, }; module.exports = BufferingLoader; diff --git a/src/routes/Player/ControlBar/ControlBar.js b/src/routes/Player/ControlBar/ControlBar.js index 745e2bd49..ba721036c 100644 --- a/src/routes/Player/ControlBar/ControlBar.js +++ b/src/routes/Player/ControlBar/ControlBar.js @@ -9,7 +9,7 @@ const { useServices } = require('stremio/services'); const SeekBar = require('./SeekBar'); const VolumeSlider = require('./VolumeSlider'); const styles = require('./styles'); -const { useBinaryState } = require('stremio/common'); +const { useBinaryState, usePlatform } = require('stremio/common'); const { t } = require('i18next'); const ControlBar = ({ @@ -40,9 +40,11 @@ const ControlBar = ({ onToggleSideDrawer, onToggleOptionsMenu, onToggleStatisticsMenu, + onTouchEnd, ...props }) => { const { chromecast } = useServices(); + const platform = usePlatform(); const [chromecastServiceActive, setChromecastServiceActive] = React.useState(() => chromecast.active); const [buttonsMenuOpen, , , toggleButtonsMenu] = useBinaryState(false); const onSubtitlesButtonMouseDown = React.useCallback((event) => { @@ -103,7 +105,7 @@ const ControlBar = ({ }; }, []); return ( -
+
- + { + !platform.isMobile ? + + : null + }
@@ -206,6 +213,9 @@ ControlBar.propTypes = { onToggleSideDrawer: PropTypes.func, onToggleOptionsMenu: PropTypes.func, onToggleStatisticsMenu: PropTypes.func, + onMouseOver: PropTypes.func, + onMouseMove: PropTypes.func, + onTouchEnd: PropTypes.func, }; module.exports = ControlBar; diff --git a/src/routes/Player/Error/Error.js b/src/routes/Player/Error/Error.js index c9a7c3bef..61db0e45f 100644 --- a/src/routes/Player/Error/Error.js +++ b/src/routes/Player/Error/Error.js @@ -8,7 +8,7 @@ const { default: Icon } = require('@stremio/stremio-icons/react'); const { Button } = require('stremio/components'); const styles = require('./styles'); -const Error = ({ className, code, message, stream }) => { +const Error = React.forwardRef(({ className, code, message, stream }, ref) => { const { t } = useTranslation(); const [playlist, fileName] = React.useMemo(() => { @@ -19,7 +19,7 @@ const Error = ({ className, code, message, stream }) => { }, [stream]); return ( -
+
{message}
{ code === 2 ? @@ -44,7 +44,7 @@ const Error = ({ className, code, message, stream }) => { }
); -}; +}); Error.propTypes = { className: PropTypes.string, diff --git a/src/routes/Player/NextVideoPopup/NextVideoPopup.js b/src/routes/Player/NextVideoPopup/NextVideoPopup.js index 8b400bfe2..a02d2377e 100644 --- a/src/routes/Player/NextVideoPopup/NextVideoPopup.js +++ b/src/routes/Player/NextVideoPopup/NextVideoPopup.js @@ -4,11 +4,13 @@ const React = require('react'); const PropTypes = require('prop-types'); const classnames = require('classnames'); const { default: Icon } = require('@stremio/stremio-icons/react'); -const { CONSTANTS } = require('stremio/common'); +const { CONSTANTS, useProfile } = require('stremio/common'); const { Button, Image } = require('stremio/components'); const styles = require('./styles'); const NextVideoPopup = ({ className, metaItem, nextVideo, onDismiss, onNextVideoRequested }) => { + const profile = useProfile(); + const blurPosterImage = profile.settings.hideSpoilers && metaItem.type === 'series'; const watchNowButtonRef = React.useRef(null); const [animationEnded, setAnimationEnded] = React.useState(false); const videoName = React.useMemo(() => { @@ -51,7 +53,7 @@ const NextVideoPopup = ({ className, metaItem, nextVideo, onDismiss, onNextVideo
{' { const onMouseDown = React.useCallback((event) => { event.nativeEvent.optionsMenuClosePrevented = true; }, []); + return (
{ @@ -112,7 +113,7 @@ const OptionsMenu = ({ className, stream, playbackDevices }) => { OptionsMenu.propTypes = { className: PropTypes.string, stream: PropTypes.object, - playbackDevices: PropTypes.array + playbackDevices: PropTypes.array, }; module.exports = OptionsMenu; diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index c491fde7e..9c9ae5e92 100644 --- a/src/routes/Player/Player.js +++ b/src/routes/Player/Player.js @@ -8,8 +8,8 @@ const langs = require('langs'); const { useTranslation } = require('react-i18next'); const { useRouteFocused } = require('stremio-router'); const { useServices } = require('stremio/services'); -const { onFileDrop, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS } = require('stremio/common'); -const { HorizontalNavBar, Transition } = require('stremio/components'); +const { onFileDrop, useSettings, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS, useShell } = require('stremio/common'); +const { HorizontalNavBar, Transition, ContextMenu } = require('stremio/components'); const BufferingLoader = require('./BufferingLoader'); const VolumeChangeIndicator = require('./VolumeChangeIndicator'); const Error = require('./Error'); @@ -23,7 +23,6 @@ const SpeedMenu = require('./SpeedMenu'); const { default: SideDrawerButton } = require('./SideDrawerButton'); const { default: SideDrawer } = require('./SideDrawer'); const usePlayer = require('./usePlayer'); -const useSettings = require('./useSettings'); const useStatistics = require('./useStatistics'); const useVideo = require('./useVideo'); const styles = require('./styles'); @@ -31,7 +30,8 @@ const Video = require('./Video'); const Player = ({ urlParams, queryParams }) => { const { t } = useTranslation(); - const { chromecast, shell, core } = useServices(); + const services = useServices(); + const shell = useShell(); const forceTranscoding = React.useMemo(() => { return queryParams.has('forceTranscoding'); }, [queryParams]); @@ -47,8 +47,12 @@ const Player = ({ urlParams, queryParams }) => { const [seeking, setSeeking] = React.useState(false); const [casting, setCasting] = React.useState(() => { - return chromecast.active && chromecast.transport.getCastState() === cast.framework.CastState.CONNECTED; + return services.chromecast.active && services.chromecast.transport.getCastState() === cast.framework.CastState.CONNECTED; }); + const playbackDevices = React.useMemo(() => streamingServer.playbackDevices !== null && streamingServer.playbackDevices.type === 'Ready' ? streamingServer.playbackDevices.content : [], [streamingServer]); + + const bufferingRef = React.useRef(); + const errorRef = React.useRef(); const [immersed, setImmersed] = React.useState(true); const setImmersedDebounced = React.useCallback(debounce(setImmersed, 3000), []); @@ -317,8 +321,8 @@ const Player = ({ urlParams, queryParams }) => { null, seriesInfo: player.seriesInfo, }, { - chromecastTransport: chromecast.active ? chromecast.transport : null, - shellTransport: shell.active ? shell.transport : null, + chromecastTransport: services.chromecast.active ? services.chromecast.transport : null, + shellTransport: services.shell.active ? services.shell.transport : null, }); } }, [streamingServer.baseUrl, player.selected, forceTranscoding, casting]); @@ -439,12 +443,12 @@ const Player = ({ urlParams, queryParams }) => { const toastFilter = (item) => item?.dataset?.type === 'CoreEvent'; toast.addFilter(toastFilter); const onCastStateChange = () => { - setCasting(chromecast.active && chromecast.transport.getCastState() === cast.framework.CastState.CONNECTED); + setCasting(services.chromecast.active && services.chromecast.transport.getCastState() === cast.framework.CastState.CONNECTED); }; const onChromecastServiceStateChange = () => { onCastStateChange(); - if (chromecast.active) { - chromecast.transport.on( + if (services.chromecast.active) { + services.chromecast.transport.on( cast.framework.CastContextEventType.CAST_STATE_CHANGED, onCastStateChange ); @@ -455,15 +459,15 @@ const Player = ({ urlParams, queryParams }) => { onPauseRequested(); } }; - chromecast.on('stateChanged', onChromecastServiceStateChange); - core.transport.on('CoreEvent', onCoreEvent); + services.chromecast.on('stateChanged', onChromecastServiceStateChange); + services.core.transport.on('CoreEvent', onCoreEvent); onChromecastServiceStateChange(); return () => { toast.removeFilter(toastFilter); - chromecast.off('stateChanged', onChromecastServiceStateChange); - core.transport.off('CoreEvent', onCoreEvent); - if (chromecast.active) { - chromecast.transport.off( + services.chromecast.off('stateChanged', onChromecastServiceStateChange); + services.core.transport.off('CoreEvent', onCoreEvent); + if (services.chromecast.active) { + services.chromecast.transport.off( cast.framework.CastContextEventType.CAST_STATE_CHANGED, onCastStateChange ); @@ -471,6 +475,12 @@ const Player = ({ urlParams, queryParams }) => { }; }, []); + React.useEffect(() => { + if (settings.pauseOnMinimize && (shell.windowClosed || shell.windowHidden)) { + onPauseRequested(); + } + }, [settings.pauseOnMinimize, shell.windowClosed, shell.windowHidden]); + React.useLayoutEffect(() => { const onKeyDown = (event) => { switch (event.code) { @@ -561,6 +571,7 @@ const Player = ({ urlParams, queryParams }) => { } case 'Escape': { closeMenus(); + !settings.escExitFullscreen && window.history.back(); break; } } @@ -591,7 +602,7 @@ const Player = ({ urlParams, queryParams }) => { window.removeEventListener('keyup', onKeyUp); window.removeEventListener('wheel', onWheel); }; - }, [player.metaItem, player.selected, streamingServer.statistics, settings.seekTimeDuration, settings.seekShortTimeDuration, routeFocused, menusOpen, nextVideoPopupOpen, video.state.paused, video.state.time, video.state.volume, video.state.audioTracks, video.state.subtitlesTracks, video.state.extraSubtitlesTracks, video.state.playbackSpeed, toggleSubtitlesMenu, toggleStatisticsMenu, toggleSideDrawer]); + }, [player.metaItem, player.selected, streamingServer.statistics, settings.seekTimeDuration, settings.seekShortTimeDuration, settings.escExitFullscreen, routeFocused, menusOpen, nextVideoPopupOpen, video.state.paused, video.state.time, video.state.volume, video.state.audioTracks, video.state.subtitlesTracks, video.state.extraSubtitlesTracks, video.state.playbackSpeed, toggleSubtitlesMenu, toggleStatisticsMenu, toggleSideDrawer]); React.useEffect(() => { video.events.on('error', onError); @@ -626,7 +637,7 @@ const Player = ({ urlParams, queryParams }) => { onMouseOver={onContainerMouseMove} onMouseLeave={onContainerMouseLeave}>
{ t('SETTINGS_NAV_PLAYER') }
-
{t('SETTINGS_CLOSE_WINDOW')}
+
{t('SETTINGS_SECTION_SUBTITLES')}
@@ -352,20 +381,6 @@ const Settings = () => { {...subtitlesLanguageSelect} />
- { - shell.active ? -
-
-
{ t('SETTINGS_FULLSCREEN_EXIT') }
-
- -
- : - null - }
{ t('SETTINGS_SUBTITLES_SIZE') }
@@ -515,6 +530,18 @@ const Settings = () => { />
} + { + shell.active && +
+
+
{ t('SETTINGS_PAUSE_MINIMIZED') }
+
+ +
+ }
{ t('SETTINGS_NAV_STREAMING') }
@@ -722,6 +749,18 @@ const Settings = () => {
+
+
+
+ Build Version +
+
+
+
+ {process.env.COMMIT_HASH} +
+
+
{ streamingServer.settings !== null && streamingServer.settings.type === 'Ready' ?
diff --git a/src/routes/Settings/URLsManager/URLsManager.tsx b/src/routes/Settings/URLsManager/URLsManager.tsx index b43232f4e..46e57020d 100644 --- a/src/routes/Settings/URLsManager/URLsManager.tsx +++ b/src/routes/Settings/URLsManager/URLsManager.tsx @@ -46,7 +46,7 @@ const URLsManager = () => { }
- diff --git a/src/routes/Settings/styles.less b/src/routes/Settings/styles.less index de37d17c2..072dd32a3 100644 --- a/src/routes/Settings/styles.less +++ b/src/routes/Settings/styles.less @@ -64,8 +64,11 @@ .version-info-label { flex: 0 1 auto; margin: 0.5rem 0; + white-space: nowrap; + text-overflow: ellipsis; color: var(--primary-foreground-color); opacity: 0.3; + overflow: hidden; } } @@ -242,6 +245,8 @@ flex-shrink: 1; flex-basis: auto; line-height: 1.5rem; + white-space: nowrap; + text-overflow: ellipsis; color: var(--primary-foreground-color); } diff --git a/src/routes/Settings/useProfileSettingsInputs.js b/src/routes/Settings/useProfileSettingsInputs.js index c193c6eaf..2a31fc254 100644 --- a/src/routes/Settings/useProfileSettingsInputs.js +++ b/src/routes/Settings/useProfileSettingsInputs.js @@ -32,6 +32,22 @@ const useProfileSettingsInputs = (profile) => { } }), [profile.settings]); + const hideSpoilersToggle = React.useMemo(() => ({ + checked: profile.settings.hideSpoilers, + onClick: () => { + core.transport.dispatch({ + action: 'Ctx', + args: { + action: 'UpdateSettings', + args: { + ...profile.settings, + hideSpoilers: !profile.settings.hideSpoilers + } + } + }); + } + }), [profile.settings]); + const quitOnCloseToggle = React.useMemo(() => ({ checked: profile.settings.quitOnClose, onClick: () => { @@ -323,8 +339,24 @@ const useProfileSettingsInputs = (profile) => { }); } }), [profile.settings]); + const pauseOnMinimizeToggle = React.useMemo(() => ({ + checked: profile.settings.pauseOnMinimize, + onClick: () => { + core.transport.dispatch({ + action: 'Ctx', + args: { + action: 'UpdateSettings', + args: { + ...profile.settings, + pauseOnMinimize: !profile.settings.pauseOnMinimize, + } + } + }); + } + }), [profile.settings]); return { interfaceLanguageSelect, + hideSpoilersToggle, subtitlesLanguageSelect, subtitlesSizeSelect, subtitlesTextColorInput, @@ -341,6 +373,7 @@ const useProfileSettingsInputs = (profile) => { bingeWatchingToggle, playInBackgroundToggle, hardwareDecodingToggle, + pauseOnMinimizeToggle, }; }; diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 3849b8914..e55146e9a 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -26,6 +26,31 @@ interface Chrome { declare global { var qt: Qt | undefined; var chrome: Chrome | undefined; + interface Window { + AppleID: { + auth: { + init: (config: { + clientId: string; + scope: string; + redirectURI: string; + state: string; + usePopup: boolean; + }) => void; + signIn: () => Promise<{ + authorization: { + code?: string; + id_token: string; + state?: string; + }; + email?: string; + fullName?: { + firstName?: string; + lastName?: string; + }; + }>; + }; + }; + } } export {}; diff --git a/src/types/models/Ctx.d.ts b/src/types/models/Ctx.d.ts index a86fd60fa..47f18749f 100644 --- a/src/types/models/Ctx.d.ts +++ b/src/types/models/Ctx.d.ts @@ -21,6 +21,7 @@ type Settings = { hardwareDecoding: boolean, escExitFullscreen: boolean, interfaceLanguage: string, + hideSpoilers: boolean, nextVideoNotificationDuration: number, playInBackground: boolean, playerType: string | null, diff --git a/webpack.config.js b/webpack.config.js index 36f6b6e50..ea1685592 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -234,6 +234,7 @@ module.exports = (env, argv) => ({ { from: 'favicons', to: 'favicons' }, { from: 'images', to: 'images' }, { from: 'screenshots/*.webp', to: './' }, + { from: '.well-known', to: '.well-known' }, ] }), new MiniCssExtractPlugin({