mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-09 22:02:19 +02:00
add initial files
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import ReactPlayer, { ReactPlayerProps } from 'react-player';
|
||||
import {
|
||||
CrossfadeStyle,
|
||||
PlaybackStyle,
|
||||
PlayerStatus,
|
||||
Song,
|
||||
} from '../../../types';
|
||||
import { crossfadeHandler, gaplessHandler } from './utils/listenHandlers';
|
||||
|
||||
interface AudioPlayerProps extends ReactPlayerProps {
|
||||
crossfadeDuration: number;
|
||||
crossfadeStyle: CrossfadeStyle;
|
||||
currentPlayer: 1 | 2;
|
||||
player1: Song;
|
||||
player2: Song;
|
||||
status: PlayerStatus;
|
||||
style: PlaybackStyle;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export type AudioPlayerProgress = {
|
||||
loaded: number;
|
||||
loadedSeconds: number;
|
||||
played: number;
|
||||
playedSeconds: number;
|
||||
};
|
||||
|
||||
const getDuration = (ref: any) => {
|
||||
return ref.current?.player?.player?.player?.duration;
|
||||
};
|
||||
|
||||
export const AudioPlayer = forwardRef(
|
||||
(
|
||||
{
|
||||
status,
|
||||
style,
|
||||
crossfadeStyle,
|
||||
crossfadeDuration,
|
||||
currentPlayer,
|
||||
autoNext,
|
||||
player1,
|
||||
player2,
|
||||
muted,
|
||||
volume,
|
||||
}: AudioPlayerProps,
|
||||
ref: any
|
||||
) => {
|
||||
const player1Ref = useRef<any>(null);
|
||||
const player2Ref = useRef<any>(null);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
get player1() {
|
||||
return player1Ref?.current;
|
||||
},
|
||||
get player2() {
|
||||
return player2Ref?.current;
|
||||
},
|
||||
}));
|
||||
|
||||
const handleOnEnded = () => {
|
||||
autoNext();
|
||||
setIsTransitioning(false);
|
||||
};
|
||||
|
||||
const handleCrossfade1 = useCallback(
|
||||
(e: AudioPlayerProgress) => {
|
||||
return crossfadeHandler({
|
||||
currentPlayer,
|
||||
currentPlayerRef: player1Ref,
|
||||
currentTime: e.playedSeconds,
|
||||
duration: getDuration(player1Ref),
|
||||
fadeDuration: crossfadeDuration,
|
||||
fadeType: crossfadeStyle,
|
||||
isTransitioning,
|
||||
nextPlayerRef: player2Ref,
|
||||
player: 1,
|
||||
setIsTransitioning,
|
||||
volume,
|
||||
});
|
||||
},
|
||||
[
|
||||
crossfadeDuration,
|
||||
crossfadeStyle,
|
||||
currentPlayer,
|
||||
isTransitioning,
|
||||
volume,
|
||||
]
|
||||
);
|
||||
|
||||
const handleCrossfade2 = useCallback(
|
||||
(e: AudioPlayerProgress) => {
|
||||
return crossfadeHandler({
|
||||
currentPlayer,
|
||||
currentPlayerRef: player2Ref,
|
||||
currentTime: e.playedSeconds,
|
||||
duration: getDuration(player2Ref),
|
||||
fadeDuration: crossfadeDuration,
|
||||
fadeType: crossfadeStyle,
|
||||
isTransitioning,
|
||||
nextPlayerRef: player1Ref,
|
||||
player: 2,
|
||||
setIsTransitioning,
|
||||
volume,
|
||||
});
|
||||
},
|
||||
[
|
||||
crossfadeDuration,
|
||||
crossfadeStyle,
|
||||
currentPlayer,
|
||||
isTransitioning,
|
||||
volume,
|
||||
]
|
||||
);
|
||||
|
||||
const handleGapless1 = useCallback(
|
||||
(e: AudioPlayerProgress) => {
|
||||
return gaplessHandler({
|
||||
currentTime: e.playedSeconds,
|
||||
duration: getDuration(player1Ref),
|
||||
isFlac: player1?.suffix === 'flac',
|
||||
isTransitioning,
|
||||
nextPlayerRef: player2Ref,
|
||||
setIsTransitioning,
|
||||
});
|
||||
},
|
||||
[isTransitioning, player1?.suffix]
|
||||
);
|
||||
|
||||
const handleGapless2 = useCallback(
|
||||
(e: AudioPlayerProgress) => {
|
||||
return gaplessHandler({
|
||||
currentTime: e.playedSeconds,
|
||||
duration: getDuration(player2Ref),
|
||||
isFlac: player2?.suffix === 'flac',
|
||||
isTransitioning,
|
||||
nextPlayerRef: player1Ref,
|
||||
setIsTransitioning,
|
||||
});
|
||||
},
|
||||
[isTransitioning, player2?.suffix]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactPlayer
|
||||
ref={player1Ref}
|
||||
height={0}
|
||||
muted={muted}
|
||||
playing={currentPlayer === 1 && status === PlayerStatus.Playing}
|
||||
progressInterval={isTransitioning ? 10 : 250}
|
||||
url={player1?.streamUrl}
|
||||
volume={volume}
|
||||
width={0}
|
||||
onEnded={handleOnEnded}
|
||||
onProgress={
|
||||
style === PlaybackStyle.Gapless ? handleGapless1 : handleCrossfade1
|
||||
}
|
||||
/>
|
||||
<ReactPlayer
|
||||
ref={player2Ref}
|
||||
height={0}
|
||||
muted={muted}
|
||||
playing={currentPlayer === 2 && status === PlayerStatus.Playing}
|
||||
progressInterval={isTransitioning ? 10 : 250}
|
||||
url={player2?.streamUrl}
|
||||
volume={volume}
|
||||
width={0}
|
||||
onEnded={handleOnEnded}
|
||||
onProgress={
|
||||
style === PlaybackStyle.Gapless ? handleGapless2 : handleCrossfade2
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,147 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { Dispatch } from 'react';
|
||||
import { CrossfadeStyle } from '../../../../types';
|
||||
|
||||
export const gaplessHandler = (args: {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isFlac: boolean;
|
||||
isTransitioning: boolean;
|
||||
nextPlayerRef: any;
|
||||
setIsTransitioning: Dispatch<boolean>;
|
||||
}) => {
|
||||
const {
|
||||
nextPlayerRef,
|
||||
currentTime,
|
||||
duration,
|
||||
isTransitioning,
|
||||
setIsTransitioning,
|
||||
isFlac,
|
||||
} = args;
|
||||
|
||||
if (!isTransitioning) {
|
||||
if (currentTime > duration - 2) {
|
||||
return setIsTransitioning(true);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const durationPadding = isFlac ? 0.065 : 0.116;
|
||||
if (currentTime + durationPadding >= duration) {
|
||||
return nextPlayerRef.current.getInternalPlayer().play();
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const crossfadeHandler = (args: {
|
||||
currentPlayer: 1 | 2;
|
||||
currentPlayerRef: any;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
fadeDuration: number;
|
||||
fadeType: CrossfadeStyle;
|
||||
isTransitioning: boolean;
|
||||
nextPlayerRef: any;
|
||||
player: 1 | 2;
|
||||
setIsTransitioning: Dispatch<boolean>;
|
||||
volume: number;
|
||||
}) => {
|
||||
const {
|
||||
currentTime,
|
||||
player,
|
||||
currentPlayer,
|
||||
currentPlayerRef,
|
||||
nextPlayerRef,
|
||||
fadeDuration,
|
||||
fadeType,
|
||||
duration,
|
||||
volume,
|
||||
isTransitioning,
|
||||
setIsTransitioning,
|
||||
} = args;
|
||||
|
||||
if (!isTransitioning || currentPlayer !== player) {
|
||||
const shouldBeginTransition = currentTime >= duration - fadeDuration;
|
||||
|
||||
if (shouldBeginTransition) {
|
||||
setIsTransitioning(true);
|
||||
return nextPlayerRef.current.getInternalPlayer().play();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeLeft = duration - currentTime;
|
||||
let currentPlayerVolumeCalculation;
|
||||
let nextPlayerVolumeCalculation;
|
||||
let percentageOfFadeLeft;
|
||||
let n;
|
||||
switch (fadeType) {
|
||||
case 'equalPower':
|
||||
// https://dsp.stackexchange.com/a/14755
|
||||
percentageOfFadeLeft = (timeLeft / fadeDuration) * 2;
|
||||
currentPlayerVolumeCalculation =
|
||||
Math.sqrt(0.5 * percentageOfFadeLeft) * volume;
|
||||
nextPlayerVolumeCalculation =
|
||||
Math.sqrt(0.5 * (2 - percentageOfFadeLeft)) * volume;
|
||||
break;
|
||||
case 'linear':
|
||||
currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;
|
||||
nextPlayerVolumeCalculation =
|
||||
((fadeDuration - timeLeft) / fadeDuration) * volume;
|
||||
break;
|
||||
case 'dipped':
|
||||
// https://math.stackexchange.com/a/4622
|
||||
percentageOfFadeLeft = timeLeft / fadeDuration;
|
||||
currentPlayerVolumeCalculation = percentageOfFadeLeft ** 2 * volume;
|
||||
nextPlayerVolumeCalculation = (percentageOfFadeLeft - 1) ** 2 * volume;
|
||||
break;
|
||||
case fadeType.match(/constantPower.*/)?.input:
|
||||
// https://math.stackexchange.com/a/26159
|
||||
n =
|
||||
fadeType === 'constantPower'
|
||||
? 0
|
||||
: fadeType === 'constantPowerSlowFade'
|
||||
? 1
|
||||
: fadeType === 'constantPowerSlowCut'
|
||||
? 3
|
||||
: 10;
|
||||
|
||||
percentageOfFadeLeft = timeLeft / fadeDuration;
|
||||
currentPlayerVolumeCalculation =
|
||||
Math.cos(
|
||||
(Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) - 1)
|
||||
) * volume;
|
||||
nextPlayerVolumeCalculation =
|
||||
Math.cos(
|
||||
(Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) + 1)
|
||||
) * volume;
|
||||
break;
|
||||
|
||||
default:
|
||||
currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;
|
||||
nextPlayerVolumeCalculation =
|
||||
((fadeDuration - timeLeft) / fadeDuration) * volume;
|
||||
break;
|
||||
}
|
||||
|
||||
const currentPlayerVolume =
|
||||
currentPlayerVolumeCalculation >= 0 ? currentPlayerVolumeCalculation : 0;
|
||||
|
||||
const nextPlayerVolume =
|
||||
nextPlayerVolumeCalculation <= volume
|
||||
? nextPlayerVolumeCalculation
|
||||
: volume;
|
||||
|
||||
if (currentPlayer === 1) {
|
||||
currentPlayerRef.current.getInternalPlayer().volume = currentPlayerVolume;
|
||||
nextPlayerRef.current.getInternalPlayer().volume = nextPlayerVolume;
|
||||
} else {
|
||||
currentPlayerRef.current.getInternalPlayer().volume = currentPlayerVolume;
|
||||
nextPlayerRef.current.getInternalPlayer().volume = nextPlayerVolume;
|
||||
}
|
||||
// }
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Button as MantineButton } from '@mantine/core';
|
||||
|
||||
interface ButtonProps {
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export const Button = ({ icon }: ButtonProps) => {
|
||||
return <MantineButton>Button</MantineButton>;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { ActionIcon, ActionIconProps, TooltipProps } from '@mantine/core';
|
||||
import { Tooltip } from '../tooltip/Tooltip';
|
||||
|
||||
type MantineIconButtonProps = ActionIconProps &
|
||||
React.ComponentPropsWithoutRef<'button'>;
|
||||
|
||||
interface IconButtonProps extends MantineIconButtonProps {
|
||||
active?: boolean;
|
||||
icon: ReactNode;
|
||||
tooltip?: Omit<TooltipProps, 'children'>;
|
||||
}
|
||||
|
||||
export const IconButton = ({
|
||||
active,
|
||||
tooltip,
|
||||
icon,
|
||||
...rest
|
||||
}: IconButtonProps) => {
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip {...tooltip}>
|
||||
<ActionIcon {...rest}>{icon}</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return <ActionIcon {...rest}>{icon}</ActionIcon>;
|
||||
};
|
||||
|
||||
IconButton.defaultProps = {
|
||||
active: false,
|
||||
tooltip: undefined,
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './tooltip/Tooltip';
|
||||
export * from './audio-player/AudioPlayer';
|
||||
export * from './icon-button/IconButton';
|
||||
export * from './text/Text';
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
Modal as MantineModal,
|
||||
ModalProps as MantineModalProps,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
|
||||
interface ModalProps extends MantineModalProps {
|
||||
condition: boolean;
|
||||
}
|
||||
|
||||
export const Modal = ({ condition, children, ...rest }: ModalProps) => {
|
||||
const [opened, handlers] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{condition && (
|
||||
<MantineModal
|
||||
{...rest}
|
||||
opened={opened}
|
||||
onClose={() => handlers.close()}
|
||||
>
|
||||
{children}
|
||||
</MantineModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ReactNode } from 'react';
|
||||
import {
|
||||
Text as MantineText,
|
||||
TextProps as MantineTextProps,
|
||||
} from '@mantine/core';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { Font } from 'renderer/styles';
|
||||
import { textEllipsis } from 'renderer/styles/mixins';
|
||||
|
||||
interface TextProps extends MantineTextProps<'div'> {
|
||||
children: ReactNode;
|
||||
font?: Font;
|
||||
link?: boolean;
|
||||
noSelect?: boolean;
|
||||
overflow?: 'hidden' | 'visible';
|
||||
secondary?: boolean;
|
||||
to?: string;
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
interface LinkTextProps extends MantineTextProps<'Link'> {
|
||||
children: ReactNode;
|
||||
font?: Font;
|
||||
link?: boolean;
|
||||
overflow?: 'hidden' | 'visible';
|
||||
secondary?: boolean;
|
||||
to: string;
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
const BaseText = styled(MantineText)<any>`
|
||||
color: ${(props) =>
|
||||
props.$secondary
|
||||
? 'var(--playerbar-text-secondary-color)'
|
||||
: 'var(--playerbar-text-primary-color)'};
|
||||
font-family: ${(props) => props.font || Font.GOTHAM};
|
||||
cursor: ${(props) => (props.link ? 'cursor' : 'default')};
|
||||
user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')};
|
||||
${(props) => props.overflow === 'hidden' && textEllipsis}
|
||||
`;
|
||||
|
||||
const StyledText = styled(BaseText)<TextProps>``;
|
||||
|
||||
const StyledLinkText = styled(BaseText)<LinkTextProps>``;
|
||||
|
||||
export const Text = ({
|
||||
children,
|
||||
link,
|
||||
secondary,
|
||||
overflow,
|
||||
font,
|
||||
to,
|
||||
noSelect,
|
||||
...rest
|
||||
}: TextProps) => {
|
||||
if (link) {
|
||||
return (
|
||||
<StyledLinkText<typeof Link>
|
||||
$noSelect={noSelect}
|
||||
$secondary={secondary}
|
||||
component={Link}
|
||||
font={font}
|
||||
link="true"
|
||||
overflow={overflow}
|
||||
to={to || ''}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</StyledLinkText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledText
|
||||
$noSelect={noSelect}
|
||||
$secondary={secondary}
|
||||
font={font}
|
||||
overflow={overflow}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</StyledText>
|
||||
);
|
||||
};
|
||||
|
||||
Text.defaultProps = {
|
||||
font: Font.GOTHAM,
|
||||
link: false,
|
||||
noSelect: false,
|
||||
overflow: 'visible',
|
||||
secondary: false,
|
||||
to: '',
|
||||
weight: 500,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
.body {
|
||||
padding: 5px;
|
||||
color: var(--tooltip-text-color);
|
||||
background: var(--tooltip-bg);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
background: var(--tooltip-bg);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Tooltip as MantineTooltip, TooltipProps } from '@mantine/core';
|
||||
import styles from './Tooltip.module.scss';
|
||||
|
||||
export const Tooltip = ({ children, ...rest }: TooltipProps) => {
|
||||
return (
|
||||
<MantineTooltip
|
||||
classNames={{ arrow: styles.arrow, body: styles.body }}
|
||||
radius="xs"
|
||||
styles={{
|
||||
arrow: {
|
||||
background: 'var(--tooltip-bg)',
|
||||
},
|
||||
body: {
|
||||
background: 'var(--tooltip-bg)',
|
||||
color: 'var(--tooltip-text-color)',
|
||||
padding: '5px',
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</MantineTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
Tooltip.defaultProps = {
|
||||
openDelay: 0,
|
||||
placement: 'center',
|
||||
transition: 'fade',
|
||||
transitionDuration: 250,
|
||||
withArrow: true,
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Card } from '@mantine/core';
|
||||
import { motion } from 'framer-motion';
|
||||
import styled from 'styled-components';
|
||||
import { CardRow } from 'renderer/types';
|
||||
import { Text } from '../text/Text';
|
||||
import { GridCardControls } from './GridCardControls';
|
||||
|
||||
const CardWrapper = styled(motion.div)<{
|
||||
itemGap: number;
|
||||
itemHeight: number;
|
||||
itemWidth: number;
|
||||
}>`
|
||||
display: flex;
|
||||
flex: ${({ itemWidth }) => `0 0 ${itemWidth}px`};
|
||||
width: ${({ itemWidth }) => `${itemWidth}px`};
|
||||
height: ${({ itemHeight }) => `${itemHeight}px`};
|
||||
margin: ${({ itemGap }) => `0 ${itemGap / 2}px`};
|
||||
border-radius: 3px;
|
||||
filter: drop-shadow(0 4px 4px #000);
|
||||
user-select: none;
|
||||
pointer-events: auto; // https://github.com/bvaughn/react-window/issues/128#issuecomment-460166682
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid #fff;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCard = styled(Card)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
background-color: rgb(50, 50, 50, 50%);
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: rgb(50, 50, 50, 60%);
|
||||
}
|
||||
`;
|
||||
|
||||
const ImageSection = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const Image = styled(motion.div)<{ height: number; src: string }>`
|
||||
height: ${({ height }) => `${height}px`};
|
||||
background: ${({ src }) => `url(${src})`};
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
border: 0;
|
||||
`;
|
||||
|
||||
const ControlsContainer = styled.div`
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
${StyledCard}:hover & {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
const DetailSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const Row = styled.div`
|
||||
height: 25px;
|
||||
padding: 0 0.2rem;
|
||||
`;
|
||||
|
||||
export const GridCard = ({ data, index, style, isScrolling }: any) => {
|
||||
const {
|
||||
itemHeight,
|
||||
itemWidth,
|
||||
columnCount,
|
||||
itemGap,
|
||||
itemCount,
|
||||
cardControls,
|
||||
cardRows,
|
||||
itemData,
|
||||
handlePlayQueueAdd,
|
||||
} = data;
|
||||
|
||||
const startIndex = index * columnCount;
|
||||
const stopIndex = Math.min(itemCount - 1, startIndex + columnCount - 1);
|
||||
const cards = [];
|
||||
|
||||
for (let i = startIndex; i <= stopIndex; i += 1) {
|
||||
cards.push(
|
||||
<CardWrapper
|
||||
key={`card-${i}-${index}`}
|
||||
itemGap={itemGap}
|
||||
itemHeight={itemHeight}
|
||||
itemWidth={itemWidth}
|
||||
tabIndex={0}
|
||||
>
|
||||
<StyledCard>
|
||||
<ImageSection>
|
||||
<Image
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
height={itemWidth}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
}}
|
||||
src={itemData[i]?.image}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
ease: 'anticipate',
|
||||
}}
|
||||
>
|
||||
{!isScrolling && (
|
||||
<ControlsContainer>
|
||||
<GridCardControls
|
||||
cardControls={cardControls}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
itemData={itemData[i]}
|
||||
/>
|
||||
</ControlsContainer>
|
||||
)}
|
||||
</Image>
|
||||
</ImageSection>
|
||||
<DetailSection>
|
||||
{cardRows.map((row: CardRow) => (
|
||||
<Row key={`row-${row.prop}`}>
|
||||
<Text overflow="hidden" weight={500}>
|
||||
{itemData[i] && itemData[i][row.prop]}
|
||||
</Text>
|
||||
</Row>
|
||||
))}
|
||||
</DetailSection>
|
||||
</StyledCard>
|
||||
</CardWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
>
|
||||
{cards}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { UnstyledButton, UnstyledButtonProps } from '@mantine/core';
|
||||
import { motion } from 'framer-motion';
|
||||
import styled from 'styled-components';
|
||||
import { PlayerPlay } from 'tabler-icons-react';
|
||||
|
||||
type PlayButtonType = UnstyledButtonProps &
|
||||
React.ComponentPropsWithoutRef<'button'>;
|
||||
|
||||
const PlayButton = styled(UnstyledButton)<PlayButtonType>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
cursor: default;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: #000;
|
||||
stroke: #000;
|
||||
}
|
||||
`;
|
||||
|
||||
const GridCardControlsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const ControlsRow = styled(motion.div)`
|
||||
width: 100%;
|
||||
height: calc(100% / 3);
|
||||
`;
|
||||
|
||||
const TopControls = styled(ControlsRow)`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const CenterControls = styled(ControlsRow)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const BottomControls = styled(ControlsRow)`
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
export const GridCardControls = ({
|
||||
itemData,
|
||||
handlePlayQueueAdd,
|
||||
cardControls,
|
||||
}: any) => {
|
||||
return (
|
||||
<GridCardControlsContainer>
|
||||
<TopControls />
|
||||
<CenterControls animate={{ opacity: 1 }} initial={{ opacity: 0 }}>
|
||||
<PlayButton
|
||||
onClick={() => {
|
||||
handlePlayQueueAdd({
|
||||
byItemType: {
|
||||
endpoint: cardControls.endpoint,
|
||||
id: itemData[cardControls.idProperty],
|
||||
type: cardControls.type,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<PlayerPlay />
|
||||
</PlayButton>
|
||||
</CenterControls>
|
||||
<BottomControls />
|
||||
</GridCardControlsContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Ref, useMemo } from 'react';
|
||||
import { FixedSizeList, FixedSizeListProps } from 'react-window';
|
||||
import { usePlayQueueHandler } from 'renderer/features/player/hooks/usePlayQueueHandler';
|
||||
import { CardRow } from 'renderer/types';
|
||||
import { GridCard } from './GridCard';
|
||||
|
||||
export const VirtualGridWrapper = ({
|
||||
refInstance,
|
||||
cardControls,
|
||||
cardRows,
|
||||
itemGap,
|
||||
itemWidth,
|
||||
itemHeight,
|
||||
itemCount,
|
||||
columnCount,
|
||||
rowCount,
|
||||
...rest
|
||||
}: Omit<FixedSizeListProps, 'ref' | 'itemSize' | 'children'> & {
|
||||
cardControls: any;
|
||||
cardRows: CardRow[];
|
||||
columnCount: number;
|
||||
itemGap: number;
|
||||
itemHeight: number;
|
||||
itemWidth: number;
|
||||
refInstance: Ref<any>;
|
||||
rowCount: number;
|
||||
}) => {
|
||||
const { handlePlayQueueAdd } = usePlayQueueHandler();
|
||||
|
||||
const itemData = useMemo(
|
||||
() => ({
|
||||
cardControls,
|
||||
cardRows,
|
||||
columnCount,
|
||||
handlePlayQueueAdd,
|
||||
itemCount,
|
||||
itemData: rest.itemData,
|
||||
itemGap,
|
||||
itemHeight,
|
||||
itemWidth,
|
||||
}),
|
||||
[
|
||||
cardRows,
|
||||
cardControls,
|
||||
columnCount,
|
||||
itemCount,
|
||||
rest.itemData,
|
||||
itemGap,
|
||||
itemHeight,
|
||||
itemWidth,
|
||||
handlePlayQueueAdd,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<FixedSizeList
|
||||
style={{ scrollBehavior: 'smooth' }}
|
||||
{...rest}
|
||||
ref={refInstance}
|
||||
initialScrollOffset={0}
|
||||
itemCount={rowCount}
|
||||
itemData={itemData}
|
||||
itemSize={itemHeight + itemGap}
|
||||
overscanCount={10}
|
||||
>
|
||||
{GridCard}
|
||||
</FixedSizeList>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { forwardRef, Ref, useState } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { FixedSizeListProps } from 'react-window';
|
||||
import InfiniteLoader from 'react-window-infinite-loader';
|
||||
import { CardRow } from 'renderer/types';
|
||||
import { VirtualGridWrapper } from './VirtualGridWrapper';
|
||||
|
||||
interface VirtualGridProps
|
||||
extends Omit<
|
||||
FixedSizeListProps,
|
||||
'children' | 'itemSize' | 'height' | 'width'
|
||||
> {
|
||||
cardControls: any;
|
||||
cardRows: CardRow[];
|
||||
itemGap?: number;
|
||||
itemSize: number;
|
||||
minimumBatchSize?: number;
|
||||
query: (props: any) => Promise<any>;
|
||||
queryParams?: Record<string, any>;
|
||||
}
|
||||
|
||||
export const VirtualInfiniteGrid = forwardRef(
|
||||
(
|
||||
{
|
||||
itemCount,
|
||||
itemGap,
|
||||
itemSize,
|
||||
cardControls,
|
||||
cardRows,
|
||||
minimumBatchSize,
|
||||
query,
|
||||
queryParams,
|
||||
}: VirtualGridProps,
|
||||
ref: Ref<InfiniteLoader>
|
||||
) => {
|
||||
const [itemData, setItemData] = useState<any[]>([]);
|
||||
|
||||
const isItemLoaded = (index: number, columnCount: number) => {
|
||||
const itemIndex = index * columnCount;
|
||||
|
||||
return (
|
||||
itemIndex < itemData.length * columnCount &&
|
||||
itemData[itemIndex] !== undefined
|
||||
);
|
||||
};
|
||||
|
||||
const loadMoreItems = async (
|
||||
startIndex: number,
|
||||
stopIndex: number,
|
||||
limit: number,
|
||||
columnCount: number
|
||||
) => {
|
||||
const currentPage = Math.ceil(startIndex / minimumBatchSize!);
|
||||
|
||||
const t = await query({
|
||||
limit,
|
||||
page: currentPage,
|
||||
...queryParams,
|
||||
});
|
||||
|
||||
// Need to multiply by columnCount due to the grid layout
|
||||
const start = startIndex * columnCount;
|
||||
const end = (stopIndex + 1) * columnCount;
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const newData: any[] = [...itemData];
|
||||
|
||||
let itemIndex = 0;
|
||||
for (let rowIndex = start; rowIndex < end; rowIndex += 1) {
|
||||
newData[rowIndex] = t?.data[itemIndex];
|
||||
itemIndex += 1;
|
||||
}
|
||||
|
||||
setItemData(newData);
|
||||
resolve();
|
||||
});
|
||||
};
|
||||
|
||||
const debouncedLoadMoreItems = debounce(loadMoreItems, 300);
|
||||
|
||||
return (
|
||||
<AutoSizer>
|
||||
{({ height, width }) => {
|
||||
const itemHeight = itemSize! + cardRows.length * 25;
|
||||
|
||||
const columnCount = Math.floor(
|
||||
(Number(width) - itemGap! + 3) / (itemSize! + itemGap! + 2)
|
||||
);
|
||||
|
||||
const rowCount = Math.ceil(itemCount / columnCount);
|
||||
|
||||
const pageItemLimit = columnCount * minimumBatchSize!;
|
||||
|
||||
return (
|
||||
<InfiniteLoader
|
||||
ref={ref}
|
||||
isItemLoaded={(index) => isItemLoaded(index, columnCount)}
|
||||
itemCount={itemCount || 0}
|
||||
loadMoreItems={(startIndex, stopIndex) =>
|
||||
debouncedLoadMoreItems(
|
||||
startIndex,
|
||||
stopIndex,
|
||||
pageItemLimit,
|
||||
columnCount
|
||||
)
|
||||
}
|
||||
minimumBatchSize={minimumBatchSize}
|
||||
threshold={10}
|
||||
>
|
||||
{({ onItemsRendered, ref: infiniteLoaderRef }) => (
|
||||
<VirtualGridWrapper
|
||||
useIsScrolling
|
||||
cardControls={cardControls}
|
||||
cardRows={cardRows}
|
||||
columnCount={columnCount}
|
||||
height={height}
|
||||
itemCount={itemCount || 0}
|
||||
itemData={itemData}
|
||||
itemGap={itemGap!}
|
||||
itemHeight={itemHeight!}
|
||||
itemWidth={itemSize}
|
||||
refInstance={infiniteLoaderRef}
|
||||
rowCount={rowCount}
|
||||
width={width}
|
||||
onItemsRendered={onItemsRendered}
|
||||
/>
|
||||
)}
|
||||
</InfiniteLoader>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
VirtualInfiniteGrid.defaultProps = {
|
||||
itemGap: 10,
|
||||
minimumBatchSize: 20,
|
||||
queryParams: {},
|
||||
};
|
||||
Reference in New Issue
Block a user