mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-03-11 17:15:48 +00:00
Merge pull request #827 from Stremio/feat/season-episode-inputs
Streams List: Season and Episode picker when no streams loaded
This commit is contained in:
commit
55dac0d36f
12 changed files with 348 additions and 6 deletions
65
src/components/NumberInput/NumberInput.less
Normal file
65
src/components/NumberInput/NumberInput.less
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
113
src/components/NumberInput/NumberInput.tsx
Normal file
113
src/components/NumberInput/NumberInput.tsx
Normal file
|
|
@ -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<HTMLInputElement> & {
|
||||
containerClassName?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
showButtons?: boolean;
|
||||
defaultValue?: number;
|
||||
label?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
value?: number;
|
||||
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||
onSubmit?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
const NumberInput = forwardRef<HTMLInputElement, Props>(({ 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<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>);
|
||||
};
|
||||
|
||||
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<HTMLInputElement>) => {
|
||||
handleValueChange(clampValueToRange(valueAsNumber || 0));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={classnames(props.containerClassName, styles['number-input'])}>
|
||||
{
|
||||
showButtons ?
|
||||
<Button
|
||||
className={styles['button']}
|
||||
onClick={handleDecrement}
|
||||
disabled={props.disabled || (min !== undefined ? displayValue <= min : false)}>
|
||||
<Icon className={styles['icon']} name={'remove'} />
|
||||
</Button>
|
||||
: null
|
||||
}
|
||||
<div className={classnames(styles['number-display'], { [styles['buttons-container']]: showButtons })}>
|
||||
{
|
||||
props.label ?
|
||||
<div className={styles['label']}>{props.label}</div>
|
||||
: null
|
||||
}
|
||||
<input
|
||||
ref={ref}
|
||||
type={'number'}
|
||||
tabIndex={0}
|
||||
value={displayValue}
|
||||
{...props}
|
||||
className={classnames(props.className, styles['value'], { [styles.disabled]: props.disabled })}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
showButtons ?
|
||||
<Button
|
||||
className={styles['button']} onClick={handleIncrement} disabled={props.disabled || (max !== undefined ? displayValue >= max : false)}>
|
||||
<Icon className={styles['icon']} name={'add'} />
|
||||
</Button>
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
NumberInput.displayName = 'NumberInput';
|
||||
|
||||
export default memo(NumberInput);
|
||||
5
src/components/NumberInput/index.ts
Normal file
5
src/components/NumberInput/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
import NumberInput from './NumberInput';
|
||||
|
||||
export default NumberInput;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
29
src/routes/MetaDetails/EpisodePicker/EpisodePicker.less
Normal file
29
src/routes/MetaDetails/EpisodePicker/EpisodePicker.less
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
71
src/routes/MetaDetails/EpisodePicker/EpisodePicker.tsx
Normal file
71
src/routes/MetaDetails/EpisodePicker/EpisodePicker.tsx
Normal file
|
|
@ -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<HTMLInputElement>) => {
|
||||
setSeason(parseInt(event.target.value));
|
||||
}, []);
|
||||
|
||||
const handleEpisodeChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setEpisode(parseInt(event.target.value));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit(season, episode);
|
||||
};
|
||||
|
||||
const disabled = season === initialSeason && episode === initialEpisode;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<NumberInput
|
||||
min={0}
|
||||
label={t('SEASON')}
|
||||
defaultValue={season}
|
||||
onChange={handleSeasonChange}
|
||||
showButtons
|
||||
/>
|
||||
<NumberInput
|
||||
min={1}
|
||||
label={t('EPISODE')}
|
||||
defaultValue={episode}
|
||||
onChange={handleEpisodeChange}
|
||||
showButtons
|
||||
/>
|
||||
<Button
|
||||
className={styles['button-container']}
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className={styles['label']}>{t('SIDEBAR_SHOW_STREAMS')}</div>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EpisodePicker;
|
||||
5
src/routes/MetaDetails/EpisodePicker/index.ts
Normal file
5
src/routes/MetaDetails/EpisodePicker/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
import SeasonEpisodePicker from './EpisodePicker';
|
||||
|
||||
export default SeasonEpisodePicker;
|
||||
|
|
@ -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 ?
|
||||
<div className={styles['meta-message-container']}>
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<div className={styles['message-label']}>No addons ware requested for this meta!</div>
|
||||
<div className={styles['message-label']}>No addons were requested for this meta!</div>
|
||||
</div>
|
||||
:
|
||||
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 ?
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={classnames(className, styles['streams-list-container'])}>
|
||||
<div className={styles['select-choices-wrapper']}>
|
||||
|
|
@ -122,12 +128,27 @@ const StreamsList = ({ className, video, ...props }) => {
|
|||
{
|
||||
props.streams.length === 0 ?
|
||||
<div className={styles['message-container']}>
|
||||
{
|
||||
type === 'series' ?
|
||||
<SeasonEpisodePicker className={styles['search']} onSubmit={handleEpisodePicker} />
|
||||
: null
|
||||
}
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<div className={styles['label']}>No addons were requested for streams!</div>
|
||||
</div>
|
||||
:
|
||||
props.streams.every((streams) => streams.content.type === 'Err') ?
|
||||
<div className={styles['message-container']}>
|
||||
{
|
||||
type === 'series' ?
|
||||
<SeasonEpisodePicker className={styles['search']} onSubmit={handleEpisodePicker} />
|
||||
: null
|
||||
}
|
||||
{
|
||||
video?.upcoming ?
|
||||
<div className={styles['label']}>{t('UPCOMING')}...</div>
|
||||
: null
|
||||
}
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<div className={styles['label']}>{t('NO_STREAM')}</div>
|
||||
{
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={classnames(className, styles['videos-list-container'])}>
|
||||
{
|
||||
|
|
@ -110,6 +120,7 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
|
|||
:
|
||||
metaItem.content.type === 'Err' || videosForSeason.length === 0 ?
|
||||
<div className={styles['message-container']}>
|
||||
<EpisodePicker className={styles['episode-picker']} onSubmit={onSeasonSearch} />
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<div className={styles['label']}>No videos found for this meta!</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue