diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts
index 9413a3093..1c6af7bc3 100644
--- a/src/renderer/api/types.ts
+++ b/src/renderer/api/types.ts
@@ -268,6 +268,7 @@ export type Song = {
serverId: string;
streamUrl: string;
trackNumber: number;
+ type: ServerType;
updatedAt: string;
};
diff --git a/src/renderer/components/index.ts b/src/renderer/components/index.ts
index 7ccb65b2e..425a52292 100644
--- a/src/renderer/components/index.ts
+++ b/src/renderer/components/index.ts
@@ -19,3 +19,4 @@ export * from './slider';
export * from './accordion';
export * from './dropzone';
export * from './spinner';
+export * from './virtual-table';
diff --git a/src/renderer/components/virtual-table/cells/album-artist-cell.tsx b/src/renderer/components/virtual-table/cells/album-artist-cell.tsx
new file mode 100644
index 000000000..7e3504136
--- /dev/null
+++ b/src/renderer/components/virtual-table/cells/album-artist-cell.tsx
@@ -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 (
+
+
+ {value?.map((item: Artist | AlbumArtist, index: number) => (
+
+ {index > 0 && (
+
+ ,
+
+ )}{' '}
+
+ {item.name || '—'}
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/renderer/components/virtual-table/cells/artist-cell.tsx b/src/renderer/components/virtual-table/cells/artist-cell.tsx
new file mode 100644
index 000000000..0bb94b8ea
--- /dev/null
+++ b/src/renderer/components/virtual-table/cells/artist-cell.tsx
@@ -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 (
+
+
+ {value?.map((item: Artist | AlbumArtist, index: number) => (
+
+ {index > 0 && (
+
+ ,
+
+ )}{' '}
+
+ {item.name || '—'}
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/renderer/components/virtual-table/cells/combined-title-cell.tsx b/src/renderer/components/virtual-table/cells/combined-title-cell.tsx
new file mode 100644
index 000000000..a274a5bfd
--- /dev/null
+++ b/src/renderer/components/virtual-table/cells/combined-title-cell.tsx
@@ -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 (
+
+
+
+
+
+
+ {value.name}
+
+
+ {artists?.length ? (
+ artists.map((artist: Artist | AlbumArtist, index: number) => (
+
+ {index > 0 ? ', ' : null}
+
+ {artist.name}
+
+
+ ))
+ ) : (
+ —
+ )}
+
+
+
+ );
+};
diff --git a/src/renderer/components/virtual-table/cells/generic-cell.tsx b/src/renderer/components/virtual-table/cells/generic-cell.tsx
new file mode 100644
index 000000000..72ffb5ad2
--- /dev/null
+++ b/src/renderer/components/virtual-table/cells/generic-cell.tsx
@@ -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 (
+
+ {isLink ? (
+
+ {isLink ? displayedValue.value : displayedValue}
+
+ ) : (
+
+ {displayedValue}
+
+ )}
+
+ );
+};
+
+GenericCell.defaultProps = {
+ position: undefined,
+};
diff --git a/src/renderer/components/virtual-table/cells/genre-cell.tsx b/src/renderer/components/virtual-table/cells/genre-cell.tsx
new file mode 100644
index 000000000..d507f0258
--- /dev/null
+++ b/src/renderer/components/virtual-table/cells/genre-cell.tsx
@@ -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 (
+
+
+ {value?.map((item: Artist | AlbumArtist, index: number) => (
+
+ {index > 0 && (
+
+ ,
+
+ )}{' '}
+
+ {item.name || '—'}
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/renderer/components/virtual-table/headers/duration-header.tsx b/src/renderer/components/virtual-table/headers/duration-header.tsx
new file mode 100644
index 000000000..93cd5b8a2
--- /dev/null
+++ b/src/renderer/components/virtual-table/headers/duration-header.tsx
@@ -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 ;
+};
diff --git a/src/renderer/components/virtual-table/headers/generic-table-header.tsx b/src/renderer/components/virtual-table/headers/generic-table-header.tsx
new file mode 100644
index 000000000..6f58e2a6c
--- /dev/null
+++ b/src/renderer/components/virtual-table/headers/generic-table-header.tsx
@@ -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: };
+
+export const GenericTableHeader = (
+ { displayName }: IHeaderParams,
+ { preset, children, position }: Options
+) => {
+ if (preset) {
+ return (
+
+ {headerPresets[preset]}
+
+ );
+ }
+
+ return (
+
+ {children || displayName}
+
+ );
+};
+
+GenericTableHeader.defaultProps = {
+ position: 'left',
+ preset: undefined,
+};
diff --git a/src/renderer/components/virtual-table/index.tsx b/src/renderer/components/virtual-table/index.tsx
new file mode 100644
index 000000000..96f6e1dd5
--- /dev/null
+++ b/src/renderer/components/virtual-table/index.tsx
@@ -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) => {
+ const tableRef = useRef(null);
+
+ const mergedRef = useMergedRef(ref, tableRef);
+
+ const tableContainerRef = useClickOutside(() => {
+ if (tableRef?.current) {
+ tableRef?.current.api.deselectAll();
+ }
+ });
+
+ return (
+
+
+
+ );
+ }
+);
diff --git a/src/renderer/components/virtual-table/table-config-dropdown.tsx b/src/renderer/components/virtual-table/table-config-dropdown.tsx
new file mode 100644
index 000000000..9d12a6e44
--- /dev/null
+++ b/src/renderer/components/virtual-table/table-config-dropdown.tsx
@@ -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) => {
+ setSettings({
+ tables: {
+ ...useSettingsStore.getState().tables,
+ [type]: {
+ ...useSettingsStore.getState().tables[type],
+ autoFit: e.currentTarget.checked,
+ },
+ },
+ });
+ };
+
+ const handleUpdateFollow = (e: ChangeEvent) => {
+ setSettings({
+ tables: {
+ ...useSettingsStore.getState().tables,
+ [type]: {
+ ...useSettingsStore.getState().tables[type],
+ followCurrentSong: e.currentTarget.checked,
+ },
+ },
+ });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Table Columns
+ column.column
+ )}
+ dropdownPosition="top"
+ width={300}
+ onChange={handleAddOrRemoveColumns}
+ />
+
+
+ Row Height
+
+
+
+ Auto Fit Columns
+
+
+
+ Follow Current Song
+
+
+
+
+
+
+ );
+};
diff --git a/src/renderer/features/now-playing/components/play-queue.tsx b/src/renderer/features/now-playing/components/play-queue.tsx
new file mode 100644
index 000000000..b7024ee46
--- /dev/null
+++ b/src/renderer/features/now-playing/components/play-queue.tsx
@@ -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(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) => {
+ 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(() => {
+ 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 (
+ <>
+
+
+ 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}
+ />
+
+
+
+ >
+ );
+};
diff --git a/src/renderer/features/now-playing/index.ts b/src/renderer/features/now-playing/index.ts
index 3e3dd13de..1b2e6520e 100644
--- a/src/renderer/features/now-playing/index.ts
+++ b/src/renderer/features/now-playing/index.ts
@@ -1 +1,2 @@
export * from './routes/now-playing-route';
+export * from './components/play-queue';
diff --git a/src/renderer/features/now-playing/routes/now-playing-route.tsx b/src/renderer/features/now-playing/routes/now-playing-route.tsx
index c64a52446..598fe7ecf 100644
--- a/src/renderer/features/now-playing/routes/now-playing-route.tsx
+++ b/src/renderer/features/now-playing/routes/now-playing-route.tsx
@@ -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(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([
- {
- 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(() => {
- 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 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);
- }}
- />
-
-
+
+
+
);
};
diff --git a/src/renderer/features/player/hooks/use-playqueue-handler.ts b/src/renderer/features/player/hooks/use-playqueue-handler.ts
index 21da61749..4ffe49c66 100644
--- a/src/renderer/features/player/hooks/use-playqueue-handler.ts
+++ b/src/renderer/features/player/hooks/use-playqueue-handler.ts
@@ -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;
};
diff --git a/src/renderer/layouts/default-layout.tsx b/src/renderer/layouts/default-layout.tsx
index 43520e63c..149f1e215 100644
--- a/src/renderer/layouts/default-layout.tsx
+++ b/src/renderer/layouts/default-layout.tsx
@@ -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(null);
const rightSidebarRef = useRef(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) => {
{!shell && (
@@ -262,35 +311,53 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
/>
- {!sidebar.rightExpanded && (
- drawerHandler.open()}
- >
-
-
- )}
-
- {opened && (
+
+ {showQueueDrawerButton && (
+ drawerHandler.open()}
+ >
+
+
+ )}
+
+ {drawer && (
drawerHandler.close()}
+ onMouseLeave={() => {
+ // The drawer will close due to the delay when setting isReorderingQueue
+ setTimeout(() => {
+ if (useAppStore.getState().isReorderingQueue) return;
+ drawerHandler.close();
+ }, 50);
+ }}
>
-
+
+
+
)}
-
- {sidebar.rightExpanded && (
+
+ {showSideQueue && (
{
startResizing('right');
}}
/>
-
+
+
+
)}
diff --git a/src/renderer/store/app.store.ts b/src/renderer/store/app.store.ts
index 297bbcc07..3c855f0b9 100644
--- a/src/renderer/store/app.store.ts
+++ b/src/renderer/store/app.store.ts
@@ -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()(
type: 'grid',
},
},
+ isReorderingQueue: false,
platform: Platform.WINDOWS,
setAppStore: (data) => {
set({ ...get(), ...data });
diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts
index 15eb644a2..7ea7c3a7a 100644
--- a/src/renderer/store/player.store.ts
+++ b/src/renderer/store/player.store.ts
@@ -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()(
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()(
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;
diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts
index 62c7922e2..59278dfab 100644
--- a/src/renderer/store/settings.store.ts
+++ b/src/renderer/store/settings.store.ts
@@ -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()(
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' }
),
diff --git a/src/renderer/types.ts b/src/renderer/types.ts
index ab8a6fc4b..d5e2f7aae 100644
--- a/src/renderer/types.ts
+++ b/src/renderer/types.ts
@@ -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?: {