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/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;