Add remote control (#164)

* draft add remotes

* add favorite, rating

* add basic auth
This commit is contained in:
Kendall Garner 2023-07-23 12:23:18 +00:00 committed by GitHub
parent 0a13d047bb
commit c9dbf9b5be
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
66 changed files with 2585 additions and 298 deletions

85
src/remote/app.tsx Normal file
View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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,
};

View 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>
);
};

View 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' })}
/>
)}
</>
);
};

View 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>
);
};

View 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
View 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
View 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
View 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);

View 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
View 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;