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?: {