diff --git a/package-lock.json b/package-lock.json index b052d8342..7f2e0b955 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@babel/runtime": "7.26.0", "@sentry/browser": "8.42.0", "@stremio/stremio-colors": "5.2.0", - "@stremio/stremio-core-web": "0.49.2", + "@stremio/stremio-core-web": "0.49.3", "@stremio/stremio-icons": "5.4.1", "@stremio/stremio-video": "0.0.60", "a-color-picker": "1.2.1", @@ -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", @@ -3371,9 +3372,9 @@ "integrity": "sha512-dYlPgu9W/H7c9s1zmW5tiDnRenaUa4Hg1QCyOg1lhOcgSfM/bVTi5nnqX+IfvGTTUNA0zgzh8hI3o3miwnZxTg==" }, "node_modules/@stremio/stremio-core-web": { - "version": "0.49.2", - "resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.49.2.tgz", - "integrity": "sha512-IYU+pdHkq4iEfqZ9G+DFZheIE53nY8XyhI1OJLvZp68/4ntRwssXwfj9InHK2Wau20fH+oV2KD1ZWb0CsTLqPA==", + "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" @@ -10234,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", diff --git a/package.json b/package.json index f7fc127a1..c12a0b7e8 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@babel/runtime": "7.26.0", "@sentry/browser": "8.42.0", "@stremio/stremio-colors": "5.2.0", - "@stremio/stremio-core-web": "0.49.2", + "@stremio/stremio-core-web": "0.49.3", "@stremio/stremio-icons": "5.4.1", "@stremio/stremio-video": "0.0.60", "a-color-picker": "1.2.1", @@ -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", diff --git a/src/components/Multiselect/Multiselect.js b/src/components/Multiselect/Multiselect.js index c791c60d1..0e353eef4 100644 --- a/src/components/Multiselect/Multiselect.js +++ b/src/components/Multiselect/Multiselect.js @@ -10,16 +10,16 @@ const ModalDialog = require('stremio/components/ModalDialog'); const useBinaryState = require('stremio/common/useBinaryState'); const styles = require('./styles'); -const Multiselect = ({ className, mode, direction, title, disabled, dataset, renderLabelContent, renderLabelText, onOpen, onClose, onSelect, ...props }) => { +const Multiselect = ({ className, mode, direction, title, disabled, dataset, options, renderLabelContent, renderLabelText, onOpen, onClose, onSelect, ...props }) => { const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false); - const options = React.useMemo(() => { - return Array.isArray(props.options) ? - props.options.filter((option) => { + const filteredOptions = React.useMemo(() => { + return Array.isArray(options) ? + options.filter((option) => { return option && (typeof option.value === 'string' || option.value === null); }) : []; - }, [props.options]); + }, [options]); const selected = React.useMemo(() => { return Array.isArray(props.selected) ? props.selected.filter((value) => { @@ -94,7 +94,7 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, ren : selected.length > 0 ? selected.map((value) => { - const option = options.find((option) => option.value === value); + const option = filteredOptions.find((option) => option.value === value); return option && typeof option.label === 'string' ? option.label : @@ -109,12 +109,12 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, ren } {children} - ), [menuOpen, title, disabled, options, selected, labelOnClick, renderLabelContent, renderLabelText]); + ), [menuOpen, title, disabled, filteredOptions, selected, labelOnClick, renderLabelContent, renderLabelText]); const renderMenu = React.useCallback(() => (
{ - options.length > 0 ? - options.map(({ label, title, value }) => ( + filteredOptions.length > 0 ? + filteredOptions.map(({ label, title, value }) => (
- ), [options, selected, menuOnKeyDown, menuOnClick, optionOnClick]); + ), [filteredOptions, selected, menuOnKeyDown, menuOnClick, optionOnClick]); const renderPopupLabel = React.useMemo(() => (labelProps) => { return renderLabel({ ...labelProps, 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/index.ts b/src/components/index.ts index bd819f658..a5638007e 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -19,6 +19,7 @@ import ModalDialog from './ModalDialog'; import Multiselect from './Multiselect'; import MultiselectMenu from './MultiselectMenu'; import { HorizontalNavBar, VerticalNavBar } from './NavBar'; +import NumberInput from './NumberInput'; import Popup from './Popup'; import RadioButton from './RadioButton'; import SearchBar from './SearchBar'; @@ -52,6 +53,7 @@ export { MultiselectMenu, HorizontalNavBar, VerticalNavBar, + NumberInput, Popup, RadioButton, SearchBar, diff --git a/src/index.html b/src/index.html index ba8a9e795..033f9a267 100644 --- a/src/index.html +++ b/src/index.html @@ -15,6 +15,7 @@
<%= htmlWebpackPlugin.tags.bodyTags %> + \ No newline at end of file diff --git a/src/routes/Discover/Discover.js b/src/routes/Discover/Discover.js index bab40a1c2..edbb26550 100644 --- a/src/routes/Discover/Discover.js +++ b/src/routes/Discover/Discover.js @@ -107,9 +107,11 @@ const Discover = ({ urlParams, queryParams }) => { onSelect={onSelect} /> ))} - +
+ +
{ discover.catalog !== null && !discover.catalog.installed ? diff --git a/src/routes/Discover/styles.less b/src/routes/Discover/styles.less index 62e3adaee..527238757 100644 --- a/src/routes/Discover/styles.less +++ b/src/routes/Discover/styles.less @@ -59,7 +59,9 @@ display: none; &~.filter-container { - display: flex; + .filter-button { + display: flex; + } } } @@ -69,20 +71,27 @@ } .filter-container { - flex: none; - display: none; - align-items: center; - justify-content: center; - width: 3rem; - height: 3rem; - border-radius: var(--border-radius); - background-color: var(--overlay-color); + display: flex; + flex: 1 0 5rem; + justify-content: flex-end; - .filter-icon { + .filter-button { flex: none; - width: 1.4rem; - height: 1.4rem; - color: var(--primary-foreground-color); + display: none; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + margin-left: 1.5rem; + border-radius: var(--border-radius); + background-color: var(--overlay-color); + + .filter-icon { + flex: none; + width: 1.4rem; + height: 1.4rem; + color: var(--primary-foreground-color); + } } } } @@ -219,9 +228,14 @@ .select-input { height: 3.5rem; + display: none; - &:not(:last-child) { - margin-bottom: 1rem; + &:nth-child(n+4) { + display: flex; + + &:not(:last-child) { + margin-bottom: 1rem; + } } .multiselect-menu-container { @@ -363,7 +377,9 @@ display: none; &~.filter-container { - display: flex; + .filter-button { + display: flex; + } } } } @@ -375,4 +391,22 @@ } } } + + .selectable-inputs-modal { + .selectable-inputs-modal-container { + .selectable-inputs-modal-content { + .select-input { + display: none; + + &:nth-child(n+2) { + display: flex; + + &:not(:last-child) { + margin-bottom: 1rem; + } + } + } + } + } + } } \ No newline at end of file diff --git a/src/routes/Intro/Intro.js b/src/routes/Intro/Intro.js index 989e6febd..716fe0f0d 100644 --- a/src/routes/Intro/Intro.js +++ b/src/routes/Intro/Intro.js @@ -12,6 +12,8 @@ const { Button, Image, Checkbox } = require('stremio/components'); const CredentialsTextInput = require('./CredentialsTextInput'); const PasswordResetModal = require('./PasswordResetModal'); const useFacebookLogin = require('./useFacebookLogin'); +const { default: useAppleLogin } = require('./useAppleLogin'); + const styles = require('./styles'); const SIGNUP_FORM = 'signup'; @@ -22,6 +24,7 @@ const Intro = ({ queryParams }) => { const { t } = useTranslation(); const routeFocused = useRouteFocused(); const [startFacebookLogin, stopFacebookLogin] = useFacebookLogin(); + const [startAppleLogin, stopAppleLogin] = useAppleLogin(); const emailRef = React.useRef(null); const passwordRef = React.useRef(null); const confirmPasswordRef = React.useRef(null); @@ -106,6 +109,37 @@ const Intro = ({ queryParams }) => { stopFacebookLogin(); closeLoaderModal(); }, []); + const loginWithApple = React.useCallback(() => { + openLoaderModal(); + startAppleLogin() + .then(({ email, token, sub, name }) => { + core.transport.dispatch({ + action: 'Ctx', + args: { + action: 'Authenticate', + args: { + type: 'Apple', + token, + sub, + email, + name + } + } + }); + }) + .catch((error) => { + closeLoaderModal(); + if (error.error === 'popup_closed_by_user') { + dispatch({ type: 'error', error: 'Apple login popup was closed.' }); + } else { + dispatch({ type: 'error', error: error.error }); + } + }); + }, []); + const cancelLoginWithApple = React.useCallback(() => { + stopAppleLogin(); + closeLoaderModal(); + }, []); const loginWithEmail = React.useCallback(() => { if (typeof state.email !== 'string' || state.email.length === 0 || !emailRef.current.validity.valid) { dispatch({ type: 'error', error: 'Invalid email' }); @@ -336,7 +370,7 @@ const Intro = ({ queryParams }) => { } { - state.error.length > 0 ? + state.error && state.error.length > 0 ?
{state.error}
: null @@ -350,6 +384,10 @@ const Intro = ({ queryParams }) => {
Continue with Facebook
+ { 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/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 {};