mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +02:00
Add playqueue table
This commit is contained in:
@@ -268,6 +268,7 @@ export type Song = {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
streamUrl: string;
|
streamUrl: string;
|
||||||
trackNumber: number;
|
trackNumber: number;
|
||||||
|
type: ServerType;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ export * from './slider';
|
|||||||
export * from './accordion';
|
export * from './accordion';
|
||||||
export * from './dropzone';
|
export * from './dropzone';
|
||||||
export * from './spinner';
|
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 './routes/now-playing-route';
|
||||||
|
export * from './components/play-queue';
|
||||||
|
|||||||
@@ -1,123 +1,20 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { Box } from '@mantine/core';
|
||||||
import { ColDef, RowClassRules } from 'ag-grid-community';
|
import styled from 'styled-components';
|
||||||
import {
|
import { PlayQueue } from '@/renderer/features/now-playing/components/play-queue';
|
||||||
VirtualGridAutoSizerContainer,
|
|
||||||
VirtualGridContainer,
|
|
||||||
} from '@/renderer/components';
|
|
||||||
import { VirtualTable } from '@/renderer/components/virtual-table';
|
|
||||||
import { mpvPlayer } from '@/renderer/features/player/utils/mpvPlayer';
|
|
||||||
import { AnimatedPage } from '@/renderer/features/shared';
|
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 = () => {
|
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 (
|
return (
|
||||||
<AnimatedPage>
|
<AnimatedPage>
|
||||||
<VirtualGridContainer>
|
<QueueContainer>
|
||||||
<VirtualGridAutoSizerContainer>
|
<PlayQueue type="nowPlaying" />
|
||||||
<VirtualTable
|
</QueueContainer>
|
||||||
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>
|
|
||||||
</AnimatedPage>
|
</AnimatedPage>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '@/renderer/api';
|
import { api } from '@/renderer/api';
|
||||||
import { queryKeys } from '@/renderer/api/query-keys';
|
import { queryKeys } from '@/renderer/api/query-keys';
|
||||||
@@ -21,8 +20,7 @@ export const usePlayQueueHandler = () => {
|
|||||||
const addToQueue = usePlayerStore((state) => state.addToQueue);
|
const addToQueue = usePlayerStore((state) => state.addToQueue);
|
||||||
const playerType = useSettingsStore((state) => state.player.type);
|
const playerType = useSettingsStore((state) => state.player.type);
|
||||||
|
|
||||||
const handlePlayQueueAdd = useCallback(
|
const handlePlayQueueAdd = async (options: PlayQueueAddOptions) => {
|
||||||
async (options: PlayQueueAddOptions) => {
|
|
||||||
if (options.byData) {
|
if (options.byData) {
|
||||||
// dispatchSongsToQueue(options.byData, options.play);
|
// dispatchSongsToQueue(options.byData, options.play);
|
||||||
}
|
}
|
||||||
@@ -79,17 +77,7 @@ export const usePlayQueueHandler = () => {
|
|||||||
play();
|
play();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
[
|
|
||||||
addToQueue,
|
|
||||||
isImageTokenRequired,
|
|
||||||
play,
|
|
||||||
playerType,
|
|
||||||
queryClient,
|
|
||||||
serverId,
|
|
||||||
serverToken,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return handlePlayQueueAdd;
|
return handlePlayQueueAdd;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import { AnimatePresence, motion, Variants } from 'framer-motion';
|
|||||||
import isElectron from 'is-electron';
|
import isElectron from 'is-electron';
|
||||||
import throttle from 'lodash/throttle';
|
import throttle from 'lodash/throttle';
|
||||||
import { TbArrowBarLeft } from 'react-icons/tb';
|
import { TbArrowBarLeft } from 'react-icons/tb';
|
||||||
import { Outlet } from 'react-router';
|
import { Outlet, useLocation } from 'react-router';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { UserDetailResponse } from '@/renderer/api/users.api';
|
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 { Titlebar } from '@/renderer/features/titlebar/components/titlebar';
|
||||||
import { useUserDetail } from '@/renderer/features/users';
|
import { useUserDetail } from '@/renderer/features/users';
|
||||||
|
import { AppRoute } from '@/renderer/router/routes';
|
||||||
import { useAppStore, useAuthStore } from '@/renderer/store';
|
import { useAppStore, useAuthStore } from '@/renderer/store';
|
||||||
import { useSettingsStore } from '@/renderer/store/settings.store';
|
import { useSettingsStore } from '@/renderer/store/settings.store';
|
||||||
import { PlaybackType } from '@/renderer/types';
|
import { PlaybackType } from '@/renderer/types';
|
||||||
@@ -103,16 +104,29 @@ const QueueDrawer = styled(motion.div)`
|
|||||||
const QueueDrawerButton = styled(motion.div)`
|
const QueueDrawerButton = styled(motion.div)`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 35%;
|
top: 35%;
|
||||||
right: 0;
|
right: 25px;
|
||||||
z-index: 55;
|
z-index: 100;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 50px;
|
width: 20px;
|
||||||
height: 25vh;
|
height: 25vh;
|
||||||
opacity: 0.3;
|
|
||||||
user-select: none;
|
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 {
|
interface DefaultLayoutProps {
|
||||||
shell?: boolean;
|
shell?: boolean;
|
||||||
}
|
}
|
||||||
@@ -120,7 +134,8 @@ interface DefaultLayoutProps {
|
|||||||
export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
|
export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
|
||||||
const sidebar = useAppStore((state) => state.sidebar);
|
const sidebar = useAppStore((state) => state.sidebar);
|
||||||
const setSidebar = useAppStore((state) => state.setSidebar);
|
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 sidebarRef = useRef<HTMLDivElement | null>(null);
|
||||||
const rightSidebarRef = 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 login = useAuthStore((state) => state.login);
|
||||||
const setSettings = useSettingsStore((state) => state.setSettings);
|
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 = {
|
const queueDrawerVariants: Variants = {
|
||||||
closed: {
|
closed: {
|
||||||
height: 'calc(100% - 120px)',
|
height: 'calc(100% - 120px)',
|
||||||
position: 'absolute',
|
minWidth: '400px',
|
||||||
right: 0,
|
|
||||||
width: 0,
|
|
||||||
},
|
|
||||||
open: {
|
|
||||||
height: 'calc(100% - 120px)',
|
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: 0,
|
right: 0,
|
||||||
transition: {
|
transition: {
|
||||||
duration: 0.5,
|
duration: 0.3,
|
||||||
ease: 'anticipate',
|
ease: 'anticipate',
|
||||||
},
|
},
|
||||||
width: '30vw',
|
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 = {
|
const queueSidebarVariants: Variants = {
|
||||||
closed: {
|
closed: {
|
||||||
transition: { duration: 0.5 },
|
transition: { duration: 0.5 },
|
||||||
|
width: sidebar.rightWidth,
|
||||||
x: 1000,
|
x: 1000,
|
||||||
|
zIndex: 120,
|
||||||
},
|
},
|
||||||
open: {
|
open: {
|
||||||
transition: {
|
transition: {
|
||||||
@@ -162,7 +211,7 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
|
|||||||
},
|
},
|
||||||
width: sidebar.rightWidth,
|
width: sidebar.rightWidth,
|
||||||
x: 0,
|
x: 0,
|
||||||
zIndex: 75,
|
zIndex: 120,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -245,7 +294,7 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
|
|||||||
</TitlebarContainer>
|
</TitlebarContainer>
|
||||||
<MainContainer
|
<MainContainer
|
||||||
leftSidebarWidth={sidebar.leftWidth}
|
leftSidebarWidth={sidebar.leftWidth}
|
||||||
rightExpanded={sidebar.rightExpanded}
|
rightExpanded={showSideQueue}
|
||||||
rightSidebarWidth={sidebar.rightWidth}
|
rightSidebarWidth={sidebar.rightWidth}
|
||||||
>
|
>
|
||||||
{!shell && (
|
{!shell && (
|
||||||
@@ -262,35 +311,53 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
|
|||||||
/>
|
/>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
</SidebarContainer>
|
</SidebarContainer>
|
||||||
{!sidebar.rightExpanded && (
|
<AnimatePresence exitBeforeEnter initial={false}>
|
||||||
|
{showQueueDrawerButton && (
|
||||||
<QueueDrawerButton
|
<QueueDrawerButton
|
||||||
whileHover={{ opacity: 0, transition: { duration: 0.2 } }}
|
key="queue-drawer-button"
|
||||||
|
animate="visible"
|
||||||
|
exit="hidden"
|
||||||
|
initial="hidden"
|
||||||
|
variants={queueDrawerButtonVariants}
|
||||||
onMouseEnter={() => drawerHandler.open()}
|
onMouseEnter={() => drawerHandler.open()}
|
||||||
>
|
>
|
||||||
<TbArrowBarLeft size={20} />
|
<TbArrowBarLeft size={12} />
|
||||||
</QueueDrawerButton>
|
</QueueDrawerButton>
|
||||||
)}
|
)}
|
||||||
<AnimatePresence key="queue-drawer" initial={false}>
|
|
||||||
{opened && (
|
{drawer && (
|
||||||
<QueueDrawer
|
<QueueDrawer
|
||||||
key="queue-drawer"
|
key="queue-drawer"
|
||||||
animate="open"
|
animate="open"
|
||||||
exit="closed"
|
exit="closed"
|
||||||
initial="closed"
|
initial="closed"
|
||||||
variants={queueDrawerVariants}
|
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>
|
</QueueDrawer>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
<AnimatePresence key="queue-sidebar" initial={false}>
|
<AnimatePresence
|
||||||
{sidebar.rightExpanded && (
|
key="queue-sidebar"
|
||||||
|
exitBeforeEnter
|
||||||
|
presenceAffectsLayout
|
||||||
|
initial={false}
|
||||||
|
>
|
||||||
|
{showSideQueue && (
|
||||||
<RightSidebarContainer
|
<RightSidebarContainer
|
||||||
key="queue-sidebar"
|
key="queue-sidebar"
|
||||||
animate="open"
|
animate="open"
|
||||||
exit="closed"
|
exit="closed"
|
||||||
initial="oclosed"
|
initial="closed"
|
||||||
variants={queueSidebarVariants}
|
variants={queueSidebarVariants}
|
||||||
>
|
>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
@@ -302,7 +369,9 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
|
|||||||
startResizing('right');
|
startResizing('right');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<SideQueue />
|
<SideQueueContainer>
|
||||||
|
<PlayQueue type="sideQueue" />
|
||||||
|
</SideQueueContainer>
|
||||||
</RightSidebarContainer>
|
</RightSidebarContainer>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type ListProps = {
|
|||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
albums: LibraryPageProps;
|
albums: LibraryPageProps;
|
||||||
|
isReorderingQueue: boolean;
|
||||||
platform: Platform;
|
platform: Platform;
|
||||||
sidebar: {
|
sidebar: {
|
||||||
expanded: string[];
|
expanded: string[];
|
||||||
@@ -51,6 +52,7 @@ export const useAppStore = create<AppSlice>()(
|
|||||||
type: 'grid',
|
type: 'grid',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
isReorderingQueue: false,
|
||||||
platform: Platform.WINDOWS,
|
platform: Platform.WINDOWS,
|
||||||
setAppStore: (data) => {
|
setAppStore: (data) => {
|
||||||
set({ ...get(), ...data });
|
set({ ...get(), ...data });
|
||||||
|
|||||||
@@ -12,11 +12,9 @@ import {
|
|||||||
PlayerRepeat,
|
PlayerRepeat,
|
||||||
PlayerShuffle,
|
PlayerShuffle,
|
||||||
PlayerStatus,
|
PlayerStatus,
|
||||||
UniqueId,
|
QueueSong,
|
||||||
} from '@/renderer/types';
|
} from '@/renderer/types';
|
||||||
|
|
||||||
type QueueSong = Song & UniqueId;
|
|
||||||
|
|
||||||
export interface PlayerState {
|
export interface PlayerState {
|
||||||
current: {
|
current: {
|
||||||
index: number;
|
index: number;
|
||||||
@@ -76,8 +74,10 @@ export interface PlayerSlice extends PlayerState {
|
|||||||
player1: () => QueueSong | undefined;
|
player1: () => QueueSong | undefined;
|
||||||
player2: () => QueueSong | undefined;
|
player2: () => QueueSong | undefined;
|
||||||
prev: () => PlayerData;
|
prev: () => PlayerData;
|
||||||
|
reorderQueue: (rowUniqueIds: string[], afterUniqueId?: string) => PlayerData;
|
||||||
setCurrentIndex: (index: number) => PlayerData;
|
setCurrentIndex: (index: number) => PlayerData;
|
||||||
setCurrentTime: (time: number) => void;
|
setCurrentTime: (time: number) => void;
|
||||||
|
setCurrentTrack: (uniqueId: string) => PlayerData;
|
||||||
setMuted: (muted: boolean) => void;
|
setMuted: (muted: boolean) => void;
|
||||||
setRepeat: (type: PlayerRepeat) => PlayerData;
|
setRepeat: (type: PlayerRepeat) => PlayerData;
|
||||||
setShuffle: (type: PlayerShuffle) => PlayerData;
|
setShuffle: (type: PlayerShuffle) => PlayerData;
|
||||||
@@ -505,6 +505,45 @@ export const usePlayerStore = create<PlayerSlice>()(
|
|||||||
shuffled: [],
|
shuffled: [],
|
||||||
sorted: [],
|
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,
|
repeat: PlayerRepeat.NONE,
|
||||||
setCurrentIndex: (index) => {
|
setCurrentIndex: (index) => {
|
||||||
if (get().shuffle === PlayerShuffle.TRACK) {
|
if (get().shuffle === PlayerShuffle.TRACK) {
|
||||||
@@ -539,6 +578,40 @@ export const usePlayerStore = create<PlayerSlice>()(
|
|||||||
state.current.time = time;
|
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) => {
|
setMuted: (muted: boolean) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
state.muted = muted;
|
state.muted = muted;
|
||||||
|
|||||||
@@ -10,8 +10,21 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
PlaybackStyle,
|
PlaybackStyle,
|
||||||
PlaybackType,
|
PlaybackType,
|
||||||
|
TableColumn,
|
||||||
} from '@/renderer/types';
|
} from '@/renderer/types';
|
||||||
|
|
||||||
|
export type PersistedTableColumn = {
|
||||||
|
column: TableColumn;
|
||||||
|
width: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataTableProps = {
|
||||||
|
autoFit: boolean;
|
||||||
|
columns: PersistedTableColumn[];
|
||||||
|
followCurrentSong: boolean;
|
||||||
|
rowHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
export interface SettingsState {
|
export interface SettingsState {
|
||||||
general: {
|
general: {
|
||||||
followSystemTheme: boolean;
|
followSystemTheme: boolean;
|
||||||
@@ -39,6 +52,11 @@ export interface SettingsState {
|
|||||||
type: PlaybackType;
|
type: PlaybackType;
|
||||||
};
|
};
|
||||||
tab: 'general' | 'playback' | 'view' | string;
|
tab: 'general' | 'playback' | 'view' | string;
|
||||||
|
tables: {
|
||||||
|
nowPlaying: DataTableProps;
|
||||||
|
sideDrawerQueue: DataTableProps;
|
||||||
|
sideQueue: DataTableProps;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingsSlice extends SettingsState {
|
export interface SettingsSlice extends SettingsState {
|
||||||
@@ -78,6 +96,77 @@ export const useSettingsStore = create<SettingsSlice>()(
|
|||||||
set({ ...get(), ...data });
|
set({ ...get(), ...data });
|
||||||
},
|
},
|
||||||
tab: 'general',
|
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' }
|
{ name: 'store_settings' }
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Song } from '@/renderer/api/types';
|
||||||
import { AppRoute } from './router/routes';
|
import { AppRoute } from './router/routes';
|
||||||
|
|
||||||
export type RouteSlug = {
|
export type RouteSlug = {
|
||||||
@@ -10,6 +11,8 @@ export type CardRoute = {
|
|||||||
slugs?: RouteSlug[];
|
slugs?: RouteSlug[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TableType = 'nowPlaying' | 'sideQueue' | 'sideDrawerQueue';
|
||||||
|
|
||||||
export type CardRow = {
|
export type CardRow = {
|
||||||
arrayProperty?: string;
|
arrayProperty?: string;
|
||||||
property: string;
|
property: string;
|
||||||
@@ -93,6 +96,31 @@ export enum SortOrder {
|
|||||||
DESC = 'desc',
|
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 = {
|
export type PlayQueueAddOptions = {
|
||||||
byData?: any[];
|
byData?: any[];
|
||||||
byItemType?: {
|
byItemType?: {
|
||||||
|
|||||||
Reference in New Issue
Block a user