Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Streams List: Season and Episode picker when no streams loaded #827

Open
wants to merge 32 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
e8a6e72
feat(NumberInput): added NumberInput common component
Botsy Feb 7, 2025
15c6a23
feat(EpisodePicker): added season and episode picker when no streams …
Botsy Feb 7, 2025
461c9d3
fix(EpisodePicker): fix export
Botsy Feb 7, 2025
39f168a
fix(EpisodePicker): handle season 0 as value
Botsy Feb 7, 2025
538e462
fix(StreamsList): hide Install addons button if episode is upcoming
Botsy Feb 7, 2025
36a2896
fix(NumberInput): use color variable for font color
Botsy Feb 7, 2025
7fa4f46
feat(StreamsList): added upcoming label when no results
Botsy Feb 7, 2025
5e98355
fix(NumberInput): fix check for min and max values
Botsy Feb 10, 2025
6b30b90
fix(MetaDetails): handle search for any episode and season
Botsy Feb 10, 2025
34808d6
fix(EpisodePicker): set season and episode from url
Botsy Feb 10, 2025
0c9b992
Merge branch 'development' into feat/season-episode-inputs
Botsy Feb 10, 2025
d407e6c
fix(NumberInput): min & max validation when entering value from keyboard
Botsy Feb 10, 2025
fbdfa11
fix(StreamsList): add scroll when episode picker is shown on landscap…
Botsy Feb 10, 2025
f7494d6
fix(EpisodePicker): typings
Botsy Feb 10, 2025
6ca94a2
fix(EpisodePicker): unify styles with install addons button
Botsy Feb 11, 2025
3f60df9
refactor(EpisodePicker): improve styles and typings
Botsy Feb 11, 2025
3c2ab92
fix(EpisodePicker): make 0 the initial value for season when no value…
Botsy Feb 11, 2025
3bc075d
fix(EpisodePicker): remove named export
Botsy Feb 12, 2025
1721810
fix(NumberInput): follow style name convention
Botsy Feb 12, 2025
7a79e31
fix(EpisodePicker): use default export in StreamsList
Botsy Feb 12, 2025
6274115
refactor(EpisodePicker): simplify setting season and episode initial …
Botsy Feb 12, 2025
d7974ba
Update src/components/NumberInput/NumberInput.tsx
Botsy Feb 12, 2025
3dcd002
Update src/components/NumberInput/NumberInput.tsx
Botsy Feb 12, 2025
37020f3
Update src/routes/MetaDetails/StreamsList/StreamsList.js
Botsy Feb 12, 2025
232c64b
Update src/routes/MetaDetails/StreamsList/EpisodePicker/EpisodePicker…
Botsy Feb 12, 2025
aa3dedf
fix: linting
Botsy Feb 12, 2025
10a36d2
Update src/components/NumberInput/NumberInput.tsx
Botsy Feb 12, 2025
f678375
refactor(NumberInput): apply suggested improvements
Botsy Feb 12, 2025
4cd9db5
fix(NumberInput): remove unused import
Botsy Feb 12, 2025
07cc2a9
refactor(EpisodePicker): apply suggested improvements
Botsy Feb 12, 2025
675328c
fix(StreamsList): show EpisodePicker only if type is series
Botsy Feb 12, 2025
6999ef6
Update src/components/NumberInput/NumberInput.tsx
Botsy Feb 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/components/NumberInput/NumberInput.less
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
}
109 changes: 109 additions & 0 deletions src/components/NumberInput/NumberInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (C) 2017-2025 Smart code 203358507

import Icon from '@stremio/stremio-icons/react';
import React, { ChangeEvent, forwardRef, 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;
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
onSubmit?: (event: KeyboardEvent<HTMLInputElement>) => void;
onUpdate?: (value: number) => void;
};

const NumberInput = forwardRef<HTMLInputElement, Props>(({ defaultValue, showButtons, onUpdate, ...props }, ref) => {
const [value, setValue] = useState<number>(defaultValue || 0);
const onKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
props.onKeyDown && props.onKeyDown(event);

if (event.key === 'Enter') {
props.onSubmit && props.onSubmit(event);
}
}, [props.onKeyDown, props.onSubmit]);

const updateValueAndNotify = (valueAsNumber: number) => {
setValue(valueAsNumber);
onUpdate?.(valueAsNumber);
};

const handleIncrease = () => {
if (props.max !== undefined) {
updateValueAndNotify(Math.min(props.max, (value || 0) + 1));
return;
}
updateValueAndNotify((value || 0) + 1);
};

const handleDecrease = () => {
if (props.min !== undefined) {
updateValueAndNotify(Math.max(props.min, (value || 0) - 1));
return;
}
updateValueAndNotify((value || 0) - 1);
};

const handleChange = ({ target: { valueAsNumber }}: ChangeEvent<HTMLInputElement>) => {
const min = props.min || 0;
if (valueAsNumber && valueAsNumber < min) {
valueAsNumber = min;
}
if (props.max !== undefined && valueAsNumber && valueAsNumber > props.max) {
valueAsNumber = props.max;
}
updateValueAndNotify(valueAsNumber);
};

return (
<div className={classnames(props.containerClassName, styles['number-input'])}>
{
showButtons ?
<Button
className={styles['button']}
onClick={handleDecrease}
disabled={props.disabled || (props.min !== undefined ? value <= props.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={value}
{...props}
className={classnames(props.className, styles['value'], { 'disabled': props.disabled })}
onChange={handleChange}
onKeyDown={onKeyDown}
/>
</div>
{
showButtons ?
<Button
className={styles['button']} onClick={handleIncrease} disabled={props.disabled || (props.max !== undefined ? value >= props.max : false)}>
<Icon className={styles['icon']} name={'add'} />
</Button>
: null
}
</div>
);
});

NumberInput.displayName = 'NumberInput';

export default NumberInput;
5 changes: 5 additions & 0 deletions src/components/NumberInput/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright (C) 2017-2025 Smart code 203358507

import NumberInput from './NumberInput';

export default NumberInput;
2 changes: 2 additions & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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';
Expand Down Expand Up @@ -48,6 +49,7 @@ export {
MultiselectMenu,
HorizontalNavBar,
VerticalNavBar,
NumberInput,
Popup,
RadioButton,
SearchBar,
Expand Down
11 changes: 10 additions & 1 deletion src/routes/MetaDetails/MetaDetails.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down Expand Up @@ -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' ?
Expand Down Expand Up @@ -169,6 +176,8 @@ const MetaDetails = ({ urlParams, queryParams }) => {
className={styles['streams-list']}
streams={metaDetails.streams}
video={video}
type={streamPath.type}
onEpisodeSearch={handleEpisodeSearch}
/>
:
metaPath !== null ?
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
54 changes: 54 additions & 0 deletions src/routes/MetaDetails/StreamsList/EpisodePicker/EpisodePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright (C) 2017-2025 Smart code 203358507

import React, { useCallback, useMemo, useState } 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: isNaN(parseInt(pathSeason)) ? 0 : parseInt(pathSeason),
initialEpisode: isNaN(parseInt(pathEpisode)) ? 1 : parseInt(pathEpisode)
};
}, []);

const [season, setSeason] = useState(initialSeason);
const [episode, setEpisode] = useState(initialEpisode);

const handleSeasonChange = useCallback((value: number) => setSeason(!isNaN(value) ? value : 0), []);
const handleEpisodeChange = useCallback((value: number) => setEpisode(!isNaN(value) ? value : 1), []);

const handleSubmit = useCallback(() => {
if (typeof onSubmit === 'function' && !isNaN(season) && !isNaN(episode)) {
onSubmit(season, episode);
}
}, [onSubmit, season, episode]);

const disabled = useMemo(() => {
return season === initialSeason && episode === initialEpisode;
}, [season, episode, initialSeason, initialEpisode]);

return (
<div className={className}>
<NumberInput min={0} label={t('SEASON')} defaultValue={season} onUpdate={handleSeasonChange} showButtons />
<NumberInput min={1} label={t('EPISODE')} defaultValue={episode} onUpdate={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 changes: 5 additions & 0 deletions src/routes/MetaDetails/StreamsList/EpisodePicker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright (C) 2017-2025 Smart code 203358507

import SeasonEpisodePicker from './EpisodePicker';

export default SeasonEpisodePicker;
31 changes: 27 additions & 4 deletions src/routes/MetaDetails/StreamsList/StreamsList.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 + (
Expand Down Expand Up @@ -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']}>
Expand Down Expand Up @@ -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>
{
Expand Down Expand Up @@ -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;
Loading