feature: Episode Picker compoenent

This commit is contained in:
Timothy Z. 2024-07-01 16:26:29 +03:00
parent d986aab0ee
commit 4cad74b97c
14 changed files with 392 additions and 45 deletions

View file

@ -0,0 +1,89 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React, { MouseEvent } from 'react';
import { t } from 'i18next';
import { Multiselect, Button, Icon } from 'stremio/common';
import { CustomSelectEvent, OnSelectFunction } from './types';
import EpisodesBarPlaceholder from './EpisodesBarPlaceholder';
import classnames from 'classnames';
import styles from './styles.less';
type Props = {
className?: string;
episodes: number[];
episode: number;
onSelect?: OnSelectFunction;
};
const EpisodesBar = ({ episodes, episode, className, onSelect }: Props) => {
const options = React.useMemo(() => {
return episodes.map((episode) => ({
value: String(episode),
label: `${t('EPISODE')} ${episode}`
}));
}, [episodes]);
const selected = React.useMemo(() => {
return [String(episode)];
}, [episode]);
const prevNextButtonOnClick = React.useCallback((event: MouseEvent<HTMLButtonElement>) => {
if (typeof onSelect === 'function') {
const episodeIndex = episodes.indexOf(episode);
const isNextAction = event.currentTarget.dataset.action === 'next';
const valueIndex = isNextAction
? episodeIndex + 1 < episodes.length
? episodeIndex + 1
: episodes.length - 1
: episodeIndex - 1 >= 0
? episodeIndex - 1
: 0;
const value = episodes[valueIndex];
onSelect({
type: 'select',
value,
reactEvent: event,
nativeEvent: event.nativeEvent,
});
}
}, [episode, episodes, onSelect]);
const episodesOnSelect = React.useCallback((event: CustomSelectEvent) => {
const value = Number(event.value);
if (typeof onSelect === 'function') {
onSelect({
type: 'select',
value,
reactEvent: event.reactEvent,
nativeEvent: event.nativeEvent,
});
}
}, [onSelect]);
return (
<div className={classnames(className, styles['seasons-bar-container'])}>
<Button className={styles['prev-episode-button']} title={'Previous episode'} data-action={'prev'} onClick={prevNextButtonOnClick}>
<Icon className={styles['icon']} name={'chevron-back'} />
<div className={styles['label']}>Prev</div>
</Button>
<Multiselect
className={styles['episodes-popup-label-container']}
title={`${t('EPISODE')} ${episode}`}
direction={'bottom-left'}
options={options}
selected={selected}
onSelect={episodesOnSelect}
/>
<Button className={styles['next-episode-button']} title={'Next episode'} data-action={'next'} onClick={prevNextButtonOnClick}>
<div className={styles['label']}>Next</div>
<Icon className={styles['icon']} name={'chevron-forward'} />
</Button>
</div>
);
};
EpisodesBar.Placeholder = EpisodesBarPlaceholder;
export default EpisodesBar;

View file

@ -0,0 +1,31 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React from 'react';
import classnames from 'classnames';
import Icon from '@stremio/stremio-icons/react';
import styles from './styles.less';
type Props = {
className?: string;
};
const EpisodesBarPlaceholder = ({ className }: Props) => {
return (
<div className={classnames(className, styles['episodes-bar-placeholder-container'])}>
<div className={styles['prev-episode-button']}>
<Icon className={styles['icon']} name={'chevron-back'} />
<div className={styles['label']}>Prev</div>
</div>
<div className={styles['episodes-popup-label-container']}>
<div className={styles['episodes-popup-label']}>Episode 1</div>
<Icon className={styles['episodes-popup-icon']} name={'caret-down'} />
</div>
<div className={styles['next-episode-button']}>
<div className={styles['label']}>Next</div>
<Icon className={styles['icon']} name={'chevron-forward'} />
</div>
</div>
);
};
export default EpisodesBarPlaceholder;

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2024 Smart code 203358507
import EpisodesBarPlaceholder from './EpisodesBarPlaceholder';
export default EpisodesBarPlaceholder;

View file

@ -0,0 +1,60 @@
// Copyright (C) 2017-2024 Smart code 203358507
.episodes-bar-placeholder-container {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 1rem;
.prev-episodes-button, .next-episodes-button {
flex: none;
display: flex;
flex-direction: row;
align-items: center;
width: 6.5rem;
height: 3rem;
padding: 0.5rem;
&>:first-child {
margin-right: 0.5rem;
}
.icon {
flex: none;
display: block;
width: 1.5rem;
height: 1.5rem;
color: var(--color-placeholder-background);
}
.label {
flex: 1;
max-height: 1.2em;
font-weight: 500;
text-align: center;
color: var(--color-placeholder-text);
}
}
.episodes-popup-label-container {
flex: 0 1 auto;
display: flex;
flex-direction: row;
align-items: center;
margin: 0 1rem;
.episodes-popup-label {
max-height: 1.2em;
font-weight: 500;
color: var(--color-placeholder-text);
}
.episodes-popup-icon {
flex: none;
width: 1rem;
height: 1rem;
margin-left: 1rem;
color: var(--color-placeholder-background);
}
}
}

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2024 Smart code 203358507
import EpisodesBar from './EpisodesBar';
export default EpisodesBar;

View file

@ -0,0 +1,91 @@
// Copyright (C) 2017-2024 Smart code 203358507
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
@import (reference) '~stremio/common/screen-sizes.less';
:import('~stremio/common/Multiselect/styles.less') {
multiselect-menu-container: menu-container;
multiselect-label: label;
multiselect-icon: icon;
}
.episodes-bar-container {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 1rem;
overflow: visible;
.prev-episode-button, .next-episode-button {
flex: none;
display: flex;
flex-direction: row;
align-items: center;
width: 6.5rem;
height: 3rem;
border-radius: 3rem;
padding: 0.5rem;
&:hover, &:focus {
background-color: var(--overlay-color);
}
&:focus {
background-color: var(--primary-foreground-color);
}
&>:first-child {
margin-right: 0.5rem;
}
.label {
flex: 1;
max-height: 1.2em;
font-weight: 500;
text-align: center;
color: var(--primary-foreground-color);
}
.icon {
flex: none;
width: 1.5rem;
height: 1.5rem;
color: var(--primary-foreground-color);
}
}
.episode-popup-label-container {
flex: 0 1 auto;
background: none;
&:hover, &:focus, &:global(.active) {
background-color: var(--overlay-color);
}
&>.multiselect-label {
color: var(--primary-foreground-color);
}
&>.multiselect-icon {
color: var(--primary-foreground-color);
}
.multiselect-menu-container {
max-height: calc(3.2rem * 7);
overflow: auto;
}
}
}
@media only screen and (max-width: @minimum) {
.episodes-bar-container {
height: 6rem;
.episodes-popup-label-container {
.multiselect-menu-container {
max-height: calc(3.2rem * 3);
}
}
}
}

View file

@ -0,0 +1,21 @@
// Copyright (C) 2017-2024 Smart code 203358507
export type OnSelectFunction = (event: OnSelectEvent) => void;
export type CustomSelectEvent = {
value: number;
reactEvent: React.SyntheticEvent;
nativeEvent: Event;
currentTarget: {
dataset: {
action: string;
};
};
};
export type OnSelectEvent = {
type: string;
value: number;
reactEvent: React.SyntheticEvent;
nativeEvent: Event;
};

View file

@ -9,6 +9,7 @@ const StreamsList = require('./StreamsList');
const VideosList = require('./VideosList');
const useMetaDetails = require('./useMetaDetails');
const useSeason = require('./useSeason');
const useEpisode = require('./useEpisode');
const useMetaExtensionTabs = require('./useMetaExtensionTabs');
const styles = require('./styles');
@ -16,6 +17,7 @@ const MetaDetails = ({ urlParams, queryParams }) => {
const { core } = useServices();
const metaDetails = useMetaDetails(urlParams);
const [season, setSeason] = useSeason(urlParams, queryParams);
const [episode, setEpisode] = useEpisode(urlParams, queryParams);
const [tabs, metaExtension, clearMetaExtension] = useMetaExtensionTabs(metaDetails.metaExtensions);
const [metaPath, streamPath] = React.useMemo(() => {
return metaDetails.selected !== null ?
@ -75,6 +77,10 @@ const MetaDetails = ({ urlParams, queryParams }) => {
const seasonOnSelect = React.useCallback((event) => {
setSeason(event.value);
}, [setSeason]);
const episodeOnSelect = React.useCallback((event) => {
setEpisode(event.value);
}, [setEpisode]);
const renderBackgroundImageFallback = React.useCallback(() => null, []);
return (
<div className={styles['metadetails-container']}>
@ -162,8 +168,8 @@ const MetaDetails = ({ urlParams, queryParams }) => {
className={styles['streams-list']}
metaItem={metaDetails.metaItem}
streams={metaDetails.streams}
season={season}
seasonOnSelect={seasonOnSelect}
episode={episode}
episodeOnSelect={episodeOnSelect}
video={video}
/>
:

View file

@ -7,28 +7,9 @@ import { t } from 'i18next';
import Icon from '@stremio/stremio-icons/react';
import { Button, Multiselect } from 'stremio/common';
import SeasonsBarPlaceholder from './SeasonsBarPlaceholder';
import type { OnSelectFunction, CustomSelectEvent } from './types';
import styles from './styles.less';
type OnSelectFunction = (event: OnSelectEvent) => void;
type CustomSelectEvent = {
value: number | string;
reactEvent: React.SyntheticEvent;
nativeEvent: Event;
currentTarget: {
dataset: {
action: string;
};
};
};
type OnSelectEvent = {
type: string;
value: number;
reactEvent: React.SyntheticEvent;
nativeEvent: Event;
};
type Props = {
className?: string;
seasons: number[];

View file

@ -1,4 +1,4 @@
// Copyright (C) 2017-2023 Smart code 203358507
// Copyright (C) 2017-2024 Smart code 203358507
import React from 'react';
import classnames from 'classnames';

View file

@ -0,0 +1,21 @@
// Copyright (C) 2017-2024 Smart code 203358507
export type OnSelectFunction = (event: OnSelectEvent) => void;
export type CustomSelectEvent = {
value: number;
reactEvent: React.SyntheticEvent;
nativeEvent: Event;
currentTarget: {
dataset: {
action: string;
};
};
};
export type OnSelectEvent = {
type: string;
value: number;
reactEvent: React.SyntheticEvent;
nativeEvent: Event;
};

View file

@ -1,4 +1,4 @@
// Copyright (C) 2017-2023 Smart code 203358507
// Copyright (C) 2017-2024 Smart code 203358507
const React = require('react');
const PropTypes = require('prop-types');
@ -9,6 +9,7 @@ const { Button, Image, Multiselect } = require('stremio/common');
const { useServices } = require('stremio/services');
const Stream = require('./Stream');
const styles = require('./styles');
const { default: EpisodesBar } = require('../EpisodesBar');
const ALL_ADDONS_KEY = 'ALL';
@ -16,23 +17,6 @@ const StreamsList = ({ className, video, metaItem, ...props }) => {
const { t } = useTranslation();
const { core } = useServices();
const [selectedAddon, setSelectedAddon] = React.useState(ALL_ADDONS_KEY);
const videos = React.useMemo(() => {
return metaItem && metaItem.content.type === 'Ready' ?
metaItem.content.content.videos
:
[];
}, [metaItem]);
const seasons = React.useMemo(() => {
return videos
.map(({ season }) => season)
.filter((season, index, seasons) => {
return season !== null &&
!isNaN(season) &&
typeof season === 'number' &&
seasons.indexOf(season) === index;
})
.sort((a, b) => (a || Number.MAX_SAFE_INTEGER) - (b || Number.MAX_SAFE_INTEGER));
}, [videos]);
const onAddonSelected = React.useCallback((event) => {
setSelectedAddon(event.value);
}, []);
@ -65,6 +49,22 @@ const StreamsList = ({ className, video, metaItem, ...props }) => {
return streamsByAddon;
}, {});
}, [props.streams]);
const videos = React.useMemo(() => {
return metaItem && metaItem.content.type === 'Ready' ?
metaItem.content.content.videos
:
[];
}, [metaItem]);
const currSeason = React.useMemo(() => {
return video?.season;
}, [video]);
const episodes = React.useMemo(() => {
return videos
.filter(({ season }) => season === currSeason)
.map(({ episode }) => episode)
.sort((a, b) => a - b);
}, [videos, currSeason]);
const filteredStreams = React.useMemo(() => {
return selectedAddon === ALL_ADDONS_KEY ?
Object.values(streamsByAddon).map(({ streams }) => streams).flat(1)
@ -104,7 +104,6 @@ const StreamsList = ({ className, video, metaItem, ...props }) => {
:
props.streams.every((streams) => streams.content.type === 'Err') ?
<React.Fragment>
{/* */}
<div className={styles['message-container']}>
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
<div className={styles['label']}>{t('NO_STREAM')}</div>
@ -129,6 +128,18 @@ const StreamsList = ({ className, video, metaItem, ...props }) => {
:
null
}
{
episodes.length > 0 ?
<EpisodesBar
className={styles['episodes-bar']}
episodes={episodes}
episode={props.episode}
onSelect={props.episodeOnSelect}
/>
:
null
}
<div className={styles['select-choices-wrapper']}>
{
video ?
@ -182,10 +193,10 @@ const StreamsList = ({ className, video, metaItem, ...props }) => {
StreamsList.propTypes = {
className: PropTypes.string,
streams: PropTypes.arrayOf(PropTypes.object).isRequired,
episode: PropTypes.number,
metaItem: PropTypes.object,
season: PropTypes.number,
seasonOnSelect: PropTypes.func,
video: PropTypes.object
video: PropTypes.object,
episodeOnSelect: PropTypes.func
};
module.exports = StreamsList;

View file

@ -64,6 +64,12 @@
}
}
.episodes-bar {
flex: none;
align-self: stretch;
margin: 0.5rem 1rem 1rem 1rem;
}
.select-choices-wrapper {
display: flex;
align-items: center;

View file

@ -0,0 +1,20 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const useEpisode = (urlParams, queryParams) => {
const episode = React.useMemo(() => {
return queryParams.has('episode') && !isNaN(queryParams.get('episode')) ?
parseInt(queryParams.get('episode'), 10)
:
null;
}, [queryParams]);
const setEpisode = React.useCallback((episode) => {
const nextQueryParams = new URLSearchParams(queryParams);
nextQueryParams.set('episode', episode);
window.location.replace(`#${urlParams.path}?${nextQueryParams}`);
}, [urlParams, queryParams]);
return [episode, setEpisode];
};
module.exports = useEpisode;