mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 12:30:12 +02:00
Add playqueue table
This commit is contained in:
@@ -268,6 +268,7 @@ export type Song = {
|
||||
serverId: string;
|
||||
streamUrl: string;
|
||||
trackNumber: number;
|
||||
type: ServerType;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }
|
||||
),
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
Reference in New Issue
Block a user