mirror of
https://github.com/antebudimir/feishin.git
synced 2026-01-01 02:13:33 +00:00
Add remote control (#164)
* draft add remotes * add favorite, rating * add basic auth
This commit is contained in:
parent
0a13d047bb
commit
c9dbf9b5be
66 changed files with 2585 additions and 298 deletions
85
src/remote/app.tsx
Normal file
85
src/remote/app.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { useEffect } from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import './styles/global.scss';
|
||||
import '@ag-grid-community/styles/ag-grid.css';
|
||||
import { useIsDark, useReconnect } from '/@/remote/store';
|
||||
import { Shell } from '/@/remote/components/shell';
|
||||
|
||||
export const App = () => {
|
||||
const isDark = useIsDark();
|
||||
const reconnect = useReconnect();
|
||||
|
||||
useEffect(() => {
|
||||
reconnect();
|
||||
}, [reconnect]);
|
||||
|
||||
return (
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
theme={{
|
||||
colorScheme: isDark ? 'dark' : 'light',
|
||||
components: {
|
||||
AppShell: {
|
||||
styles: {
|
||||
body: {
|
||||
height: '100vh',
|
||||
overflow: 'scroll',
|
||||
},
|
||||
},
|
||||
},
|
||||
Modal: {
|
||||
styles: {
|
||||
body: {
|
||||
background: 'var(--modal-bg)',
|
||||
height: '100vh',
|
||||
},
|
||||
close: { marginRight: '0.5rem' },
|
||||
content: { borderRadius: '5px' },
|
||||
header: {
|
||||
background: 'var(--modal-header-bg)',
|
||||
paddingBottom: '1rem',
|
||||
},
|
||||
title: { fontSize: 'medium', fontWeight: 500 },
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultRadius: 'xs',
|
||||
dir: 'ltr',
|
||||
focusRing: 'auto',
|
||||
focusRingStyles: {
|
||||
inputStyles: () => ({
|
||||
border: '1px solid var(--primary-color)',
|
||||
}),
|
||||
resetStyles: () => ({ outline: 'none' }),
|
||||
styles: () => ({
|
||||
outline: '1px solid var(--primary-color)',
|
||||
outlineOffset: '-1px',
|
||||
}),
|
||||
},
|
||||
fontFamily: 'var(--content-font-family)',
|
||||
fontSizes: {
|
||||
lg: '1.1rem',
|
||||
md: '1rem',
|
||||
sm: '0.9rem',
|
||||
xl: '1.5rem',
|
||||
xs: '0.8rem',
|
||||
},
|
||||
headings: {
|
||||
fontFamily: 'var(--content-font-family)',
|
||||
fontWeight: 700,
|
||||
},
|
||||
other: {},
|
||||
spacing: {
|
||||
lg: '2rem',
|
||||
md: '1rem',
|
||||
sm: '0.5rem',
|
||||
xl: '4rem',
|
||||
xs: '0rem',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Shell />
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
20
src/remote/components/buttons/image-button.tsx
Normal file
20
src/remote/components/buttons/image-button.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { CiImageOff, CiImageOn } from 'react-icons/ci';
|
||||
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
|
||||
import { useShowImage, useToggleShowImage } from '/@/remote/store';
|
||||
|
||||
export const ImageButton = () => {
|
||||
const showImage = useShowImage();
|
||||
const toggleImage = useToggleShowImage();
|
||||
|
||||
return (
|
||||
<RemoteButton
|
||||
mr={5}
|
||||
size="xl"
|
||||
tooltip={showImage ? 'Hide Image' : 'Show Image'}
|
||||
variant="default"
|
||||
onClick={() => toggleImage()}
|
||||
>
|
||||
{showImage ? <CiImageOff size={30} /> : <CiImageOn size={30} />}
|
||||
</RemoteButton>
|
||||
);
|
||||
};
|
||||
21
src/remote/components/buttons/reconnect-button.tsx
Normal file
21
src/remote/components/buttons/reconnect-button.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
|
||||
import { useConnected, useReconnect } from '/@/remote/store';
|
||||
import { RiRestartLine } from 'react-icons/ri';
|
||||
|
||||
export const ReconnectButton = () => {
|
||||
const connected = useConnected();
|
||||
const reconnect = useReconnect();
|
||||
|
||||
return (
|
||||
<RemoteButton
|
||||
$active={!connected}
|
||||
mr={5}
|
||||
size="xl"
|
||||
tooltip={connected ? 'Reconnect' : 'Not connected. Reconnect.'}
|
||||
variant="default"
|
||||
onClick={() => reconnect()}
|
||||
>
|
||||
<RiRestartLine size={30} />
|
||||
</RemoteButton>
|
||||
);
|
||||
};
|
||||
60
src/remote/components/buttons/remote-button.tsx
Normal file
60
src/remote/components/buttons/remote-button.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { Ref, forwardRef } from 'react';
|
||||
import { Button, type ButtonProps as MantineButtonProps } from '@mantine/core';
|
||||
import { Tooltip } from '/@/renderer/components/tooltip';
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface StyledButtonProps extends MantineButtonProps {
|
||||
$active?: boolean;
|
||||
children: React.ReactNode;
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
onMouseDown?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
ref: Ref<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
export interface ButtonProps extends StyledButtonProps {
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
const StyledButton = styled(Button)<StyledButtonProps>`
|
||||
svg {
|
||||
display: flex;
|
||||
fill: ${({ $active: active }) =>
|
||||
active ? 'var(--primary-color)' : 'var(--playerbar-btn-fg)'};
|
||||
stroke: var(--playerbar-btn-fg);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--playerbar-btn-bg-hover);
|
||||
|
||||
svg {
|
||||
fill: ${({ $active: active }) =>
|
||||
active
|
||||
? 'var(--primary-color) !important'
|
||||
: 'var(--playerbar-btn-fg-hover) !important'};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const RemoteButton = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ children, tooltip, ...props }: ButtonProps, ref) => {
|
||||
return (
|
||||
<Tooltip
|
||||
withinPortal
|
||||
label={tooltip}
|
||||
>
|
||||
<StyledButton
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</StyledButton>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RemoteButton.defaultProps = {
|
||||
$active: false,
|
||||
onClick: undefined,
|
||||
onMouseDown: undefined,
|
||||
};
|
||||
27
src/remote/components/buttons/theme-button.tsx
Normal file
27
src/remote/components/buttons/theme-button.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useIsDark, useToggleDark } from '/@/remote/store';
|
||||
import { RiMoonLine, RiSunLine } from 'react-icons/ri';
|
||||
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
|
||||
import { AppTheme } from '/@/renderer/themes/types';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
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]);
|
||||
|
||||
return (
|
||||
<RemoteButton
|
||||
mr={5}
|
||||
size="xl"
|
||||
tooltip="Toggle Theme"
|
||||
variant="default"
|
||||
onClick={() => toggleDark()}
|
||||
>
|
||||
{isDark ? <RiSunLine size={30} /> : <RiMoonLine size={30} />}
|
||||
</RemoteButton>
|
||||
);
|
||||
};
|
||||
175
src/remote/components/remote-container.tsx
Normal file
175
src/remote/components/remote-container.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { useCallback } from 'react';
|
||||
import { Group, Image, Rating, Text, Title } from '@mantine/core';
|
||||
import { useInfo, useSend, useShowImage } from '/@/remote/store';
|
||||
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
|
||||
import formatDuration from 'format-duration';
|
||||
import debounce from 'lodash/debounce';
|
||||
import {
|
||||
RiHeartLine,
|
||||
RiPauseFill,
|
||||
RiPlayFill,
|
||||
RiRepeat2Line,
|
||||
RiRepeatOneLine,
|
||||
RiShuffleFill,
|
||||
RiSkipBackFill,
|
||||
RiSkipForwardFill,
|
||||
RiVolumeUpFill,
|
||||
} from 'react-icons/ri';
|
||||
import { PlayerRepeat, PlayerStatus } from '/@/renderer/types';
|
||||
import { WrapperSlider } from '/@/remote/components/wrapped-slider';
|
||||
import { Tooltip } from '/@/renderer/components/tooltip';
|
||||
|
||||
export const RemoteContainer = () => {
|
||||
const { repeat, shuffle, song, status, volume } = useInfo();
|
||||
const send = useSend();
|
||||
const showImage = useShowImage();
|
||||
|
||||
const id = song?.id;
|
||||
|
||||
const setRating = useCallback(
|
||||
(rating: number) => {
|
||||
send({ event: 'rating', id: id!, rating });
|
||||
},
|
||||
[send, id],
|
||||
);
|
||||
|
||||
const debouncedSetRating = debounce(setRating, 400);
|
||||
|
||||
return (
|
||||
<>
|
||||
{song && (
|
||||
<>
|
||||
<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>
|
||||
<Group position="apart">
|
||||
<Title order={3}>Duration: {formatDuration(song.duration * 1000)}</Title>
|
||||
{song.releaseDate && (
|
||||
<Title order={3}>
|
||||
Released: {new Date(song.releaseDate).toLocaleDateString()}
|
||||
</Title>
|
||||
)}
|
||||
<Title order={3}>Plays: {song.playCount}</Title>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
<Group
|
||||
grow
|
||||
spacing={0}
|
||||
>
|
||||
<RemoteButton
|
||||
tooltip="Previous track"
|
||||
variant="default"
|
||||
onClick={() => send({ event: 'previous' })}
|
||||
>
|
||||
<RiSkipBackFill size={25} />
|
||||
</RemoteButton>
|
||||
<RemoteButton
|
||||
tooltip={status === PlayerStatus.PLAYING ? 'Pause' : 'Play'}
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
if (status === PlayerStatus.PLAYING) {
|
||||
send({ event: 'pause' });
|
||||
} else if (status === PlayerStatus.PAUSED) {
|
||||
send({ event: 'play' });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{status === PlayerStatus.PLAYING ? (
|
||||
<RiPauseFill size={25} />
|
||||
) : (
|
||||
<RiPlayFill size={25} />
|
||||
)}
|
||||
</RemoteButton>
|
||||
<RemoteButton
|
||||
tooltip="Next track"
|
||||
variant="default"
|
||||
onClick={() => send({ event: 'next' })}
|
||||
>
|
||||
<RiSkipForwardFill size={25} />
|
||||
</RemoteButton>
|
||||
</Group>
|
||||
<Group
|
||||
grow
|
||||
spacing={0}
|
||||
>
|
||||
<RemoteButton
|
||||
$active={shuffle || false}
|
||||
tooltip={shuffle ? 'Shuffle tracks' : 'Shuffle disabled'}
|
||||
variant="default"
|
||||
onClick={() => send({ event: 'shuffle' })}
|
||||
>
|
||||
<RiShuffleFill size={25} />
|
||||
</RemoteButton>
|
||||
<RemoteButton
|
||||
$active={repeat !== undefined && repeat !== PlayerRepeat.NONE}
|
||||
tooltip={`Repeat ${
|
||||
repeat === PlayerRepeat.ONE
|
||||
? 'One'
|
||||
: repeat === PlayerRepeat.ALL
|
||||
? 'all'
|
||||
: 'none'
|
||||
}`}
|
||||
variant="default"
|
||||
onClick={() => send({ event: 'repeat' })}
|
||||
>
|
||||
{repeat === undefined || repeat === PlayerRepeat.ONE ? (
|
||||
<RiRepeatOneLine size={25} />
|
||||
) : (
|
||||
<RiRepeat2Line size={25} />
|
||||
)}
|
||||
</RemoteButton>
|
||||
<RemoteButton
|
||||
$active={song?.userFavorite}
|
||||
disabled={!song}
|
||||
tooltip={song?.userFavorite ? 'Unfavorite' : 'Favorite'}
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
if (!id) return;
|
||||
|
||||
send({ event: 'favorite', favorite: !song.userFavorite, id });
|
||||
}}
|
||||
>
|
||||
<RiHeartLine size={25} />
|
||||
</RemoteButton>
|
||||
{(song?.serverType === 'navidrome' || song?.serverType === 'subsonic') && (
|
||||
<div style={{ margin: 'auto' }}>
|
||||
<Tooltip
|
||||
label="Double click to clear"
|
||||
openDelay={1000}
|
||||
>
|
||||
<Rating
|
||||
sx={{ margin: 'auto' }}
|
||||
value={song.userRating ?? 0}
|
||||
onChange={debouncedSetRating}
|
||||
onDoubleClick={() => debouncedSetRating(0)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
<WrapperSlider
|
||||
leftLabel={<RiVolumeUpFill size={20} />}
|
||||
max={100}
|
||||
rightLabel={
|
||||
<Text
|
||||
size="xs"
|
||||
weight={600}
|
||||
>
|
||||
{volume ?? 0}
|
||||
</Text>
|
||||
}
|
||||
value={volume ?? 0}
|
||||
onChangeEnd={(e) => send({ event: 'volume', volume: e })}
|
||||
/>
|
||||
{showImage && (
|
||||
<Image
|
||||
src={song?.imageUrl?.replaceAll(/&(size|width|height=\d+)/g, '')}
|
||||
onError={() => send({ event: 'proxy' })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
76
src/remote/components/shell.tsx
Normal file
76
src/remote/components/shell.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import {
|
||||
AppShell,
|
||||
Container,
|
||||
Flex,
|
||||
Grid,
|
||||
Header,
|
||||
Image,
|
||||
MediaQuery,
|
||||
Skeleton,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { ThemeButton } from '/@/remote/components/buttons/theme-button';
|
||||
import { ImageButton } from '/@/remote/components/buttons/image-button';
|
||||
import { RemoteContainer } from '/@/remote/components/remote-container';
|
||||
import { ReconnectButton } from '/@/remote/components/buttons/reconnect-button';
|
||||
import { useConnected } from '/@/remote/store';
|
||||
|
||||
export const Shell = () => {
|
||||
const connected = useConnected();
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={
|
||||
<Header height={60}>
|
||||
<Grid>
|
||||
<Grid.Col span="auto">
|
||||
<div>
|
||||
<Image
|
||||
bg="rgb(25, 25, 25)"
|
||||
fit="contain"
|
||||
height={60}
|
||||
src="/favicon.ico"
|
||||
width={60}
|
||||
/>
|
||||
</div>
|
||||
</Grid.Col>
|
||||
<MediaQuery
|
||||
smallerThan="sm"
|
||||
styles={{ display: 'none' }}
|
||||
>
|
||||
<Grid.Col
|
||||
sm={6}
|
||||
xs={0}
|
||||
>
|
||||
<Title ta="center">Feishin Remote</Title>
|
||||
</Grid.Col>
|
||||
</MediaQuery>
|
||||
|
||||
<Grid.Col span="auto">
|
||||
<Flex
|
||||
direction="row"
|
||||
justify="right"
|
||||
>
|
||||
<ReconnectButton />
|
||||
<ImageButton />
|
||||
<ThemeButton />
|
||||
</Flex>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Header>
|
||||
}
|
||||
padding="md"
|
||||
>
|
||||
<Container>
|
||||
{connected ? (
|
||||
<RemoteContainer />
|
||||
) : (
|
||||
<Skeleton
|
||||
height={300}
|
||||
width="100%"
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</AppShell>
|
||||
);
|
||||
};
|
||||
62
src/remote/components/wrapped-slider.tsx
Normal file
62
src/remote/components/wrapped-slider.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { useState } from 'react';
|
||||
import { SliderProps } from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
|
||||
|
||||
const SliderContainer = styled.div`
|
||||
display: flex;
|
||||
width: 95%;
|
||||
height: 20px;
|
||||
margin: 10px 0px;
|
||||
`;
|
||||
|
||||
const SliderValueWrapper = styled.div<{ position: 'left' | 'right' }>`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-self: flex-end;
|
||||
justify-content: center;
|
||||
max-width: 50px;
|
||||
`;
|
||||
|
||||
const SliderWrapper = styled.div`
|
||||
display: flex;
|
||||
flex: 6;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
export interface WrappedProps extends Omit<SliderProps, 'onChangeEnd'> {
|
||||
leftLabel?: JSX.Element;
|
||||
onChangeEnd: (value: number) => void;
|
||||
rightLabel?: JSX.Element;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export const WrapperSlider = ({ leftLabel, rightLabel, value, ...props }: WrappedProps) => {
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const [seek, setSeek] = useState(0);
|
||||
|
||||
return (
|
||||
<SliderContainer>
|
||||
{leftLabel && <SliderValueWrapper position="left">{leftLabel}</SliderValueWrapper>}
|
||||
<SliderWrapper>
|
||||
<PlayerbarSlider
|
||||
{...props}
|
||||
min={0}
|
||||
size={6}
|
||||
value={!isSeeking ? value ?? 0 : seek}
|
||||
w="100%"
|
||||
onChange={(e) => {
|
||||
setIsSeeking(true);
|
||||
setSeek(e);
|
||||
}}
|
||||
onChangeEnd={(e) => {
|
||||
props.onChangeEnd(e);
|
||||
setIsSeeking(false);
|
||||
}}
|
||||
/>
|
||||
</SliderWrapper>
|
||||
{rightLabel && <SliderValueWrapper position="right">{rightLabel}</SliderValueWrapper>}
|
||||
</SliderContainer>
|
||||
);
|
||||
};
|
||||
15
src/remote/index.ejs
Normal file
15
src/remote/index.ejs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-Security-Policy" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Feishin Remote</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
16
src/remote/index.tsx
Normal file
16
src/remote/index.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Notifications } from '@mantine/notifications';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from '/@/remote/app';
|
||||
|
||||
const container = document.getElementById('root')! as HTMLElement;
|
||||
const root = createRoot(container);
|
||||
|
||||
root.render(
|
||||
<>
|
||||
<Notifications
|
||||
containerWidth="300px"
|
||||
position="bottom-center"
|
||||
/>
|
||||
<App />
|
||||
</>,
|
||||
);
|
||||
220
src/remote/store/index.ts
Normal file
220
src/remote/store/index.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { hideNotification, showNotification } from '@mantine/notifications';
|
||||
import type { NotificationProps as MantineNotificationProps } from '@mantine/notifications';
|
||||
import merge from 'lodash/merge';
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
import { ClientEvent, ServerEvent, SongUpdateSocket } from '/@/remote/types';
|
||||
|
||||
interface StatefulWebSocket extends WebSocket {
|
||||
natural: boolean;
|
||||
}
|
||||
|
||||
interface SettingsState {
|
||||
connected: boolean;
|
||||
info: Omit<SongUpdateSocket, 'currentTime'>;
|
||||
isDark: boolean;
|
||||
showImage: boolean;
|
||||
socket?: StatefulWebSocket;
|
||||
}
|
||||
|
||||
export interface SettingsSlice extends SettingsState {
|
||||
actions: {
|
||||
reconnect: () => void;
|
||||
send: (data: ClientEvent) => void;
|
||||
toggleIsDark: () => void;
|
||||
toggleShowImage: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
const initialState: SettingsState = {
|
||||
connected: false,
|
||||
info: {},
|
||||
isDark: window.matchMedia('(prefers-color-scheme: dark)').matches,
|
||||
showImage: true,
|
||||
};
|
||||
|
||||
interface NotificationProps extends MantineNotificationProps {
|
||||
type?: 'error' | 'warning';
|
||||
}
|
||||
|
||||
const showToast = ({ type, ...props }: NotificationProps) => {
|
||||
const color = type === 'warning' ? 'var(--warning-color)' : 'var(--danger-color)';
|
||||
|
||||
const defaultTitle = type === 'warning' ? 'Warning' : 'Error';
|
||||
|
||||
const defaultDuration = type === 'error' ? 2000 : 1000;
|
||||
|
||||
return showNotification({
|
||||
autoClose: defaultDuration,
|
||||
styles: () => ({
|
||||
closeButton: {
|
||||
'&:hover': {
|
||||
background: 'transparent',
|
||||
},
|
||||
},
|
||||
description: {
|
||||
color: 'var(--toast-description-fg)',
|
||||
fontSize: '1rem',
|
||||
},
|
||||
loader: {
|
||||
margin: '1rem',
|
||||
},
|
||||
root: {
|
||||
'&::before': { backgroundColor: color },
|
||||
background: 'var(--toast-bg)',
|
||||
border: '2px solid var(--generic-border-color)',
|
||||
bottom: '90px',
|
||||
},
|
||||
title: {
|
||||
color: 'var(--toast-title-fg)',
|
||||
fontSize: '1.3rem',
|
||||
},
|
||||
}),
|
||||
title: defaultTitle,
|
||||
...props,
|
||||
});
|
||||
};
|
||||
|
||||
const toast = {
|
||||
error: (props: NotificationProps) => showToast({ type: 'error', ...props }),
|
||||
hide: hideNotification,
|
||||
warn: (props: NotificationProps) => showToast({ type: 'warning', ...props }),
|
||||
};
|
||||
|
||||
export const useRemoteStore = create<SettingsSlice>()(
|
||||
persist(
|
||||
devtools(
|
||||
immer((set, get) => ({
|
||||
actions: {
|
||||
reconnect: () => {
|
||||
const existing = get().socket;
|
||||
|
||||
if (existing) {
|
||||
if (
|
||||
existing.readyState === WebSocket.OPEN ||
|
||||
existing.readyState === WebSocket.CONNECTING
|
||||
) {
|
||||
existing.natural = true;
|
||||
existing.close(4001);
|
||||
}
|
||||
}
|
||||
set((state) => {
|
||||
const socket = new WebSocket(
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
location.href.replace('http', 'ws'),
|
||||
) as StatefulWebSocket;
|
||||
|
||||
socket.natural = false;
|
||||
|
||||
socket.addEventListener('message', (message) => {
|
||||
const { event, data } = JSON.parse(message.data) as ServerEvent;
|
||||
|
||||
switch (event) {
|
||||
case 'error': {
|
||||
toast.error({ message: data, title: 'Socket error' });
|
||||
break;
|
||||
}
|
||||
case 'favorite': {
|
||||
set((state) => {
|
||||
if (state.info.song?.id === data.id) {
|
||||
state.info.song.userFavorite = data.favorite;
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'proxy': {
|
||||
set((state) => {
|
||||
if (state.info.song) {
|
||||
state.info.song.imageUrl = `data:image/jpeg;base64,${data}`;
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'rating': {
|
||||
set((state) => {
|
||||
if (state.info.song?.id === data.id) {
|
||||
state.info.song.userRating = data.rating;
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'song': {
|
||||
set((nested) => {
|
||||
nested.info = { ...nested.info, ...data };
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
set({ connected: true });
|
||||
});
|
||||
|
||||
socket.addEventListener('close', (reason) => {
|
||||
if (reason.code === 4002 || reason.code === 4003) {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
location.reload();
|
||||
} else if (reason.code === 4000) {
|
||||
toast.warn({
|
||||
message: 'Feishin remote server is down',
|
||||
title: 'Connection closed',
|
||||
});
|
||||
} else if (reason.code !== 4001 && !socket.natural) {
|
||||
toast.error({
|
||||
message: 'Socket closed for unexpected reason',
|
||||
title: 'Connection closed',
|
||||
});
|
||||
}
|
||||
|
||||
if (!socket.natural) {
|
||||
set({ connected: false, info: {} });
|
||||
}
|
||||
});
|
||||
|
||||
state.socket = socket;
|
||||
});
|
||||
},
|
||||
send: (data: ClientEvent) => {
|
||||
get().socket?.send(JSON.stringify(data));
|
||||
},
|
||||
toggleIsDark: () => {
|
||||
set((state) => {
|
||||
state.isDark = !state.isDark;
|
||||
});
|
||||
},
|
||||
toggleShowImage: () => {
|
||||
set((state) => {
|
||||
state.showImage = !state.showImage;
|
||||
});
|
||||
},
|
||||
},
|
||||
...initialState,
|
||||
})),
|
||||
{ name: 'store_settings' },
|
||||
),
|
||||
{
|
||||
merge: (persistedState, currentState) => {
|
||||
return merge(currentState, persistedState);
|
||||
},
|
||||
name: 'store_settings',
|
||||
version: 6,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const useConnected = () => useRemoteStore((state) => state.connected);
|
||||
|
||||
export const useInfo = () => useRemoteStore((state) => state.info);
|
||||
|
||||
export const useIsDark = () => useRemoteStore((state) => state.isDark);
|
||||
|
||||
export const useReconnect = () => useRemoteStore((state) => state.actions.reconnect);
|
||||
|
||||
export const useShowImage = () => useRemoteStore((state) => state.showImage);
|
||||
|
||||
export const useSend = () => useRemoteStore((state) => state.actions.send);
|
||||
|
||||
export const useToggleDark = () => useRemoteStore((state) => state.actions.toggleIsDark);
|
||||
|
||||
export const useToggleShowImage = () => useRemoteStore((state) => state.actions.toggleShowImage);
|
||||
127
src/remote/styles/global.scss
Normal file
127
src/remote/styles/global.scss
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
@use '../../renderer/themes/default.scss';
|
||||
@use '../../renderer/themes/dark.scss';
|
||||
@use '../../renderer/themes/light.scss';
|
||||
@use '../../renderer/styles/ag-grid.scss';
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
color: var(--content-text-color);
|
||||
background: var(--content-bg);
|
||||
font-family: var(--content-font-family);
|
||||
font-size: var(--root-font-size);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 639px) {
|
||||
body,
|
||||
html {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
height: inherit;
|
||||
}
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: border-box;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
-webkit-text-size-adjust: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: var(--scrollbar-track-bg);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--scrollbar-track-bg);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb-bg);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-bg-hover);
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.overlay-scrollbar {
|
||||
overflow-y: overlay !important;
|
||||
overflow-x: overlay !important;
|
||||
}
|
||||
|
||||
.hide-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none; /* Safari and Chrome */
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mantine-ScrollArea-thumb[data-state='visible'] {
|
||||
animation: fadeIn 0.3s forwards;
|
||||
}
|
||||
|
||||
.mantine-ScrollArea-scrollbar[data-state='hidden'] {
|
||||
animation: fadeOut 0.2s forwards;
|
||||
}
|
||||
56
src/remote/types.ts
Normal file
56
src/remote/types.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { QueueSong } from '/@/renderer/api/types';
|
||||
import type { SongUpdate } from '/@/renderer/types';
|
||||
|
||||
export interface SongUpdateSocket extends Omit<SongUpdate, 'song'> {
|
||||
song?: QueueSong | null;
|
||||
}
|
||||
|
||||
export interface ServerError {
|
||||
data: string;
|
||||
event: 'error';
|
||||
}
|
||||
|
||||
export interface ServerFavorite {
|
||||
data: { favorite: boolean; id: string };
|
||||
event: 'favorite';
|
||||
}
|
||||
|
||||
export interface ServerProxy {
|
||||
data: string;
|
||||
event: 'proxy';
|
||||
}
|
||||
|
||||
export interface ServerRating {
|
||||
data: { id: string; rating: number };
|
||||
event: 'rating';
|
||||
}
|
||||
|
||||
export interface ServerSong {
|
||||
data: SongUpdateSocket;
|
||||
event: 'song';
|
||||
}
|
||||
|
||||
export type ServerEvent = ServerError | ServerFavorite | ServerRating | ServerSong | ServerProxy;
|
||||
|
||||
export interface ClientSimpleEvent {
|
||||
event: 'next' | 'pause' | 'play' | 'previous' | 'proxy' | 'repeat' | 'shuffle';
|
||||
}
|
||||
|
||||
export interface ClientFavorite {
|
||||
event: 'favorite';
|
||||
favorite: boolean;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ClientRating {
|
||||
event: 'rating';
|
||||
id: string;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export interface ClientVolume {
|
||||
event: 'volume';
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export type ClientEvent = ClientSimpleEvent | ClientFavorite | ClientRating | ClientVolume;
|
||||
Loading…
Add table
Add a link
Reference in a new issue