fix and update remote design

This commit is contained in:
jeffvli 2025-06-24 14:36:14 -07:00
parent ad533a1d9c
commit 6689e84f67
24 changed files with 326 additions and 453 deletions

View file

@ -1,23 +1,21 @@
import { CiImageOff, CiImageOn } from 'react-icons/ci';
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
import { useShowImage, useToggleShowImage } from '/@/remote/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
export const ImageButton = () => {
const showImage = useShowImage();
const toggleImage = useToggleShowImage();
return (
<RemoteButton
mr={5}
<ActionIcon
onClick={() => toggleImage()}
size="xl"
tooltip={{
label: showImage ? 'Hide Image' : 'Show Image',
}}
variant="default"
>
{showImage ? <CiImageOff size={30} /> : <CiImageOn size={30} />}
</RemoteButton>
</ActionIcon>
);
};

View file

@ -1,24 +1,24 @@
import { RiRestartLine } from 'react-icons/ri';
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
import { useConnected, useReconnect } from '/@/remote/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
export const ReconnectButton = () => {
const connected = useConnected();
const reconnect = useReconnect();
return (
<RemoteButton
isActive={!connected}
mr={5}
<ActionIcon
onClick={() => reconnect()}
size="xl"
tooltip={{
label: connected ? 'Reconnect' : 'Not connected. Reconnect.',
}}
variant="default"
>
<RiRestartLine size={30} />
</RemoteButton>
<RiRestartLine
color={connected ? 'var(--theme-colors-primary)' : 'var(--theme-colors-foreground)'}
size={30}
/>
</ActionIcon>
);
};

View file

@ -1,24 +0,0 @@
.button {
svg {
display: flex;
fill: var(--theme-colors-foreground);
}
&:hover {
svg {
fill: var(--theme-colors-foreground);
}
}
}
.button.active {
svg {
fill: var(--primary-color);
}
&:hover {
svg {
fill: var(--primary-color) !important;
}
}
}

View file

@ -1,29 +0,0 @@
import clsx from 'clsx';
import { forwardRef, ReactNode, Ref } from 'react';
import styles from './remote-button.module.css';
import { Button, ButtonProps } from '/@/shared/components/button/button';
interface RemoteButtonProps extends ButtonProps {
children: ReactNode;
isActive?: boolean;
ref: Ref<HTMLButtonElement>;
}
export const RemoteButton = forwardRef<HTMLButtonElement, RemoteButtonProps>(
({ children, isActive, tooltip, ...props }, ref) => {
return (
<Button
className={clsx(styles.button, {
[styles.active]: isActive,
})}
tooltip={tooltip}
{...props}
ref={ref}
>
{children}
</Button>
);
},
);

View file

@ -1,30 +1,34 @@
import { useEffect } from 'react';
import { RiMoonLine, RiSunLine } from 'react-icons/ri';
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
import { useIsDark, useToggleDark } from '/@/remote/store';
import { AppTheme } from '/@/shared/themes/app-theme-types';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Icon } from '/@/shared/components/icon/icon';
export const ThemeButton = () => {
const isDark = useIsDark();
const toggleDark = useToggleDark();
useEffect(() => {
const targetTheme: AppTheme = isDark ? AppTheme.DEFAULT_DARK : AppTheme.DEFAULT_LIGHT;
document.body.setAttribute('data-theme', targetTheme);
}, [isDark]);
const handleToggleTheme = () => {
toggleDark();
};
return (
<RemoteButton
mr={5}
onClick={() => toggleDark()}
size="xl"
<ActionIcon
onClick={handleToggleTheme}
tooltip={{
label: 'Toggle Theme',
}}
variant="default"
>
{isDark ? <RiSunLine size={30} /> : <RiMoonLine size={30} />}
</RemoteButton>
{isDark ? (
<Icon
icon="themeLight"
size={30}
/>
) : (
<Icon
icon="themeDark"
size={30}
/>
)}
</ActionIcon>
);
};

View file

@ -0,0 +1,7 @@
.container {
width: 100%;
height: 40vh;
aspect-ratio: 1/1;
object-fit: var(--theme-image-fit);
border-radius: var(--theme-radius-md);
}

View file

@ -0,0 +1,18 @@
import styles from './player-image.module.css';
import { useSend } from '/@/remote/store';
interface PlayerImageProps {
src?: null | string;
}
export const PlayerImage = ({ src }: PlayerImageProps) => {
const send = useSend();
return (
<img
className={styles.container}
onError={() => send({ event: 'proxy' })}
src={src?.replaceAll(/&(size|width|height=\d+)/g, '')}
/>
);
};

View file

@ -1,24 +1,16 @@
import { Image, Title } from '@mantine/core';
import formatDuration from 'format-duration';
import debounce from 'lodash/debounce';
import { useCallback } from 'react';
import {
RiHeartLine,
RiPauseFill,
RiPlayFill,
RiRepeat2Line,
RiRepeatOneLine,
RiShuffleFill,
RiSkipBackFill,
RiSkipForwardFill,
RiVolumeUpFill,
} from 'react-icons/ri';
import { RiPauseFill, RiPlayFill, RiVolumeUpFill } from 'react-icons/ri';
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
import { WrapperSlider } from '/@/remote/components/wrapped-slider';
import { PlayerImage } from '/@/remote/components/player-image';
import { WrappedSlider } from '/@/remote/components/wrapped-slider';
import { useInfo, useSend, useShowImage } from '/@/remote/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { Rating } from '/@/shared/components/rating/rating';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { PlayerRepeat, PlayerStatus } from '/@/shared/types/types';
@ -40,40 +32,115 @@ export const RemoteContainer = () => {
const debouncedSetRating = debounce(setRating, 400);
return (
<>
<Stack
gap="md"
h="100dvh"
w="100%"
>
{showImage && (
<Flex
align="center"
justify="center"
w="100%"
>
<PlayerImage src={song?.imageUrl} />
</Flex>
)}
{id && (
<>
<Title order={1}>{song.name}</Title>
<Group align="flex-end">
<Title order={2}>Album: {song.album}</Title>
<Title order={2}>Artist: {song.artistName}</Title>
</Group>
<Stack gap="xs">
<Text
fw={700}
size="xl"
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{song.name}
</Text>
<Text
isMuted
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{song.album}
</Text>
<Text
isMuted
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{song.artistName}
</Text>
<Group justify="space-between">
<Title order={3}>Duration: {formatDuration(song.duration)}</Title>
{song.releaseDate && (
<Title order={3}>
Released: {new Date(song.releaseDate).toLocaleDateString()}
</Title>
<Text isMuted>{new Date(song.releaseDate).toLocaleDateString()}</Text>
)}
<Title order={3}>Plays: {song.playCount}</Title>
<Text isMuted>Plays: {song.playCount}</Text>
</Group>
</>
</Stack>
)}
<Group
gap={0}
grow
>
<RemoteButton
<ActionIcon
disabled={!id}
icon="favorite"
iconProps={{
fill: song?.userFavorite ? 'primary' : 'default',
}}
onClick={() => {
if (!id) return;
send({ event: 'favorite', favorite: !song.userFavorite, id });
}}
tooltip={{
label: song?.userFavorite ? 'Unfavorite' : 'Favorite',
}}
variant="transparent"
/>
{(song?.serverType === 'navidrome' || song?.serverType === 'subsonic') && (
<div style={{ margin: 'auto' }}>
<Tooltip
label="Double click to clear"
openDelay={1000}
>
<Rating
onChange={debouncedSetRating}
onDoubleClick={() => debouncedSetRating(0)}
style={{ margin: 'auto' }}
value={song.userRating ?? 0}
/>
</Tooltip>
</div>
)}
</Group>
<Group
gap="xs"
grow
>
<ActionIcon
disabled={!id}
icon="mediaPrevious"
iconProps={{
fill: 'default',
size: 'lg',
}}
onClick={() => send({ event: 'previous' })}
tooltip={{
label: 'Previous track',
}}
variant="default"
>
<RiSkipBackFill size={25} />
</RemoteButton>
<RemoteButton
/>
<ActionIcon
disabled={!id}
onClick={() => {
if (status === PlayerStatus.PLAYING) {
@ -92,34 +159,50 @@ export const RemoteContainer = () => {
) : (
<RiPlayFill size={25} />
)}
</RemoteButton>
<RemoteButton
</ActionIcon>
<ActionIcon
disabled={!id}
icon="mediaNext"
iconProps={{
fill: 'default',
size: 'lg',
}}
onClick={() => send({ event: 'next' })}
tooltip={{
label: 'Next track',
}}
variant="default"
>
<RiSkipForwardFill size={25} />
</RemoteButton>
/>
</Group>
<Group
gap={0}
gap="xs"
grow
>
<RemoteButton
isActive={shuffle || false}
<ActionIcon
icon="mediaShuffle"
iconProps={{
fill: shuffle ? 'primary' : 'default',
size: 'lg',
}}
onClick={() => send({ event: 'shuffle' })}
tooltip={{
label: shuffle ? 'Shuffle tracks' : 'Shuffle disabled',
}}
variant="default"
>
<RiShuffleFill size={25} />
</RemoteButton>
<RemoteButton
isActive={repeat !== undefined && repeat !== PlayerRepeat.NONE}
/>
<ActionIcon
icon={
repeat === undefined || repeat === PlayerRepeat.ONE
? 'mediaRepeatOne'
: 'mediaRepeat'
}
iconProps={{
fill:
repeat !== undefined && repeat !== PlayerRepeat.NONE
? 'primary'
: 'default',
size: 'lg',
}}
onClick={() => send({ event: 'repeat' })}
tooltip={{
label: `Repeat ${
@ -131,74 +214,34 @@ export const RemoteContainer = () => {
}`,
}}
variant="default"
>
{repeat === undefined || repeat === PlayerRepeat.ONE ? (
<RiRepeatOneLine size={25} />
) : (
<RiRepeat2Line size={25} />
)}
</RemoteButton>
<RemoteButton
disabled={!id}
isActive={song?.userFavorite}
onClick={() => {
if (!id) return;
send({ event: 'favorite', favorite: !song.userFavorite, id });
}}
tooltip={{
label: song?.userFavorite ? 'Unfavorite' : 'Favorite',
}}
variant="default"
>
<RiHeartLine size={25} />
</RemoteButton>
{(song?.serverType === 'navidrome' || song?.serverType === 'subsonic') && (
<div style={{ margin: 'auto' }}>
<Tooltip
label="Double click to clear"
openDelay={1000}
>
<Rating
onChange={debouncedSetRating}
onDoubleClick={() => debouncedSetRating(0)}
style={{ margin: 'auto' }}
value={song.userRating ?? 0}
/>
</Tooltip>
</div>
)}
/>
</Group>
{id && position !== undefined && (
<WrapperSlider
label={(value) => formatDuration(value * 1e3)}
leftLabel={formatDuration(position * 1e3)}
max={song.duration / 1e3}
onChangeEnd={(e) => send({ event: 'position', position: e })}
rightLabel={formatDuration(song.duration)}
value={position}
<Stack gap="lg">
{id && position !== undefined && (
<WrappedSlider
label={(value) => formatDuration(value * 1e3)}
leftLabel={formatDuration(position * 1e3)}
max={song.duration / 1e3}
onChangeEnd={(e) => send({ event: 'position', position: e })}
rightLabel={formatDuration(song.duration)}
value={position}
/>
)}
<WrappedSlider
leftLabel={<RiVolumeUpFill size={20} />}
max={100}
onChangeEnd={(e) => send({ event: 'volume', volume: e })}
rightLabel={
<Text
fw={600}
size="xs"
>
{volume ?? 0}
</Text>
}
value={volume ?? 0}
/>
)}
<WrapperSlider
leftLabel={<RiVolumeUpFill size={20} />}
max={100}
onChangeEnd={(e) => send({ event: 'volume', volume: e })}
rightLabel={
<Text
fw={600}
size="xs"
>
{volume ?? 0}
</Text>
}
value={volume ?? 0}
/>
{showImage && (
<Image
onError={() => send({ event: 'proxy' })}
src={song?.imageUrl?.replaceAll(/&(size|width|height=\d+)/g, '')}
/>
)}
</>
</Stack>
</Stack>
);
};

View file

@ -1,54 +1,71 @@
import { AppShell, Container, Flex, Grid, Image, Skeleton, Title } from '@mantine/core';
import { AppShell, Flex, Grid, Image } from '@mantine/core';
import { ImageButton } from '/@/remote/components/buttons/image-button';
import { ReconnectButton } from '/@/remote/components/buttons/reconnect-button';
import { ThemeButton } from '/@/remote/components/buttons/theme-button';
import { RemoteContainer } from '/@/remote/components/remote-container';
import { useConnected } from '/@/remote/store';
import { Center } from '/@/shared/components/center/center';
import { Group } from '/@/shared/components/group/group';
import { Spinner } from '/@/shared/components/spinner/spinner';
export const Shell = () => {
const connected = useConnected();
return (
<AppShell padding="md">
<AppShell.Header>
<Grid>
<Grid.Col span="auto">
<div>
<AppShell
h="100vh"
padding="md"
w="100vw"
>
<AppShell.Header style={{ background: 'var(--theme-colors-surface)' }}>
<Grid
px="md"
py="sm"
>
<Grid.Col span={4}>
<Flex
align="center"
direction="row"
h="100%"
justify="flex-start"
style={{
justifySelf: 'flex-start',
}}
>
<Image
fit="contain"
height={60}
height={32}
src="/favicon.ico"
width={60}
width={32}
/>
</div>
</Flex>
</Grid.Col>
<Grid.Col hiddenFrom="md">
<Title ta="center">Feishin Remote</Title>
</Grid.Col>
<Grid.Col span="auto">
<Flex
direction="row"
justify="right"
<Grid.Col span={8}>
<Group
gap="sm"
justify="flex-end"
wrap="nowrap"
>
<ReconnectButton />
<ImageButton />
<ThemeButton />
</Flex>
</Group>
</Grid.Col>
</Grid>
</AppShell.Header>
<Container>
<AppShell.Main pt="60px">
{connected ? (
<RemoteContainer />
) : (
<Skeleton
height={300}
width="100%"
/>
<Center
h="100vh"
w="100vw"
>
<Spinner />
</Center>
)}
</Container>
</AppShell.Main>
</AppShell>
);
};

View file

@ -1,21 +0,0 @@
.container {
display: flex;
width: 95%;
height: 20px;
margin: 10px 0;
}
.wrapper {
display: flex;
flex: 6;
align-items: center;
height: 100%;
}
.value-wrapper {
display: flex;
flex: 1;
align-self: flex-end;
justify-content: center;
max-width: 50px;
}

View file

@ -1,7 +1,8 @@
import { rem, Slider, SliderProps } from '@mantine/core';
import { ReactNode, useState } from 'react';
import styles from './wrapped-slider.module.css';
import { Group } from '/@/shared/components/group/group';
import { Text } from '/@/shared/components/text/text';
const PlayerbarSlider = ({ ...props }: SliderProps) => {
return (
@ -37,6 +38,7 @@ const PlayerbarSlider = ({ ...props }: SliderProps) => {
'&::before': {
right: 'calc(0.1rem * -1)',
},
height: '1rem',
},
}}
{...props}
@ -54,31 +56,32 @@ export interface WrappedProps extends Omit<SliderProps, 'onChangeEnd'> {
value: number;
}
export const WrapperSlider = ({ leftLabel, rightLabel, value, ...props }: WrappedProps) => {
export const WrappedSlider = ({ leftLabel, rightLabel, value, ...props }: WrappedProps) => {
const [isSeeking, setIsSeeking] = useState(false);
const [seek, setSeek] = useState(0);
return (
<div className={styles.container}>
{leftLabel && <div className={styles.valueWrapper}>{leftLabel}</div>}
<div className={styles.wrapper}>
<PlayerbarSlider
{...props}
min={0}
onChange={(e) => {
setIsSeeking(true);
setSeek(e);
}}
onChangeEnd={(e) => {
props.onChangeEnd(e);
setIsSeeking(false);
}}
size={6}
value={!isSeeking ? (value ?? 0) : seek}
w="100%"
/>
</div>
{rightLabel && <div className={styles.valueWrapper}>{rightLabel}</div>}
</div>
<Group
align="center"
wrap="nowrap"
>
{leftLabel && <Text size="sm">{leftLabel}</Text>}
<PlayerbarSlider
{...props}
min={0}
onChange={(e) => {
setIsSeeking(true);
setSeek(e);
}}
onChangeEnd={(e) => {
props.onChangeEnd(e);
setIsSeeking(false);
}}
size={6}
value={!isSeeking ? (value ?? 0) : seek}
w="100%"
/>
{rightLabel && <Text size="sm">{rightLabel}</Text>}
</Group>
);
};