mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-04-20 02:22:09 +00:00
feature: Episode Picker compoenent
This commit is contained in:
parent
d986aab0ee
commit
4cad74b97c
14 changed files with 392 additions and 45 deletions
89
src/routes/MetaDetails/EpisodesBar/EpisodesBar.tsx
Normal file
89
src/routes/MetaDetails/EpisodesBar/EpisodesBar.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import EpisodesBarPlaceholder from './EpisodesBarPlaceholder';
|
||||
|
||||
export default EpisodesBarPlaceholder;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/routes/MetaDetails/EpisodesBar/index.ts
Normal file
5
src/routes/MetaDetails/EpisodesBar/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import EpisodesBar from './EpisodesBar';
|
||||
|
||||
export default EpisodesBar;
|
||||
91
src/routes/MetaDetails/EpisodesBar/styles.less
Normal file
91
src/routes/MetaDetails/EpisodesBar/styles.less
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/routes/MetaDetails/EpisodesBar/types.ts
Normal file
21
src/routes/MetaDetails/EpisodesBar/types.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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}
|
||||
/>
|
||||
:
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
21
src/routes/MetaDetails/SeasonsBar/types.ts
Normal file
21
src/routes/MetaDetails/SeasonsBar/types.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
20
src/routes/MetaDetails/useEpisode.js
Normal file
20
src/routes/MetaDetails/useEpisode.js
Normal 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;
|
||||
Loading…
Reference in a new issue