add table row playback controls

- supports song, album, artist, and album artist tables
- hovering over the first row index or track number column will display a hovercard for the playback controls
This commit is contained in:
jeffvli
2026-05-19 20:58:34 -07:00
parent 42e9394246
commit 64efbc5210
16 changed files with 656 additions and 180 deletions
@@ -0,0 +1,26 @@
import { ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
import { TableColumn } from '/@/shared/types/types';
const ROW_PLAY_CONTROL_COLUMN_IDS = [TableColumn.TRACK_NUMBER, TableColumn.ROW_INDEX] as const;
type RowPlayControlColumnId = (typeof ROW_PLAY_CONTROL_COLUMN_IDS)[number];
const isRowPlayControlColumnId = (columnId: TableColumn): columnId is RowPlayControlColumnId =>
ROW_PLAY_CONTROL_COLUMN_IDS.includes(columnId as RowPlayControlColumnId);
export const getRowPlayControlColumnId = (
columns: Array<Pick<ItemTableListColumnConfig, 'id'>>,
): null | TableColumn => {
for (const column of columns) {
if (isRowPlayControlColumnId(column.id)) {
return column.id;
}
}
return null;
};
export const isRowPlayControlColumn = (
columnId: TableColumn,
columns: Array<Pick<ItemTableListColumnConfig, 'id'>>,
): boolean => getRowPlayControlColumnId(columns) === columnId;
@@ -0,0 +1,74 @@
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
import { Album, AlbumArtist, Artist, LibraryItem, Song } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
type PlayableArtistItem = AlbumArtist | Artist;
interface PlayerQueueByDataActions {
addToQueueByData: (data: Song[], type: Play, playSongId?: string) => void;
}
interface PlayerQueueByFetchActions {
addToQueueByFetch: (
serverId: string,
ids: string[],
itemType: LibraryItem,
playType: Play,
) => void;
}
export const playSongFromItemListControl = ({
index,
internalState,
item,
meta,
player,
}: {
index?: number;
internalState?: ItemListStateActions;
item: Song;
meta?: Record<string, unknown>;
player: PlayerQueueByDataActions;
}) => {
const playType = (meta?.playType as Play) || Play.NOW;
const singleSongOnly = meta?.singleSongOnly === true;
if (singleSongOnly) {
player.addToQueueByData([item], playType, item.id);
return;
}
const items = internalState?.getData() as Song[];
if (index !== undefined && items) {
player.addToQueueByData(items, playType, item.id);
}
};
export const playAlbumFromItemListControl = ({
album,
meta,
player,
}: {
album: Album;
meta?: Record<string, unknown>;
player: PlayerQueueByFetchActions;
}) => {
const playType = (meta?.playType as Play) || Play.NOW;
player.addToQueueByFetch(album._serverId, [album.id], LibraryItem.ALBUM, playType);
};
export const playArtistFromItemListControl = ({
artist,
itemType,
meta,
player,
}: {
artist: PlayableArtistItem;
itemType: LibraryItem.ALBUM_ARTIST | LibraryItem.ARTIST;
meta?: Record<string, unknown>;
player: PlayerQueueByFetchActions;
}) => {
const playType = (meta?.playType as Play) || Play.NOW;
player.addToQueueByFetch(artist._serverId, [artist.id], itemType, playType);
};
@@ -1,3 +1,45 @@
.full-size-content {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
overflow: visible;
-webkit-line-clamp: unset;
line-clamp: unset;
}
.expansion-cell {
overflow: visible;
}
.expansion-inner {
position: relative;
width: 100%;
height: 100%;
overflow: visible;
}
.play-target {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.expand {
position: absolute;
top: 50%;
left: 50%;
z-index: 4;
transform: translate(-50%, -50%);
}
.index-content {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
@@ -2,30 +2,38 @@ import clsx from 'clsx';
import styles from './row-index-column.module.css';
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
import { RowPlayControlCell } from '/@/renderer/components/item-list/item-table-list/columns/row-play-control-cell';
import { useRowPlayControl } from '/@/renderer/components/item-list/item-table-list/columns/use-row-play-control';
import {
ItemTableListInnerColumn,
TableColumnContainer,
TableColumnTextContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { ItemListItem } from '/@/renderer/components/item-list/types';
import { usePlayerStatus } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Flex } from '/@/shared/components/flex/flex';
import { Icon } from '/@/shared/components/icon/icon';
import { Text } from '/@/shared/components/text/text';
import { LibraryItem, QueueSong } from '/@/shared/types/domain-types';
import { PlayerStatus } from '/@/shared/types/types';
import { LibraryItem } from '/@/shared/types/domain-types';
import { TableColumn } from '/@/shared/types/types';
const RowIndexColumnBase = (props: ItemTableListInnerColumn) => {
const { itemType } = props;
if (!isRowPlayControlColumn(TableColumn.ROW_INDEX, props.columns)) {
return <DefaultRowIndexColumn {...props} />;
}
switch (itemType) {
case LibraryItem.ALBUM:
case LibraryItem.ALBUM_ARTIST:
case LibraryItem.ARTIST:
case LibraryItem.FOLDER:
case LibraryItem.PLAYLIST_SONG:
case LibraryItem.QUEUE_SONG:
case LibraryItem.SONG:
return <QueueSongRowIndexColumn {...props} />;
return <PlayableRowIndexColumn {...props} />;
default:
return <DefaultRowIndexColumn {...props} />;
}
@@ -88,12 +96,8 @@ const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => {
return <TableColumnTextContainer {...props}>{adjustedRowIndex}</TableColumnTextContainer>;
};
const QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => {
const status = usePlayerStatus();
const song = (props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex]) as QueueSong;
const isActive = useIsActiveRow(song?.id, song?._uniqueId);
const isActiveAndPlaying = isActive && status === PlayerStatus.PLAYING;
const PlayableRowIndexColumn = (props: ItemTableListInnerColumn) => {
const { handlePlay, isActive, isPlaying, showPlayControls } = useRowPlayControl(props);
let adjustedRowIndex =
props.getAdjustedRowIndex?.(props.rowIndex) ??
@@ -104,38 +108,20 @@ const QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => {
adjustedRowIndex = props.startRowIndex + adjustedRowIndex;
}
const indexContent = isActive ? (
<Flex className={styles.indexContent}>
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
</Flex>
) : (
adjustedRowIndex
);
return (
<InnerQueueSongRowIndexColumn
<RowPlayControlCell
{...props}
adjustedRowIndex={adjustedRowIndex}
isActive={isActive}
isPlaying={isActiveAndPlaying}
indexContent={indexContent}
onPlay={handlePlay}
showPlayControls={showPlayControls}
/>
);
};
const InnerQueueSongRowIndexColumn = (
props: ItemTableListInnerColumn & {
adjustedRowIndex: number;
isActive: boolean;
isPlaying: boolean;
},
) => {
return (
<TableColumnTextContainer {...props}>
{props.isActive ? (
props.isPlaying ? (
<Flex>
<Icon fill="primary" icon="mediaPlay" />
</Flex>
) : (
<Flex>
<Icon fill="primary" icon="mediaPause" />
</Flex>
)
) : (
props.adjustedRowIndex
)}
</TableColumnTextContainer>
);
};
@@ -0,0 +1,125 @@
import clsx from 'clsx';
import { ReactNode, useCallback } from 'react';
import styles from './row-index-column.module.css';
import {
ItemTableListInnerColumn,
TableColumnContainer,
TableColumnTextContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { ItemListItem } from '/@/renderer/components/item-list/types';
import { ItemRowPlayControls } from '/@/renderer/features/shared/components/item-row-play-controls';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Flex } from '/@/shared/components/flex/flex';
import { HoverCard } from '/@/shared/components/hover-card/hover-card';
import { Text } from '/@/shared/components/text/text';
import { Play } from '/@/shared/types/types';
export const RowPlayControlCell = (
props: ItemTableListInnerColumn & {
indexContent: ReactNode;
onPlay: (playType: Play) => void;
showPlayControls: boolean;
},
) => {
const {
controls,
data,
enableExpansion,
getRowItem,
indexContent,
internalState,
itemType,
onPlay,
rowIndex,
showPlayControls,
} = props;
const handleExpand = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const item = (getRowItem?.(rowIndex) ?? data[rowIndex]) as ItemListItem;
const rowId = internalState.extractRowId(item);
const index = rowId ? internalState.findItemIndex(rowId) : -1;
controls.onExpand?.({
event: e,
index,
internalState,
item,
itemType,
});
},
[controls, data, getRowItem, internalState, itemType, rowIndex],
);
const getIndexDisplay = (useMutedText: boolean) => {
const hideOnHoverClass = enableExpansion ? 'hide-on-hover' : undefined;
if (typeof indexContent === 'number') {
return useMutedText ? (
<Text className={hideOnHoverClass} isMuted isNoSelect>
{indexContent}
</Text>
) : (
indexContent
);
}
return <span className={hideOnHoverClass}>{indexContent}</span>;
};
const expansionTarget = (
<div className={styles.playTarget}>
{getIndexDisplay(true)}
<ActionIcon
className={clsx(styles.expand, 'hover-only')}
icon="arrowDownS"
iconProps={{ color: 'muted', size: 'md' }}
onClick={handleExpand}
size="xs"
variant="subtle"
/>
</div>
);
if (enableExpansion) {
return (
<TableColumnContainer {...props} className={styles.expansionCell}>
<div className={styles.expansionInner}>
{showPlayControls ? (
<HoverCard openDelay={300} position="top" withArrow>
<HoverCard.Target>{expansionTarget}</HoverCard.Target>
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
<ItemRowPlayControls onPlay={onPlay} />
</HoverCard.Dropdown>
</HoverCard>
) : (
expansionTarget
)}
</div>
</TableColumnContainer>
);
}
if (!showPlayControls) {
return (
<TableColumnTextContainer {...props}>{getIndexDisplay(false)}</TableColumnTextContainer>
);
}
return (
<TableColumnTextContainer {...props} className={styles.fullSizeContent}>
<HoverCard openDelay={300} position="top" withArrow>
<HoverCard.Target>
<Flex className={styles.indexContent} justify="center" w="100%">
{getIndexDisplay(false)}
</Flex>
</HoverCard.Target>
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
<ItemRowPlayControls onPlay={onPlay} />
</HoverCard.Dropdown>
</HoverCard>
</TableColumnTextContainer>
);
};
@@ -0,0 +1,51 @@
import styles from './row-index-column.module.css';
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
import { NumericColumn } from '/@/renderer/components/item-list/item-table-list/columns/numeric-column';
import { RowPlayControlCell } from '/@/renderer/components/item-list/item-table-list/columns/row-play-control-cell';
import {
supportsTrackNumberRowPlayControls,
useRowPlayControl,
} from '/@/renderer/components/item-list/item-table-list/columns/use-row-play-control';
import { ItemTableListInnerColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { Flex } from '/@/shared/components/flex/flex';
import { Icon } from '/@/shared/components/icon/icon';
import { TableColumn } from '/@/shared/types/types';
export const TrackNumberColumn = (props: ItemTableListInnerColumn) => {
if (
isRowPlayControlColumn(TableColumn.TRACK_NUMBER, props.columns) &&
supportsTrackNumberRowPlayControls(props.itemType)
) {
return <PlayableTrackNumberColumn {...props} />;
}
return <NumericColumn {...props} />;
};
const PlayableTrackNumberColumn = (props: ItemTableListInnerColumn) => {
const { handlePlay, isActive, isPlaying, showPlayControls } = useRowPlayControl(props);
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as unknown[])[props.rowIndex];
const trackNumber = (rowItem as undefined | { trackNumber?: number })?.trackNumber;
if (typeof trackNumber !== 'number') {
return <NumericColumn {...props} />;
}
const indexContent = isActive ? (
<Flex className={styles.indexContent}>
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
</Flex>
) : (
trackNumber
);
return (
<RowPlayControlCell
{...props}
indexContent={indexContent}
onPlay={handlePlay}
showPlayControls={showPlayControls}
/>
);
};
@@ -0,0 +1,140 @@
import { useCallback } from 'react';
import {
playAlbumFromItemListControl,
playArtistFromItemListControl,
playSongFromItemListControl,
} from '/@/renderer/components/item-list/helpers/play-row-from-list';
import { ItemTableListInnerColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { usePlayerSong, usePlayerStatus } from '/@/renderer/store';
import {
Album,
AlbumArtist,
Artist,
LibraryItem,
QueueSong,
Song,
} from '/@/shared/types/domain-types';
import { Play, PlayerStatus } from '/@/shared/types/types';
export const supportsRowPlayControls = (itemType: LibraryItem) =>
itemType === LibraryItem.ALBUM ||
itemType === LibraryItem.ALBUM_ARTIST ||
itemType === LibraryItem.ARTIST ||
itemType === LibraryItem.PLAYLIST_SONG ||
itemType === LibraryItem.SONG;
export const supportsTrackNumberRowPlayControls = (itemType: LibraryItem) =>
itemType === LibraryItem.PLAYLIST_SONG ||
itemType === LibraryItem.QUEUE_SONG ||
itemType === LibraryItem.SONG;
export const hasPlayableRowItem = (
itemType: LibraryItem,
items: { album: Album; artist: AlbumArtist | Artist; song: QueueSong },
) => {
switch (itemType) {
case LibraryItem.ALBUM:
return !!items.album?.id;
case LibraryItem.ALBUM_ARTIST:
case LibraryItem.ARTIST:
return !!items.artist?.id;
default:
return !!items.song;
}
};
export const useRowPlayControl = (props: ItemTableListInnerColumn) => {
const status = usePlayerStatus();
const currentSong = usePlayerSong();
const player = usePlayer();
const rowItem = props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex];
const song = rowItem as QueueSong;
const album = rowItem as Album;
const artist = rowItem as AlbumArtist | Artist;
const isActiveFromRow = useIsActiveRow(song?.id, song?._uniqueId);
const isActive = (() => {
switch (props.itemType) {
case LibraryItem.ALBUM:
return !!album?.id && currentSong?.albumId === album.id;
case LibraryItem.ALBUM_ARTIST:
return (
!!artist?.id &&
!!currentSong?.albumArtists?.some(
(relatedArtist) => relatedArtist.id === artist.id,
)
);
case LibraryItem.ARTIST:
return (
!!artist?.id &&
!!currentSong?.artists?.some((relatedArtist) => relatedArtist.id === artist.id)
);
default:
return isActiveFromRow;
}
})();
const isPlaying = isActive && status === PlayerStatus.PLAYING;
const showPlayControls =
supportsRowPlayControls(props.itemType) &&
hasPlayableRowItem(props.itemType, { album, artist, song });
const handlePlay = useCallback(
(playType: Play) => {
if (props.itemType === LibraryItem.ALBUM) {
if (!album?.id) {
return;
}
playAlbumFromItemListControl({
album,
meta: { playType },
player,
});
return;
}
if (
props.itemType === LibraryItem.ALBUM_ARTIST ||
props.itemType === LibraryItem.ARTIST
) {
if (!artist?.id) {
return;
}
playArtistFromItemListControl({
artist,
itemType: props.itemType,
meta: { playType },
player,
});
return;
}
if (!song) {
return;
}
playSongFromItemListControl({
item: song as Song,
meta: { playType, singleSongOnly: true },
player,
});
},
[album, artist, player, props.itemType, song],
);
return {
album,
artist,
handlePlay,
isActive,
isPlaying,
showPlayControls,
song,
};
};
@@ -44,7 +44,6 @@ import { FavoriteColumn } from '/@/renderer/components/item-list/item-table-list
import { GenreBadgeColumn } from '/@/renderer/components/item-list/item-table-list/columns/genre-badge-column';
import { GenreColumn } from '/@/renderer/components/item-list/item-table-list/columns/genre-column';
import { ImageColumn } from '/@/renderer/components/item-list/item-table-list/columns/image-column';
import { NumericColumn } from '/@/renderer/components/item-list/item-table-list/columns/numeric-column';
import { PathColumn } from '/@/renderer/components/item-list/item-table-list/columns/path-column';
import { PlaylistReorderColumn } from '/@/renderer/components/item-list/item-table-list/columns/playlist-reorder-column';
import { RatingColumn } from '/@/renderer/components/item-list/item-table-list/columns/rating-column';
@@ -54,6 +53,7 @@ import { TextColumn } from '/@/renderer/components/item-list/item-table-list/col
import { TitleArtistColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-artist-column';
import { TitleColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-column';
import { TitleCombinedColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-combined-column';
import { TrackNumberColumn } from '/@/renderer/components/item-list/item-table-list/columns/track-number-column';
import { YearColumn } from '/@/renderer/components/item-list/item-table-list/columns/year-column';
import { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state';
import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';
@@ -240,7 +240,9 @@ const ItemTableListColumnBase = (props: ItemTableListColumn) => {
case TableColumn.DISC_NUMBER:
case TableColumn.SAMPLE_RATE:
case TableColumn.TRACK_NUMBER:
return <NumericColumn {...props} {...dragProps} controls={controls} type={type} />;
return (
<TrackNumberColumn {...props} {...dragProps} controls={controls} type={type} />
);
case TableColumn.COMPOSER:
return <ComposerColumn {...props} {...dragProps} controls={controls} type={type} />;