Add playqueue table

This commit is contained in:
jeffvli
2022-11-19 20:30:57 -08:00
parent 4cb78bb656
commit d66c756af5
20 changed files with 1332 additions and 213 deletions
+1
View File
@@ -268,6 +268,7 @@ export type Song = {
serverId: string;
streamUrl: string;
trackNumber: number;
type: ServerType;
updatedAt: string;
};
+1
View File
@@ -19,3 +19,4 @@ export * from './slider';
export * from './accordion';
export * from './dropzone';
export * from './spinner';
export * from './virtual-table';
@@ -0,0 +1,43 @@
import React from 'react';
import { ICellRendererParams } from 'ag-grid-community';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import { AlbumArtist, Artist } from '@/renderer/api/types';
import { Text } from '@/renderer/components/text';
import { CellContainer } from '@/renderer/components/virtual-table/cells/generic-cell';
import { AppRoute } from '@/renderer/router/routes';
export const AlbumArtistCell = ({ value, data }: ICellRendererParams) => {
return (
<CellContainer position="left">
<Text $secondary overflow="hidden" size="xs">
{value?.map((item: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && (
<Text
$link
$secondary
size="xs"
style={{ display: 'inline-block' }}
>
,
</Text>
)}{' '}
<Text
$link
$secondary
component={Link}
overflow="hidden"
size="xs"
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, {
albumArtistId: item.id,
})}
>
{item.name || '—'}
</Text>
</React.Fragment>
))}
</Text>
</CellContainer>
);
};
@@ -0,0 +1,43 @@
import React from 'react';
import { ICellRendererParams } from 'ag-grid-community';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import { AlbumArtist, Artist } from '@/renderer/api/types';
import { Text } from '@/renderer/components/text';
import { CellContainer } from '@/renderer/components/virtual-table/cells/generic-cell';
import { AppRoute } from '@/renderer/router/routes';
export const ArtistCell = ({ value, data }: ICellRendererParams) => {
return (
<CellContainer position="left">
<Text $secondary overflow="hidden" size="xs">
{value?.map((item: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && (
<Text
$link
$secondary
size="xs"
style={{ display: 'inline-block' }}
>
,
</Text>
)}{' '}
<Text
$link
$secondary
component={Link}
overflow="hidden"
size="xs"
to={generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, {
artistId: item.id,
})}
>
{item.name || '—'}
</Text>
</React.Fragment>
))}
</Text>
</CellContainer>
);
};
@@ -0,0 +1,98 @@
import React, { useMemo } from 'react';
import { ICellRendererParams } from 'ag-grid-community';
import { motion } from 'framer-motion';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { AlbumArtist, Artist } from '@/renderer/api/types';
import { Text } from '@/renderer/components/text';
import { AppRoute } from '@/renderer/router/routes';
import { ServerType } from '@/renderer/types';
const CellContainer = styled(motion.div)<{ height: number }>`
display: grid;
grid-auto-columns: 1fr;
grid-template-areas: 'image info';
grid-template-rows: 1fr;
grid-template-columns: ${(props) => props.height}px minmax(0, 1fr);
gap: 0.5rem;
width: 100%;
max-width: 100%;
height: 100%;
`;
const ImageWrapper = styled.div`
display: flex;
grid-area: image;
align-items: center;
justify-content: center;
height: 100%;
`;
const MetadataWrapper = styled.div`
display: flex;
flex-direction: column;
grid-area: info;
justify-content: center;
width: 100%;
`;
const StyledImage = styled.img`
object-fit: cover;
`;
export const CombinedTitleCell = ({
value,
rowIndex,
node,
}: ICellRendererParams) => {
const artists = useMemo(() => {
return value.type === ServerType.JELLYFIN
? value.artists
: value.albumArtists;
}, [value]);
return (
<CellContainer height={node.rowHeight || 40}>
<ImageWrapper>
<StyledImage
alt="song-cover"
height={(node.rowHeight || 40) - 10}
loading="lazy"
src={value.imageUrl}
style={{}}
width={(node.rowHeight || 40) - 10}
/>
</ImageWrapper>
<MetadataWrapper>
<Text overflow="hidden" size="sm">
{value.name}
</Text>
<Text $secondary overflow="hidden" size="xs">
{artists?.length ? (
artists.map((artist: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`queue-${rowIndex}-artist-${artist.id}`}>
{index > 0 ? ', ' : null}
<Text
$link
$secondary
component={Link}
overflow="hidden"
size="xs"
sx={{ width: 'fit-content' }}
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
>
{artist.name}
</Text>
</React.Fragment>
))
) : (
<Text $secondary></Text>
)}
</Text>
</MetadataWrapper>
</CellContainer>
);
};
@@ -0,0 +1,59 @@
import { ICellRendererParams } from 'ag-grid-community';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { Text } from '@/renderer/components/text';
export const CellContainer = styled.div<{
position?: 'left' | 'center' | 'right';
}>`
display: flex;
align-items: center;
justify-content: ${(props) =>
props.position === 'right'
? 'flex-end'
: props.position === 'center'
? 'center'
: 'flex-start'};
width: 100%;
height: 100%;
`;
type Options = {
array?: boolean;
isArray?: boolean;
isLink?: boolean;
position?: 'left' | 'center' | 'right';
primary?: boolean;
};
export const GenericCell = (
{ value, valueFormatted }: ICellRendererParams,
{ position, primary, isLink }: Options
) => {
const displayedValue = valueFormatted || value;
return (
<CellContainer position={position || 'left'}>
{isLink ? (
<Text
$link={isLink}
$secondary={!primary}
component={Link}
overflow="hidden"
size="xs"
to={displayedValue.link}
>
{isLink ? displayedValue.value : displayedValue}
</Text>
) : (
<Text $secondary={!primary} overflow="hidden" size="xs">
{displayedValue}
</Text>
)}
</CellContainer>
);
};
GenericCell.defaultProps = {
position: undefined,
};
@@ -0,0 +1,39 @@
import React from 'react';
import { ICellRendererParams } from 'ag-grid-community';
import { Link } from 'react-router-dom';
import { AlbumArtist, Artist } from '@/renderer/api/types';
import { Text } from '@/renderer/components/text';
import { CellContainer } from '@/renderer/components/virtual-table/cells/generic-cell';
export const GenreCell = ({ value, data }: ICellRendererParams) => {
return (
<CellContainer position="left">
<Text $secondary overflow="hidden" size="xs">
{value?.map((item: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && (
<Text
$link
$secondary
size="xs"
style={{ display: 'inline-block' }}
>
,
</Text>
)}{' '}
<Text
$link
$secondary
component={Link}
overflow="hidden"
size="xs"
to="/"
>
{item.name || '—'}
</Text>
</React.Fragment>
))}
</Text>
</CellContainer>
);
};
@@ -0,0 +1,10 @@
import { IHeaderParams } from 'ag-grid-community';
import { FiClock } from 'react-icons/fi';
export interface ICustomHeaderParams extends IHeaderParams {
menuIcon: string;
}
export const DurationHeader = () => {
return <FiClock size={15} />;
};
@@ -0,0 +1,51 @@
import { ReactNode } from 'react';
import { IHeaderParams } from 'ag-grid-community';
import { FiClock } from 'react-icons/fi';
import styled from 'styled-components';
type Presets = 'duration';
type Options = {
children?: ReactNode;
position?: 'left' | 'center' | 'right';
preset?: Presets;
};
const HeaderWrapper = styled.div<{ position: 'left' | 'center' | 'right' }>`
display: flex;
justify-content: ${(props) =>
props.position === 'right'
? 'flex-end'
: props.position === 'center'
? 'center'
: 'flex-start'};
width: 100%;
font-family: var(--header-font-family);
text-transform: uppercase;
`;
const headerPresets = { duration: <FiClock size={15} /> };
export const GenericTableHeader = (
{ displayName }: IHeaderParams,
{ preset, children, position }: Options
) => {
if (preset) {
return (
<HeaderWrapper position={position || 'left'}>
{headerPresets[preset]}
</HeaderWrapper>
);
}
return (
<HeaderWrapper position={position || 'left'}>
{children || displayName}
</HeaderWrapper>
);
};
GenericTableHeader.defaultProps = {
position: 'left',
preset: undefined,
};
@@ -0,0 +1,210 @@
import { forwardRef, Ref, useRef } from 'react';
import { useClickOutside, useMergedRef } from '@mantine/hooks';
import {
ICellRendererParams,
ValueGetterParams,
IHeaderParams,
ValueFormatterParams,
ColDef,
} from 'ag-grid-community';
import { AgGridReact, AgGridReactProps } from 'ag-grid-react';
import type { AgGridReact as AgGridReactType } from 'ag-grid-react/lib/agGridReact';
import formatDuration from 'format-duration';
import { generatePath } from 'react-router';
import styled from 'styled-components';
import { AlbumArtistCell } from '@/renderer/components/virtual-table/cells/album-artist-cell';
import { ArtistCell } from '@/renderer/components/virtual-table/cells/artist-cell';
import { CombinedTitleCell } from '@/renderer/components/virtual-table/cells/combined-title-cell';
import { GenericCell } from '@/renderer/components/virtual-table/cells/generic-cell';
import { GenreCell } from '@/renderer/components/virtual-table/cells/genre-cell';
import { GenericTableHeader } from '@/renderer/components/virtual-table/headers/generic-table-header';
import { AppRoute } from '@/renderer/router/routes';
import { PersistedTableColumn } from '@/renderer/store/settings.store';
import { TableColumn } from '@/renderer/types';
export * from './table-config-dropdown';
const TableWrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
`;
const tableColumns: { [key: string]: ColDef } = {
album: {
cellRenderer: (params: ICellRendererParams) =>
GenericCell(params, { isLink: true, position: 'left' }),
colId: TableColumn.ALBUM,
field: 'album',
headerName: 'Album',
valueGetter: (params: ValueGetterParams) => ({
link: generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: params.data.album.id,
}),
value: params.data.album.name,
}),
},
albumArtist: {
cellRenderer: AlbumArtistCell,
colId: TableColumn.ALBUM_ARTIST,
headerName: 'Album Artist',
valueGetter: (params: ValueGetterParams) => params.data.album.albumArtists,
},
artist: {
cellRenderer: ArtistCell,
colId: TableColumn.ARTIST,
headerName: 'Artist',
valueGetter: (params: ValueGetterParams) => params.data.artists,
},
bitRate: {
cellRenderer: (params: ICellRendererParams) =>
GenericCell(params, { position: 'left' }),
colId: TableColumn.BIT_RATE,
field: 'bitRate',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'left' }),
valueFormatter: (params: ValueFormatterParams) => `${params.value} kbps`,
},
dateAdded: {
cellRenderer: (params: ICellRendererParams) =>
GenericCell(params, { position: 'left' }),
colId: TableColumn.DATE_ADDED,
field: 'createdAt',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'left' }),
headerName: 'Date Added',
valueFormatter: (params: ValueFormatterParams) =>
params.value?.split('T')[0],
},
discNumber: {
cellRenderer: (params: ICellRendererParams) =>
GenericCell(params, { position: 'center' }),
colId: TableColumn.DISC_NUMBER,
field: 'discNumber',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Disc',
initialWidth: 75,
suppressSizeToFit: true,
},
duration: {
cellRenderer: (params: ICellRendererParams) =>
GenericCell(params, { position: 'center' }),
colId: TableColumn.DURATION,
field: 'duration',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center', preset: 'duration' }),
initialWidth: 100,
valueFormatter: (params: ValueFormatterParams) =>
formatDuration(params.value * 1000),
},
genre: {
cellRenderer: GenreCell,
colId: TableColumn.GENRE,
headerName: 'Genre',
valueGetter: (params: ValueGetterParams) => params.data.genres,
},
releaseDate: {
cellRenderer: (params: ICellRendererParams) =>
GenericCell(params, { position: 'center' }),
colId: TableColumn.RELEASE_DATE,
field: 'releaseDate',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Release Date',
valueFormatter: (params: ValueFormatterParams) =>
params.value?.split('T')[0],
},
releaseYear: {
cellRenderer: (params: ICellRendererParams) =>
GenericCell(params, { position: 'center' }),
colId: TableColumn.YEAR,
field: 'releaseYear',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Year',
},
rowIndex: {
cellRenderer: (params: ICellRendererParams) =>
GenericCell(params, { position: 'left' }),
colId: TableColumn.ROW_INDEX,
headerName: '#',
initialWidth: 50,
suppressSizeToFit: true,
valueGetter: 'node.rowIndex + 1',
},
title: {
cellRenderer: (params: ICellRendererParams) =>
GenericCell(params, { position: 'left', primary: true }),
colId: TableColumn.TITLE,
field: 'name',
headerName: 'Title',
},
titleCombined: {
cellRenderer: CombinedTitleCell,
colId: TableColumn.TITLE_COMBINED,
headerName: 'Title',
initialWidth: 500,
valueGetter: (params: ValueGetterParams) => ({
albumArtists: params.data.album.albumArtists,
artists: params.data.artists,
imageUrl: params.data.imageUrl,
name: params.data.name,
rowHeight: params.node?.rowHeight,
type: params.data.type,
}),
},
trackNumber: {
cellRenderer: (params: ICellRendererParams) =>
GenericCell(params, { position: 'center' }),
colId: TableColumn.TRACK_NUMBER,
field: 'trackNumber',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Track',
initialWidth: 75,
suppressSizeToFit: true,
},
};
export const getColumnDef = (column: TableColumn) => {
return tableColumns[column as keyof typeof tableColumns];
};
export const getColumnDefs = (columns: PersistedTableColumn[]) => {
const columnDefs: any[] = [];
for (const column of columns) {
const columnExists =
tableColumns[column.column as keyof typeof tableColumns];
if (columnExists) columnDefs.push(columnExists);
}
return columnDefs;
};
export const VirtualTable = forwardRef(
({ ...rest }: AgGridReactProps, ref: Ref<AgGridReactType | null>) => {
const tableRef = useRef<AgGridReactType | null>(null);
const mergedRef = useMergedRef(ref, tableRef);
const tableContainerRef = useClickOutside(() => {
if (tableRef?.current) {
tableRef?.current.api.deselectAll();
}
});
return (
<TableWrapper ref={tableContainerRef} className="ag-theme-alpine-dark">
<AgGridReact
ref={mergedRef}
suppressMoveWhenRowDragging
suppressScrollOnNewData
rowBuffer={30}
{...rest}
/>
</TableWrapper>
);
}
);
@@ -0,0 +1,207 @@
import { ChangeEvent } from 'react';
import { Stack } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { motion, Variants } from 'framer-motion';
import { RiListSettingsLine } from 'react-icons/ri';
import styled from 'styled-components';
import { Button } from '@/renderer/components/button';
import { Popover } from '@/renderer/components/popover';
import { MultiSelect } from '@/renderer/components/select';
import { Slider } from '@/renderer/components/slider';
import { Switch } from '@/renderer/components/switch';
import { Text } from '@/renderer/components/text';
import { useSettingsStore } from '@/renderer/store/settings.store';
import { TableColumn, TableType } from '@/renderer/types';
export const tableColumns = [
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
{ label: 'Title', value: TableColumn.TITLE },
{ label: 'Title (Combined)', value: TableColumn.TITLE_COMBINED },
{ label: 'Duration', value: TableColumn.DURATION },
{ label: 'Album', value: TableColumn.ALBUM },
{ label: 'Album Artist', value: TableColumn.ALBUM_ARTIST },
{ label: 'Artist', value: TableColumn.ARTIST },
{ label: 'Genre', value: TableColumn.GENRE },
{ label: 'Year', value: TableColumn.YEAR },
{ label: 'Release Date', value: TableColumn.RELEASE_DATE },
{ label: 'Disc Number', value: TableColumn.DISC_NUMBER },
{ label: 'Track Number', value: TableColumn.TRACK_NUMBER },
{ label: 'Bitrate', value: TableColumn.BIT_RATE },
// { label: 'Size', value: TableColumn.SIZE },
// { label: 'Skip', value: TableColumn.SKIP },
// { label: 'Path', value: TableColumn.PATH },
// { label: 'Play Count', value: TableColumn.PLAY_COUNT },
// { label: 'Favorite', value: TableColumn.FAVORITE },
// { label: 'Rating', value: TableColumn.RATING },
{ label: 'Date Added', value: TableColumn.DATE_ADDED },
];
const Container = styled(motion.div)`
position: absolute;
right: 0;
bottom: 0;
z-index: 500;
`;
interface TableConfigDropdownProps {
type: TableType;
}
export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
const setSettings = useSettingsStore((state) => state.setSettings);
const tableConfig = useSettingsStore((state) => state.tables);
const [opened, handlers] = useDisclosure(false);
const containerVariants: Variants = {
animate: {
opacity: 0.2,
},
initial: {
opacity: 0,
},
};
const handleAddOrRemoveColumns = (values: TableColumn[]) => {
const existingColumns = tableConfig[type].columns;
if (values.length === 0) {
setSettings({
tables: {
...useSettingsStore.getState().tables,
[type]: {
...useSettingsStore.getState().tables[type],
columns: [],
},
},
});
return;
}
// If adding a column
if (values.length > existingColumns.length) {
const newColumn = { column: values[values.length - 1] };
setSettings({
tables: {
...useSettingsStore.getState().tables,
[type]: {
...useSettingsStore.getState().tables[type],
columns: [...existingColumns, newColumn],
},
},
});
}
// If removing a column
else {
const removed = existingColumns.filter(
(column) => !values.includes(column.column)
);
const newColumns = existingColumns.filter(
(column) => !removed.includes(column)
);
setSettings({
tables: {
...useSettingsStore.getState().tables,
[type]: {
...useSettingsStore.getState().tables[type],
columns: newColumns,
},
},
});
}
};
const handleUpdateRowHeight = (value: number) => {
setSettings({
tables: {
...useSettingsStore.getState().tables,
[type]: {
...useSettingsStore.getState().tables[type],
rowHeight: value,
},
},
});
};
const handleUpdateAutoFit = (e: ChangeEvent<HTMLInputElement>) => {
setSettings({
tables: {
...useSettingsStore.getState().tables,
[type]: {
...useSettingsStore.getState().tables[type],
autoFit: e.currentTarget.checked,
},
},
});
};
const handleUpdateFollow = (e: ChangeEvent<HTMLInputElement>) => {
setSettings({
tables: {
...useSettingsStore.getState().tables,
[type]: {
...useSettingsStore.getState().tables[type],
followCurrentSong: e.currentTarget.checked,
},
},
});
};
return (
<Container
animate="animate"
initial="initial"
variants={containerVariants}
whileHover={{ opacity: 1 }}
>
<Popover opened={opened} position="top-start" withArrow={false}>
<Popover.Target>
<Button compact variant="subtle" onClick={() => handlers.toggle()}>
<RiListSettingsLine size={20} />
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack p="1rem" spacing="xl">
<Stack spacing="xs">
<Text>Table Columns</Text>
<MultiSelect
clearable
data={tableColumns}
defaultValue={tableConfig[type]?.columns.map(
(column) => column.column
)}
dropdownPosition="top"
width={300}
onChange={handleAddOrRemoveColumns}
/>
</Stack>
<Stack spacing="xs">
<Text>Row Height</Text>
<Slider
defaultValue={tableConfig[type]?.rowHeight}
max={100}
min={25}
sx={{ width: 150 }}
onChangeEnd={handleUpdateRowHeight}
/>
</Stack>
<Stack spacing="xs">
<Text>Auto Fit Columns</Text>
<Switch
defaultChecked={tableConfig[type]?.autoFit}
onChange={handleUpdateAutoFit}
/>
</Stack>
<Stack spacing="xs">
<Text>Follow Current Song</Text>
<Switch
defaultChecked={tableConfig[type]?.followCurrentSong}
onChange={handleUpdateFollow}
/>
</Stack>
</Stack>
</Popover.Dropdown>
</Popover>
</Container>
);
};
@@ -0,0 +1,210 @@
import { useEffect, useMemo, useRef } from 'react';
import {
CellDoubleClickedEvent,
ColDef,
RowClassRules,
RowDragEvent,
} from 'ag-grid-community';
import 'ag-grid-community/styles/ag-theme-alpine.css';
import {
VirtualGridAutoSizerContainer,
VirtualGridContainer,
getColumnDefs,
TableConfigDropdown,
} from '@/renderer/components';
import { useAppStore, usePlayerStore } from '@/renderer/store';
import { useSettingsStore } from '@/renderer/store/settings.store';
import { QueueSong, TableType } from '@/renderer/types';
import { VirtualTable } from '../../../components/virtual-table';
import { mpvPlayer } from '../../player/utils/mpv-player';
type QueueProps = {
type: TableType;
};
export const PlayQueue = ({ type }: QueueProps) => {
const gridRef = useRef<any>(null);
const queue = usePlayerStore((state) => state.queue.default);
const reorderQueue = usePlayerStore((state) => state.reorderQueue);
const current = usePlayerStore((state) => state.getQueueData().current);
const previous = usePlayerStore((state) => state.queue.previousNode);
const setCurrentTrack = usePlayerStore((state) => state.setCurrentTrack);
const setSettings = useSettingsStore((state) => state.setSettings);
const setAppStore = useAppStore((state) => state.setAppStore);
const tableConfig = useSettingsStore((state) => state.tables[type]);
const columnDefs = getColumnDefs(tableConfig.columns);
const defaultColumnDefs: ColDef = useMemo(() => {
return {
lockPinned: true,
lockVisible: true,
resizable: true,
};
}, []);
const handlePlayByRowClick = (e: CellDoubleClickedEvent) => {
const playerData = setCurrentTrack(e.data.uniqueId);
mpvPlayer.setQueue(playerData);
};
const handleDragStart = () => {
if (type === 'sideDrawerQueue') {
setAppStore({ isReorderingQueue: true });
}
};
let timeout: any;
const handleDragEnd = (e: RowDragEvent<QueueSong>) => {
if (!e.nodes.length) return;
const selectedUniqueIds = e.nodes
.map((node) => node.data?.uniqueId)
.filter((e) => e !== undefined);
const playerData = reorderQueue(
selectedUniqueIds as string[],
e.overNode?.data?.uniqueId
);
mpvPlayer.setQueueNext(playerData);
if (type === 'sideDrawerQueue') {
setAppStore({ isReorderingQueue: false });
}
const { api } = gridRef?.current;
clearTimeout(timeout);
timeout = setTimeout(() => api.redrawRows(), 250);
};
const handleGridReady = () => {
if (tableConfig.followCurrentSong) {
const { api } = gridRef?.current;
const currentNode = api.getRowNode(current?.uniqueId);
api.ensureNodeVisible(currentNode, 'middle');
}
};
const handleColumnChange = () => {
const { columnApi } = gridRef?.current;
const columnsOrder = columnApi.getAllGridColumns();
const columnsInSettings = useSettingsStore.getState().tables[type].columns;
const updatedColumns = [];
for (const column of columnsOrder) {
const columnInSettings = columnsInSettings.find(
(c) => c.column === column.colId
);
if (columnInSettings) {
updatedColumns.push({
...columnInSettings,
...(!useSettingsStore.getState().tables[type].autoFit && {
width: column.actualWidth,
}),
});
}
}
setSettings({
tables: {
...useSettingsStore.getState().tables,
[type]: {
...useSettingsStore.getState().tables[type],
columns: updatedColumns,
},
},
});
};
const handleGridSizeChange = () => {
if (tableConfig.autoFit) {
gridRef.current.api.sizeColumnsToFit();
}
};
const rowClassRules = useMemo<RowClassRules>(() => {
return {
'current-song': (params) => {
return params.data.uniqueId === current?.uniqueId;
},
};
}, [current?.uniqueId]);
// Redraw the current song row when the previous song changes
useEffect(() => {
if (gridRef?.current) {
const { api, columnApi } = gridRef?.current;
if (api == null || columnApi == null) {
return;
}
const currentNode = api.getRowNode(current?.uniqueId);
const previousNode = api.getRowNode(previous?.uniqueId);
const rowNodes = [currentNode, previousNode];
if (rowNodes) {
api.redrawRows({ rowNodes });
if (tableConfig.followCurrentSong) {
api.ensureNodeVisible(currentNode, 'middle');
}
}
}
}, [current, previous, tableConfig.followCurrentSong]);
// Auto resize the columns when the column config changes
useEffect(() => {
if (tableConfig.autoFit) {
const { api } = gridRef?.current;
api?.sizeColumnsToFit();
}
}, [tableConfig.autoFit, tableConfig.columns]);
useEffect(() => {
const { api } = gridRef?.current;
api?.resetRowHeights();
api?.redrawRows();
}, [tableConfig.rowHeight]);
return (
<>
<VirtualGridContainer>
<VirtualGridAutoSizerContainer>
<VirtualTable
ref={gridRef}
alwaysShowHorizontalScroll
animateRows
maintainColumnOrder
rowDragEntireRow
rowDragMultiRow
suppressCopyRowsToClipboard
suppressMoveWhenRowDragging
suppressRowDrag
suppressScrollOnNewData
columnDefs={columnDefs}
defaultColDef={defaultColumnDefs}
enableCellChangeFlash={false}
getRowId={(data) => data.data.uniqueId}
rowBuffer={30}
rowClassRules={rowClassRules}
rowData={queue}
rowHeight={tableConfig.rowHeight || 40}
rowSelection="multiple"
// onCellClicked={(e) => console.log('clicked', e)}
// onCellContextMenu={(e) => console.log(e)}
onCellDoubleClicked={handlePlayByRowClick}
onColumnMoved={handleColumnChange}
onColumnResized={handleColumnChange}
onDragStarted={handleDragStart}
onGridReady={handleGridReady}
onGridSizeChanged={handleGridSizeChange}
onRowDragEnd={handleDragEnd}
/>
</VirtualGridAutoSizerContainer>
</VirtualGridContainer>
<TableConfigDropdown type={type} />
</>
);
};
@@ -1 +1,2 @@
export * from './routes/now-playing-route';
export * from './components/play-queue';
@@ -1,123 +1,20 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { ColDef, RowClassRules } from 'ag-grid-community';
import {
VirtualGridAutoSizerContainer,
VirtualGridContainer,
} from '@/renderer/components';
import { VirtualTable } from '@/renderer/components/virtual-table';
import { mpvPlayer } from '@/renderer/features/player/utils/mpvPlayer';
import { Box } from '@mantine/core';
import styled from 'styled-components';
import { PlayQueue } from '@/renderer/features/now-playing/components/play-queue';
import { AnimatedPage } from '@/renderer/features/shared';
import { usePlayerStore } from '@/renderer/store';
const selector = (state: any) => state.queue.default;
const QueueContainer = styled(Box)`
position: relative;
width: 100%;
height: 100%;
`;
export const NowPlayingRoute = () => {
const gridRef = useRef<any>(null);
const queue = usePlayerStore(selector);
const currentPlayerIndex = usePlayerStore((state) => state.current.index);
const current = usePlayerStore((state) => state.getQueueData().current);
const previous = usePlayerStore((state) => state.queue.previousNode);
const setCurrentIndex = usePlayerStore((state) => state.setCurrentIndex);
const [columnDefs] = useState<ColDef[]>([
{
field: 'index',
headerName: '-',
initialWidth: 50,
rowDrag: true,
suppressSizeToFit: true,
},
{
headerName: '#',
initialWidth: 50,
suppressSizeToFit: true,
valueGetter: 'node.rowIndex + 1',
},
{ field: 'name' },
{
field: 'duration',
initialWidth: 100,
suppressSizeToFit: true,
},
{ field: 'album.id', initialWidth: 100 },
]);
const defaultColumnDefs: ColDef = useMemo(() => {
return {
lockPinned: true,
lockVisible: true,
resizable: true,
};
}, []);
const rowClassRules = useMemo<RowClassRules>(() => {
return {
'current-song': (params) => {
return params.rowIndex === currentPlayerIndex;
},
};
}, [currentPlayerIndex]);
useEffect(() => {
const { api, columnApi } = gridRef.current;
if (api == null || columnApi == null) {
return;
}
const currentNode = api.getRowNode(current?.uniqueId);
const previousNode = api.getRowNode(previous?.uniqueId);
const rowNodes = [currentNode, previousNode];
if (rowNodes) {
api.redrawRows({ rowNodes });
api.ensureNodeVisible(currentNode, 'middle');
}
}, [current, previous]);
const handlePlayByRowClick = (e: any) => {
const playerData = setCurrentIndex(e.rowIndex);
mpvPlayer.setQueue(playerData);
};
return (
<AnimatedPage>
<VirtualGridContainer>
<VirtualGridAutoSizerContainer>
<VirtualTable
ref={gridRef}
rowDragEntireRow
rowDragManaged
rowDragMultiRow
suppressMoveWhenRowDragging
suppressScrollOnNewData
animateRows={false}
columnDefs={columnDefs}
defaultColDef={defaultColumnDefs}
enableCellChangeFlash={false}
getRowId={(data) => {
return data.data.uniqueId;
}}
rowBuffer={30}
rowClassRules={rowClassRules}
rowData={queue}
rowSelection="multiple"
onCellClicked={(e) => console.log('clicked', e)}
onCellContextMenu={(e) => console.log(e)}
onCellDoubleClicked={handlePlayByRowClick}
onDragStarted={(e) => {
console.log('ddrag move', e);
}}
onGridSizeChanged={() => {
console.log('size');
gridRef.current.api.sizeColumnsToFit();
}}
onRowDragEnd={(e) => {
console.log('dragend', e);
}}
/>
</VirtualGridAutoSizerContainer>
</VirtualGridContainer>
<QueueContainer>
<PlayQueue type="nowPlaying" />
</QueueContainer>
</AnimatedPage>
);
};
@@ -1,4 +1,3 @@
import { useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { api } from '@/renderer/api';
import { queryKeys } from '@/renderer/api/query-keys';
@@ -21,75 +20,64 @@ export const usePlayQueueHandler = () => {
const addToQueue = usePlayerStore((state) => state.addToQueue);
const playerType = useSettingsStore((state) => state.player.type);
const handlePlayQueueAdd = useCallback(
async (options: PlayQueueAddOptions) => {
if (options.byData) {
// dispatchSongsToQueue(options.byData, options.play);
const handlePlayQueueAdd = async (options: PlayQueueAddOptions) => {
if (options.byData) {
// dispatchSongsToQueue(options.byData, options.play);
}
if (options.byItemType) {
const deviceId = localStorage.getItem('device_id');
if (!deviceId || !options.byItemType.id) return;
let songs = null;
if (options.byItemType.type === LibraryItem.ALBUM) {
const albumDetail = await queryClient.fetchQuery(
queryKeys.albums.detail(options.byItemType.id),
async () =>
api.albums.getAlbumDetail({
albumId: options.byItemType!.id,
serverId,
})
);
songs = albumDetail.data.songs;
}
if (options.byItemType) {
const deviceId = localStorage.getItem('device_id');
if (!songs) return;
if (!deviceId || !options.byItemType.id) return;
// * Adds server token
if (serverToken) {
songs = songs.map((song) => {
return {
...song,
imageUrl:
song.imageUrl && isImageTokenRequired
? `${song.imageUrl}${serverToken}`
: song.imageUrl,
streamUrl: `${song.streamUrl}${serverToken}`,
};
});
}
let songs = null;
if (options.byItemType.type === LibraryItem.ALBUM) {
const albumDetail = await queryClient.fetchQuery(
queryKeys.albums.detail(options.byItemType.id),
async () =>
api.albums.getAlbumDetail({
albumId: options.byItemType!.id,
serverId,
})
);
const playerData = addToQueue(songs, options.play);
songs = albumDetail.data.songs;
}
if (!songs) return;
// * Adds server token
if (serverToken) {
songs = songs.map((song) => {
return {
...song,
imageUrl:
song.imageUrl && isImageTokenRequired
? `${song.imageUrl}${serverToken}`
: song.imageUrl,
streamUrl: `${song.streamUrl}${serverToken}`,
};
});
}
const playerData = addToQueue(songs, options.play);
if (options.play === Play.NEXT || options.play === Play.LAST) {
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueueNext(playerData);
}
}
if (options.play === Play.NOW) {
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueue(playerData);
mpvPlayer.play();
}
play();
if (options.play === Play.NEXT || options.play === Play.LAST) {
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueueNext(playerData);
}
}
},
[
addToQueue,
isImageTokenRequired,
play,
playerType,
queryClient,
serverId,
serverToken,
]
);
if (options.play === Play.NOW) {
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueue(playerData);
mpvPlayer.play();
}
play();
}
}
};
return handlePlayQueueAdd;
};
+102 -33
View File
@@ -4,12 +4,13 @@ import { AnimatePresence, motion, Variants } from 'framer-motion';
import isElectron from 'is-electron';
import throttle from 'lodash/throttle';
import { TbArrowBarLeft } from 'react-icons/tb';
import { Outlet } from 'react-router';
import { Outlet, useLocation } from 'react-router';
import styled from 'styled-components';
import { UserDetailResponse } from '@/renderer/api/users.api';
import { SideQueue } from '@/renderer/features/side-queue/components/side-queue';
import { PlayQueue } from '@/renderer/features/now-playing/';
import { Titlebar } from '@/renderer/features/titlebar/components/titlebar';
import { useUserDetail } from '@/renderer/features/users';
import { AppRoute } from '@/renderer/router/routes';
import { useAppStore, useAuthStore } from '@/renderer/store';
import { useSettingsStore } from '@/renderer/store/settings.store';
import { PlaybackType } from '@/renderer/types';
@@ -103,16 +104,29 @@ const QueueDrawer = styled(motion.div)`
const QueueDrawerButton = styled(motion.div)`
position: absolute;
top: 35%;
right: 0;
z-index: 55;
right: 25px;
z-index: 100;
display: flex;
align-items: center;
width: 50px;
width: 20px;
height: 25vh;
opacity: 0.3;
user-select: none;
`;
const SideQueueContainer = styled.div`
width: 100%;
height: 100%;
.ag-root ::-webkit-scrollbar-track-piece {
background: var(--main-bg);
}
.ag-theme-alpine-dark {
--ag-background-color: var(--sidebar-bg) !important;
--ag-odd-row-background-color: var(--sidebar-bg) !important;
}
`;
interface DefaultLayoutProps {
shell?: boolean;
}
@@ -120,7 +134,8 @@ interface DefaultLayoutProps {
export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
const sidebar = useAppStore((state) => state.sidebar);
const setSidebar = useAppStore((state) => state.setSidebar);
const [opened, drawerHandler] = useDisclosure(false);
const [drawer, drawerHandler] = useDisclosure(false);
const location = useLocation();
const sidebarRef = useRef<HTMLDivElement | null>(null);
const rightSidebarRef = useRef<HTMLDivElement | null>(null);
@@ -130,30 +145,64 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
const login = useAuthStore((state) => state.login);
const setSettings = useSettingsStore((state) => state.setSettings);
const showQueueDrawerButton =
!sidebar.rightExpanded &&
!drawer &&
location.pathname !== AppRoute.NOW_PLAYING;
const showSideQueue =
sidebar.rightExpanded && location.pathname !== AppRoute.NOW_PLAYING;
const queueDrawerButtonVariants: Variants = {
hidden: {
opacity: 0,
transition: { duration: 0.2 },
x: 100,
},
visible: {
opacity: 0.5,
transition: { duration: 0.2, ease: 'anticipate' },
x: 0,
},
};
const queueDrawerVariants: Variants = {
closed: {
height: 'calc(100% - 120px)',
position: 'absolute',
right: 0,
width: 0,
},
open: {
height: 'calc(100% - 120px)',
minWidth: '400px',
position: 'absolute',
right: 0,
transition: {
duration: 0.5,
duration: 0.3,
ease: 'anticipate',
},
width: '30vw',
zIndex: 75,
x: '30vw',
},
open: {
height: 'calc(100% - 120px)',
minWidth: '400px',
position: 'absolute',
right: 0,
transition: {
damping: 10,
delay: 0,
duration: 0.3,
ease: 'anticipate',
mass: 0.5,
},
width: '30vw',
x: 0,
zIndex: 120,
},
};
const queueSidebarVariants: Variants = {
closed: {
transition: { duration: 0.5 },
width: sidebar.rightWidth,
x: 1000,
zIndex: 120,
},
open: {
transition: {
@@ -162,7 +211,7 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
},
width: sidebar.rightWidth,
x: 0,
zIndex: 75,
zIndex: 120,
},
};
@@ -245,7 +294,7 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
</TitlebarContainer>
<MainContainer
leftSidebarWidth={sidebar.leftWidth}
rightExpanded={sidebar.rightExpanded}
rightExpanded={showSideQueue}
rightSidebarWidth={sidebar.rightWidth}
>
{!shell && (
@@ -262,35 +311,53 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
/>
<Sidebar />
</SidebarContainer>
{!sidebar.rightExpanded && (
<QueueDrawerButton
whileHover={{ opacity: 0, transition: { duration: 0.2 } }}
onMouseEnter={() => drawerHandler.open()}
>
<TbArrowBarLeft size={20} />
</QueueDrawerButton>
)}
<AnimatePresence key="queue-drawer" initial={false}>
{opened && (
<AnimatePresence exitBeforeEnter initial={false}>
{showQueueDrawerButton && (
<QueueDrawerButton
key="queue-drawer-button"
animate="visible"
exit="hidden"
initial="hidden"
variants={queueDrawerButtonVariants}
onMouseEnter={() => drawerHandler.open()}
>
<TbArrowBarLeft size={12} />
</QueueDrawerButton>
)}
{drawer && (
<QueueDrawer
key="queue-drawer"
animate="open"
exit="closed"
initial="closed"
variants={queueDrawerVariants}
onMouseLeave={() => drawerHandler.close()}
onMouseLeave={() => {
// The drawer will close due to the delay when setting isReorderingQueue
setTimeout(() => {
if (useAppStore.getState().isReorderingQueue) return;
drawerHandler.close();
}, 50);
}}
>
<SideQueue />
<SideQueueContainer>
<PlayQueue type="sideDrawerQueue" />
</SideQueueContainer>
</QueueDrawer>
)}
</AnimatePresence>
<AnimatePresence key="queue-sidebar" initial={false}>
{sidebar.rightExpanded && (
<AnimatePresence
key="queue-sidebar"
exitBeforeEnter
presenceAffectsLayout
initial={false}
>
{showSideQueue && (
<RightSidebarContainer
key="queue-sidebar"
animate="open"
exit="closed"
initial="oclosed"
initial="closed"
variants={queueSidebarVariants}
>
<ResizeHandle
@@ -302,7 +369,9 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
startResizing('right');
}}
/>
<SideQueue />
<SideQueueContainer>
<PlayQueue type="sideQueue" />
</SideQueueContainer>
</RightSidebarContainer>
)}
</AnimatePresence>
+2
View File
@@ -24,6 +24,7 @@ type ListProps = {
export interface AppState {
albums: LibraryPageProps;
isReorderingQueue: boolean;
platform: Platform;
sidebar: {
expanded: string[];
@@ -51,6 +52,7 @@ export const useAppStore = create<AppSlice>()(
type: 'grid',
},
},
isReorderingQueue: false,
platform: Platform.WINDOWS,
setAppStore: (data) => {
set({ ...get(), ...data });
+76 -3
View File
@@ -12,11 +12,9 @@ import {
PlayerRepeat,
PlayerShuffle,
PlayerStatus,
UniqueId,
QueueSong,
} from '@/renderer/types';
type QueueSong = Song & UniqueId;
export interface PlayerState {
current: {
index: number;
@@ -76,8 +74,10 @@ export interface PlayerSlice extends PlayerState {
player1: () => QueueSong | undefined;
player2: () => QueueSong | undefined;
prev: () => PlayerData;
reorderQueue: (rowUniqueIds: string[], afterUniqueId?: string) => PlayerData;
setCurrentIndex: (index: number) => PlayerData;
setCurrentTime: (time: number) => void;
setCurrentTrack: (uniqueId: string) => PlayerData;
setMuted: (muted: boolean) => void;
setRepeat: (type: PlayerRepeat) => PlayerData;
setShuffle: (type: PlayerShuffle) => PlayerData;
@@ -505,6 +505,45 @@ export const usePlayerStore = create<PlayerSlice>()(
shuffled: [],
sorted: [],
},
reorderQueue: (rowUniqueIds: string[], afterUniqueId?: string) => {
// Don't move if dropping on top of a selected row
if (afterUniqueId && rowUniqueIds.includes(afterUniqueId)) {
return get().getPlayerData();
}
const queue = get().queue.default;
const currentSongUniqueId = get().current.song?.uniqueId;
const queueWithoutSelectedRows = queue.filter(
(song) => !rowUniqueIds.includes(song.uniqueId)
);
const moveBeforeIndex = queueWithoutSelectedRows.findIndex(
(song) => song.uniqueId === afterUniqueId
);
// AG-Grid does not provide node data when a row is moved to the bottom of the list
const reorderedQueue = afterUniqueId
? [
...queueWithoutSelectedRows.slice(0, moveBeforeIndex),
...queue.filter((song) => rowUniqueIds.includes(song.uniqueId)),
...queueWithoutSelectedRows.slice(moveBeforeIndex),
]
: [
...queueWithoutSelectedRows,
...queue.filter((song) => rowUniqueIds.includes(song.uniqueId)),
];
const currentSongIndex = reorderedQueue.findIndex(
(song) => song.uniqueId === currentSongUniqueId
);
set({
current: { ...get().current, index: currentSongIndex },
queue: { ...get().queue, default: reorderedQueue },
});
return get().getPlayerData();
},
repeat: PlayerRepeat.NONE,
setCurrentIndex: (index) => {
if (get().shuffle === PlayerShuffle.TRACK) {
@@ -539,6 +578,40 @@ export const usePlayerStore = create<PlayerSlice>()(
state.current.time = time;
});
},
setCurrentTrack: (uniqueId) => {
if (get().shuffle === PlayerShuffle.TRACK) {
const defaultIndex = get().queue.default.findIndex(
(song) => song.uniqueId === uniqueId
);
const shuffledIndex = get().queue.shuffled.findIndex(
(id) => id === uniqueId
);
set((state) => {
state.current.time = 0;
state.current.index = defaultIndex;
state.current.shuffledIndex = shuffledIndex;
state.current.player = 1;
state.current.song = state.queue.default[defaultIndex];
state.queue.previousNode = get().current.song;
});
} else {
const defaultIndex = get().queue.default.findIndex(
(song) => song.uniqueId === uniqueId
);
set((state) => {
state.current.time = 0;
state.current.index = defaultIndex;
state.current.player = 1;
state.current.song = state.queue.default[defaultIndex];
state.queue.previousNode = get().current.song;
});
}
return get().getPlayerData();
},
setMuted: (muted: boolean) => {
set((state) => {
state.muted = muted;
+89
View File
@@ -10,8 +10,21 @@ import {
Play,
PlaybackStyle,
PlaybackType,
TableColumn,
} from '@/renderer/types';
export type PersistedTableColumn = {
column: TableColumn;
width: number;
};
export type DataTableProps = {
autoFit: boolean;
columns: PersistedTableColumn[];
followCurrentSong: boolean;
rowHeight: number;
};
export interface SettingsState {
general: {
followSystemTheme: boolean;
@@ -39,6 +52,11 @@ export interface SettingsState {
type: PlaybackType;
};
tab: 'general' | 'playback' | 'view' | string;
tables: {
nowPlaying: DataTableProps;
sideDrawerQueue: DataTableProps;
sideQueue: DataTableProps;
};
}
export interface SettingsSlice extends SettingsState {
@@ -78,6 +96,77 @@ export const useSettingsStore = create<SettingsSlice>()(
set({ ...get(), ...data });
},
tab: 'general',
tables: {
nowPlaying: {
autoFit: true,
columns: [
{
column: TableColumn.ROW_INDEX,
width: 50,
},
{
column: TableColumn.TITLE,
width: 500,
},
{
column: TableColumn.DURATION,
width: 100,
},
{
column: TableColumn.ALBUM,
width: 100,
},
{
column: TableColumn.ALBUM_ARTIST,
width: 100,
},
{
column: TableColumn.GENRE,
width: 100,
},
{
column: TableColumn.YEAR,
width: 100,
},
],
followCurrentSong: true,
rowHeight: 30,
},
sideDrawerQueue: {
autoFit: true,
columns: [
{
column: TableColumn.TITLE_COMBINED,
width: 500,
},
{
column: TableColumn.DURATION,
width: 100,
},
],
followCurrentSong: true,
rowHeight: 60,
},
sideQueue: {
autoFit: true,
columns: [
{
column: TableColumn.ROW_INDEX,
width: 50,
},
{
column: TableColumn.TITLE_COMBINED,
width: 500,
},
{
column: TableColumn.DURATION,
width: 100,
},
],
followCurrentSong: true,
rowHeight: 60,
},
},
})),
{ name: 'store_settings' }
),
+28
View File
@@ -1,3 +1,4 @@
import { Song } from '@/renderer/api/types';
import { AppRoute } from './router/routes';
export type RouteSlug = {
@@ -10,6 +11,8 @@ export type CardRoute = {
slugs?: RouteSlug[];
};
export type TableType = 'nowPlaying' | 'sideQueue' | 'sideDrawerQueue';
export type CardRow = {
arrayProperty?: string;
property: string;
@@ -93,6 +96,31 @@ export enum SortOrder {
DESC = 'desc',
}
export enum TableColumn {
ALBUM = 'album',
ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist',
BIT_RATE = 'bitRate',
DATE_ADDED = 'dateAdded',
DISC_NUMBER = 'discNumber',
DURATION = 'duration',
// FAVORITE = 'favorite',
GENRE = 'genre',
// PATH = 'path',
// PLAY_COUNT = 'playCount',
// RATING = 'rating',
RELEASE_DATE = 'releaseDate',
ROW_INDEX = 'rowIndex',
// SKIP = 'skip',
// SIZE = 'size',
TITLE = 'title',
TITLE_COMBINED = 'titleCombined',
TRACK_NUMBER = 'trackNumber',
YEAR = 'releaseYear',
}
export type QueueSong = Song & UniqueId;
export type PlayQueueAddOptions = {
byData?: any[];
byItemType?: {