From f14c8f21e680421374587f6ec08ab42287b37ae8 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Thu, 14 May 2026 00:01:36 +0200 Subject: [PATCH] =?UTF-8?q?refactor(album-track-list):=20I.2=20=E2=80=94?= =?UTF-8?q?=20split=20AlbumTrackList.tsx=20662=20=E2=86=92=20187=20LOC=20a?= =?UTF-8?q?cross=207=20files=20(#672)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(album-track-list): extract helpers + types Pull formatDuration / codecLabel, the COLUMNS / CENTERED_COLS / SORTABLE_COLS tables, ColKey / SortKey types, and the isSortable type guard into utils/albumTrackListHelpers.ts. SortKey is re-exported from AlbumTrackList.tsx so existing imports stay valid. AlbumTrackList.tsx: 662 → 633 LOC. * refactor(album-track-list): extract TrackRow subcomponent Move the memoised tracklist row (~220 LOC including renderCell switch and mouse handlers) into components/albumTrackList/TrackRow.tsx. It still subscribes to its own selection + preview state via primitive selectors, so per-row re-render scope is unchanged. AlbumTrackList.tsx: 633 → 404 LOC. * refactor(album-track-list): extract AlbumTrackListMobile subcomponent Move the narrow-viewport branch (compact tracklist with disc separators and no column grid) into components/albumTrackList/AlbumTrackListMobile.tsx. AlbumTrackList.tsx: 404 → 376 LOC. * refactor(album-track-list): extract TracklistColumnPicker subcomponent The column visibility dropdown lives outside .tracklist to avoid the overflow box clipping its menu — pull the wrapper + button + popover into components/albumTrackList/TracklistColumnPicker.tsx. AlbumTrackList.tsx: 376 → 347 LOC. * refactor(album-track-list): extract TracklistHeaderRow subcomponent The fixed header (sortable + resizable per-column with the bulk-select toggle on the num cell) moves into components/albumTrackList/TracklistHeaderRow.tsx, taking 85+ LOC of cell rendering with it. AlbumTrackList.tsx: 347 → 254 LOC. * refactor(album-track-list): extract useAlbumTrackListSelection hook Pull bulk-selection state (selectedIds-size subscription, shift-range toggle, click-outside-clear, song-list-change clear) and the drag-start dispatcher (single vs multi-song drag) into hooks/useAlbumTrackListSelection.ts. AlbumTrackList.tsx: 254 → 187 LOC. --- src/components/AlbumTrackList.tsx | 569 ++---------------- .../albumTrackList/AlbumTrackListMobile.tsx | 82 +++ src/components/albumTrackList/TrackRow.tsx | 245 ++++++++ .../albumTrackList/TracklistColumnPicker.tsx | 70 +++ .../albumTrackList/TracklistHeaderRow.tsx | 142 +++++ src/hooks/useAlbumTrackListSelection.ts | 101 ++++ src/utils/albumTrackListHelpers.ts | 39 ++ 7 files changed, 726 insertions(+), 522 deletions(-) create mode 100644 src/components/albumTrackList/AlbumTrackListMobile.tsx create mode 100644 src/components/albumTrackList/TrackRow.tsx create mode 100644 src/components/albumTrackList/TracklistColumnPicker.tsx create mode 100644 src/components/albumTrackList/TracklistHeaderRow.tsx create mode 100644 src/hooks/useAlbumTrackListSelection.ts create mode 100644 src/utils/albumTrackListHelpers.ts diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index 7253e3a9..ffe1d876 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -1,53 +1,22 @@ import type { SubsonicSong } from '../api/subsonicTypes'; -import { songToTrack } from '../utils/songToTrack'; import type { Track } from '../store/playerStoreTypes'; -import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { Play, ChevronRight, Heart, ChevronDown, Check, RotateCcw, Square, AudioLines } from 'lucide-react'; -import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns'; +import React, { useState, useEffect } from 'react'; +import { useTracklistColumns } from '../utils/useTracklistColumns'; import { usePlayerStore } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import { useDragDrop } from '../contexts/DragDropContext'; import { useIsMobile } from '../hooks/useIsMobile'; -import StarRating from './StarRating'; import { useSelectionStore } from '../store/selectionStore'; -import { useThemeStore } from '../store/themeStore'; -import { usePreviewStore } from '../store/previewStore'; +import { + COLUMNS, + type SortKey, +} from '../utils/albumTrackListHelpers'; +import { useAlbumTrackListSelection } from '../hooks/useAlbumTrackListSelection'; +import { TrackRow } from './albumTrackList/TrackRow'; +import { AlbumTrackListMobile } from './albumTrackList/AlbumTrackListMobile'; +import { TracklistColumnPicker } from './albumTrackList/TracklistColumnPicker'; +import { TracklistHeaderRow } from './albumTrackList/TracklistHeaderRow'; -function formatDuration(seconds: number): string { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = Math.floor(seconds % 60); - if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; - return `${m}:${s.toString().padStart(2, '0')}`; -} - -function codecLabel(song: { suffix?: string; bitRate?: number }, showBitrate: boolean): string { - const parts: string[] = []; - if (song.suffix) parts.push(song.suffix.toUpperCase()); - if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`); - return parts.join(' · '); -} - -// ── Column configuration ────────────────────────────────────────────────────── -const COLUMNS: readonly ColDef[] = [ - { key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true }, - { key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true }, - { key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false }, - { key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false }, - { key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false }, - { key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false }, - { key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false }, - { key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false }, -]; - -type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre'; - -const CENTERED_COLS = new Set(['favorite', 'rating', 'duration']); - -// ── Props ───────────────────────────────────────────────────────────────────── - -export type SortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration'; +export type { SortKey } from '../utils/albumTrackListHelpers'; interface AlbumTrackListProps { songs: SubsonicSong[]; @@ -69,231 +38,6 @@ interface AlbumTrackListProps { onSort?: (key: SortKey) => void; } -// ── TrackRow (memoised) ─────────────────────────────────────────────────────── -// Subscribes only to its own boolean in the selection store → O(1) re-render on toggle. - -interface TrackRowProps { - song: SubsonicSong; - globalIdx: number; - visibleCols: readonly ColDef[]; - gridStyle: React.CSSProperties; - currentTrackId: string | null; - isPlaying: boolean; - ratingValue: number; - isStarred: boolean; - inSelectMode: boolean; - isContextMenuSong: boolean; - onPlaySong: (song: SubsonicSong) => void; - onDoubleClickSong?: (song: SubsonicSong) => void; - onRate: (songId: string, rating: number) => void; - onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void; - onContextMenu: AlbumTrackListProps['onContextMenu']; - onToggleSelect: (id: string, globalIdx: number, shift: boolean) => void; - onDragStart: (song: SubsonicSong, me: MouseEvent) => void; - setContextMenuSongId: (id: string | null) => void; -} - -const TrackRow = React.memo(function TrackRow({ - song, - globalIdx, - visibleCols, - gridStyle, - currentTrackId, - isPlaying, - ratingValue, - isStarred, - inSelectMode, - isContextMenuSong, - onPlaySong, - onDoubleClickSong, - onRate, - onToggleSongStar, - onContextMenu, - onToggleSelect, - onDragStart, - setContextMenuSongId, -}: TrackRowProps) { - const { t } = useTranslation(); - const navigate = useNavigate(); - const showBitrate = useThemeStore(s => s.showBitrate); - // Fine-grained: only re-renders when THIS row's selection boolean flips. - const isSelected = useSelectionStore(s => s.selectedIds.has(song.id)); - const isActive = currentTrackId === song.id; - // Primitive selector: row only re-renders when *this song's* preview state flips. - const isPreviewing = usePreviewStore(s => s.previewingId === song.id); - const isPreviewAudioStarted = usePreviewStore(s => s.previewingId === song.id && s.audioStarted); - - const renderCell = (colDef: ColDef) => { - const key = colDef.key as ColKey; - switch (key) { - case 'num': - return ( -
- { e.stopPropagation(); onToggleSelect(song.id, globalIdx, e.shiftKey); }} - /> - {isActive && isPlaying ? ( - - - - ) : ( - {song.track ?? '—'} - )} -
- ); - case 'title': - return ( -
- - - {song.title} -
- ); - case 'artist': { - const artistRefs = song.artists && song.artists.length > 0 - ? song.artists - : [{ id: song.artistId, name: song.artist }]; - return ( -
- {artistRefs.map((a, i) => ( - - {i > 0 &&  · } - { if (a.id) { e.stopPropagation(); navigate(`/artist/${a.id}`); } }} - > - {a.name ?? song.artist} - - - ))} -
- ); - } - case 'favorite': - return ( -
- -
- ); - case 'rating': - return ( - onRate(song.id, r)} - /> - ); - case 'duration': - return ( -
- {formatDuration(song.duration)} -
- ); - case 'format': - return ( -
- {(song.suffix || (showBitrate && song.bitRate)) && ( - {codecLabel(song, showBitrate)} - )} -
- ); - case 'genre': - return ( -
- {song.genre ?? '—'} -
- ); - default: - return null; - } - }; - - return ( -
{ - if ((e.target as HTMLElement).closest('button, a, input')) return; - if (e.ctrlKey || e.metaKey) { - onToggleSelect(song.id, globalIdx, false); - } else if (inSelectMode) { - onToggleSelect(song.id, globalIdx, e.shiftKey); - } else { - onPlaySong(song); - } - }} - onDoubleClick={onDoubleClickSong ? e => { - if ((e.target as HTMLElement).closest('button, a, input')) return; - if (e.ctrlKey || e.metaKey || inSelectMode) return; - onDoubleClickSong(song); - } : undefined} - onContextMenu={e => { - e.preventDefault(); - setContextMenuSongId(song.id); - onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song'); - }} - role="row" - onMouseDown={e => { - if (e.button !== 0) return; - e.preventDefault(); - const sx = e.clientX, sy = e.clientY; - const onMove = (me: MouseEvent) => { - if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - onDragStart(song, me); - } - }; - const onUp = () => { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }} - > - {visibleCols.map(colDef => renderCell(colDef))} -
- ); -}); - // ── AlbumTrackList ──────────────────────────────────────────────────────────── export default function AlbumTrackList({ @@ -318,87 +62,21 @@ export default function AlbumTrackList({ const isMobile = useIsMobile(); const [contextMenuSongId, setContextMenuSongId] = useState(null); const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); - const psyDrag = useDragDrop(); - // Selection state lives in selectionStore — only the toggled row re-renders (O(1)). - const selectedCount = useSelectionStore(s => s.selectedIds.size); - const inSelectMode = selectedCount > 0; - const allSelected = selectedCount === songs.length && songs.length > 0; - const lastSelectedIdxRef = useRef(null); - - // ── Column state ────────────────────────────────────────────────────────── const { colVisible, visibleCols, gridStyle, startResize, toggleColumn, resetColumns, pickerOpen, setPickerOpen, pickerRef, tracklistRef, } = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns'); - // Clear selection when the song list changes (different album / filter applied). - useEffect(() => { - useSelectionStore.getState().clearAll(); - lastSelectedIdxRef.current = null; - }, [songs]); + const { + inSelectMode, allSelected, onToggleSelect, onDragStart, toggleAll, + } = useAlbumTrackListSelection({ songs, tracklistRef }); useEffect(() => { if (!contextMenuOpen) setContextMenuSongId(null); }, [contextMenuOpen]); - // Clear selection on click outside the tracklist (header, album art, etc.) - useEffect(() => { - if (!inSelectMode) return; - const handler = (e: MouseEvent) => { - if (tracklistRef.current && !tracklistRef.current.contains(e.target as Node)) { - useSelectionStore.getState().clearAll(); - } - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [inSelectMode, tracklistRef]); - - // ── Stable callbacks passed to memoised TrackRow ────────────────────────── - - const onToggleSelect = useCallback((id: string, globalIdx: number, shift: boolean) => { - useSelectionStore.getState().setSelectedIds(prev => { - const next = new Set(prev); - if (shift && lastSelectedIdxRef.current !== null) { - const from = Math.min(lastSelectedIdxRef.current, globalIdx); - const to = Math.max(lastSelectedIdxRef.current, globalIdx); - songs.slice(from, to + 1).forEach(s => next.add(s.id)); - } else { - next.has(id) ? next.delete(id) : next.add(id); - } - lastSelectedIdxRef.current = globalIdx; - return next; - }); - }, [songs]); - - // Drag: if the dragged song is part of the selection, drag all selected songs. - const onDragStart = useCallback((song: SubsonicSong, me: MouseEvent) => { - const { selectedIds } = useSelectionStore.getState(); - if (selectedIds.has(song.id) && selectedIds.size > 1) { - const tracks = songs - .filter(s => selectedIds.has(s.id)) - .map(s => songToTrack(s)); - psyDrag.startDrag( - { data: JSON.stringify({ type: 'songs', tracks }), label: `${tracks.length} Songs` }, - me.clientX, me.clientY, - ); - } else { - psyDrag.startDrag( - { data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title }, - me.clientX, me.clientY, - ); - } - }, [songs, psyDrag]); - - const toggleAll = useCallback(() => { - if (allSelected) { - useSelectionStore.getState().clearAll(); - } else { - useSelectionStore.getState().setSelectedIds(() => new Set(songs.map(s => s.id))); - } - }, [allSelected, songs]); - // ── Disc grouping ───────────────────────────────────────────────────────── const discs = new Map(); if (!sorted) { @@ -415,192 +93,33 @@ export default function AlbumTrackList({ const currentTrackId = currentTrack?.id ?? null; - // ── Sortable columns ────────────────────────────────────────────────────── - const SORTABLE_COLS = new Set(['title', 'artist', 'album', 'favorite', 'rating', 'duration']); - - const isSortable = (key: ColKey | string): key is SortKey => SORTABLE_COLS.has(key as ColKey); - - const handleHeaderClick = (key: ColKey | string) => { - if (!isSortable(key) || !onSort) return; - onSort(key); - }; - - const renderSortIndicator = (key: SortKey) => { - if (sortKey !== key) return null; - return ( - - {sortDir === 'asc' ? '▲' : '▼'} - - ); - }; - - // ── Header cell renderer ────────────────────────────────────────────────── - const renderHeaderCell = (colDef: ColDef, colIndex: number) => { - const key = colDef.key as ColKey; - const isLastCol = colIndex === visibleCols.length - 1; - const isCentered = CENTERED_COLS.has(key); - const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : ''; - const canSort = isSortable(key) && onSort; - const isActive = canSort && sortKey === key; - - if (key === 'num') { - return ( -
- { e.stopPropagation(); toggleAll(); }} - style={{ cursor: 'pointer' }} - /> - # -
- ); - } - - if (key === 'title') { - const hasNextCol = colIndex + 1 < visibleCols.length; - return ( -
handleHeaderClick(key)} - className={isActive ? 'tracklist-header-cell-active' : ''} - > -
- {label} - {canSort && renderSortIndicator(key as SortKey)} -
- {hasNextCol && ( -
startResize(e, colIndex + 1, -1)} /> - )} -
- ); - } - - const isResizable = !isLastCol; - return ( -
handleHeaderClick(key)} - className={isActive ? 'tracklist-header-cell-active' : ''} - > -
- {label} - {canSort && isSortable(key) && renderSortIndicator(key as SortKey)} -
- {isResizable && ( -
startResize(e, colIndex, 1)} /> - )} -
- ); - }; - - // ── Mobile tracklist ────────────────────────────────────────────────────── if (isMobile) { return ( -
- {discNums.map(discNum => ( -
- {isMultiDisc && ( -
- 💿 CD {discNum} -
- )} - {discs.get(discNum)!.map(song => { - const isActive = currentTrackId === song.id; - return ( -
onPlaySong(song)} - onContextMenu={e => { - e.preventDefault(); - setContextMenuSongId(song.id); - onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song'); - }} - > -
- {isActive && isPlaying ? ( - - - - ) : ( - {song.track ?? ''} - )} - {song.title} -
- {formatDuration(song.duration)} -
- ); - })} -
- ))} -
+ ); } return ( <> - {/* Column visibility picker - outside .tracklist to avoid overflow cutoff */} -
-
- - {pickerOpen && ( -
-
{t('albumDetail.columns')}
- {COLUMNS.filter(c => !c.required).map(c => { - const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : c.key; - const isOn = colVisible.has(c.key); - return ( - - ); - })} -
- -
- )} -
-
+
- {/* ── Header ── */} -
-
- {visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))} -
-
+ {/* ── Tracks ── */} {discNums.map(discNum => ( diff --git a/src/components/albumTrackList/AlbumTrackListMobile.tsx b/src/components/albumTrackList/AlbumTrackListMobile.tsx new file mode 100644 index 00000000..123e485d --- /dev/null +++ b/src/components/albumTrackList/AlbumTrackListMobile.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { AudioLines } from 'lucide-react'; +import type { SubsonicSong } from '../../api/subsonicTypes'; +import type { Track } from '../../store/playerStoreTypes'; +import { songToTrack } from '../../utils/songToTrack'; +import { formatDuration } from '../../utils/albumTrackListHelpers'; + +interface Props { + discNums: number[]; + discs: Map; + isMultiDisc: boolean; + currentTrackId: string | null; + isPlaying: boolean; + contextMenuSongId: string | null; + setContextMenuSongId: (id: string | null) => void; + onPlaySong: (song: SubsonicSong) => void; + onContextMenu: ( + x: number, + y: number, + track: Track, + type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song', + ) => void; +} + +/** + * Compact tracklist for narrow viewports. Drops the column grid + column + * picker + drag selection entirely — just a one-line row per song with + * disc group separators. Play on tap, context menu on long-press / right + * click. + */ +export function AlbumTrackListMobile({ + discNums, + discs, + isMultiDisc, + currentTrackId, + isPlaying, + contextMenuSongId, + setContextMenuSongId, + onPlaySong, + onContextMenu, +}: Props) { + return ( +
+ {discNums.map(discNum => ( +
+ {isMultiDisc && ( +
+ 💿 CD {discNum} +
+ )} + {discs.get(discNum)!.map(song => { + const isActive = currentTrackId === song.id; + return ( +
onPlaySong(song)} + onContextMenu={e => { + e.preventDefault(); + setContextMenuSongId(song.id); + onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song'); + }} + > +
+ {isActive && isPlaying ? ( + + + + ) : ( + {song.track ?? ''} + )} + {song.title} +
+ {formatDuration(song.duration)} +
+ ); + })} +
+ ))} +
+ ); +} diff --git a/src/components/albumTrackList/TrackRow.tsx b/src/components/albumTrackList/TrackRow.tsx new file mode 100644 index 00000000..0c4b898a --- /dev/null +++ b/src/components/albumTrackList/TrackRow.tsx @@ -0,0 +1,245 @@ +import React from 'react'; +import { AudioLines, ChevronRight, Heart, Play, Square } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import type { ColDef } from '../../utils/useTracklistColumns'; +import type { SubsonicSong } from '../../api/subsonicTypes'; +import type { Track } from '../../store/playerStoreTypes'; +import { songToTrack } from '../../utils/songToTrack'; +import { useSelectionStore } from '../../store/selectionStore'; +import { useThemeStore } from '../../store/themeStore'; +import { usePreviewStore } from '../../store/previewStore'; +import StarRating from '../StarRating'; +import { codecLabel, formatDuration, type ColKey } from '../../utils/albumTrackListHelpers'; + +type ContextMenuFn = ( + x: number, + y: number, + track: Track, + type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song', +) => void; + +interface TrackRowProps { + song: SubsonicSong; + globalIdx: number; + visibleCols: readonly ColDef[]; + gridStyle: React.CSSProperties; + currentTrackId: string | null; + isPlaying: boolean; + ratingValue: number; + isStarred: boolean; + inSelectMode: boolean; + isContextMenuSong: boolean; + onPlaySong: (song: SubsonicSong) => void; + onDoubleClickSong?: (song: SubsonicSong) => void; + onRate: (songId: string, rating: number) => void; + onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void; + onContextMenu: ContextMenuFn; + onToggleSelect: (id: string, globalIdx: number, shift: boolean) => void; + onDragStart: (song: SubsonicSong, me: MouseEvent) => void; + setContextMenuSongId: (id: string | null) => void; +} + +/** + * Memoised tracklist row. Subscribes to its own selection + preview state + * via primitive selectors so only this row re-renders when the user + * toggles selection or starts/stops a preview. + */ +export const TrackRow = React.memo(function TrackRow({ + song, + globalIdx, + visibleCols, + gridStyle, + currentTrackId, + isPlaying, + ratingValue, + isStarred, + inSelectMode, + isContextMenuSong, + onPlaySong, + onDoubleClickSong, + onRate, + onToggleSongStar, + onContextMenu, + onToggleSelect, + onDragStart, + setContextMenuSongId, +}: TrackRowProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const showBitrate = useThemeStore(s => s.showBitrate); + const isSelected = useSelectionStore(s => s.selectedIds.has(song.id)); + const isActive = currentTrackId === song.id; + const isPreviewing = usePreviewStore(s => s.previewingId === song.id); + const isPreviewAudioStarted = usePreviewStore(s => s.previewingId === song.id && s.audioStarted); + + const renderCell = (colDef: ColDef) => { + const key = colDef.key as ColKey; + switch (key) { + case 'num': + return ( +
+ { e.stopPropagation(); onToggleSelect(song.id, globalIdx, e.shiftKey); }} + /> + {isActive && isPlaying ? ( + + + + ) : ( + {song.track ?? '—'} + )} +
+ ); + case 'title': + return ( +
+ + + {song.title} +
+ ); + case 'artist': { + const artistRefs = song.artists && song.artists.length > 0 + ? song.artists + : [{ id: song.artistId, name: song.artist }]; + return ( +
+ {artistRefs.map((a, i) => ( + + {i > 0 &&  · } + { if (a.id) { e.stopPropagation(); navigate(`/artist/${a.id}`); } }} + > + {a.name ?? song.artist} + + + ))} +
+ ); + } + case 'favorite': + return ( +
+ +
+ ); + case 'rating': + return ( + onRate(song.id, r)} + /> + ); + case 'duration': + return ( +
+ {formatDuration(song.duration)} +
+ ); + case 'format': + return ( +
+ {(song.suffix || (showBitrate && song.bitRate)) && ( + {codecLabel(song, showBitrate)} + )} +
+ ); + case 'genre': + return ( +
+ {song.genre ?? '—'} +
+ ); + default: + return null; + } + }; + + return ( +
{ + if ((e.target as HTMLElement).closest('button, a, input')) return; + if (e.ctrlKey || e.metaKey) { + onToggleSelect(song.id, globalIdx, false); + } else if (inSelectMode) { + onToggleSelect(song.id, globalIdx, e.shiftKey); + } else { + onPlaySong(song); + } + }} + onDoubleClick={onDoubleClickSong ? e => { + if ((e.target as HTMLElement).closest('button, a, input')) return; + if (e.ctrlKey || e.metaKey || inSelectMode) return; + onDoubleClickSong(song); + } : undefined} + onContextMenu={e => { + e.preventDefault(); + setContextMenuSongId(song.id); + onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song'); + }} + role="row" + onMouseDown={e => { + if (e.button !== 0) return; + e.preventDefault(); + const sx = e.clientX, sy = e.clientY; + const onMove = (me: MouseEvent) => { + if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + onDragStart(song, me); + } + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }} + > + {visibleCols.map(colDef => renderCell(colDef))} +
+ ); +}); diff --git a/src/components/albumTrackList/TracklistColumnPicker.tsx b/src/components/albumTrackList/TracklistColumnPicker.tsx new file mode 100644 index 00000000..c63293a2 --- /dev/null +++ b/src/components/albumTrackList/TracklistColumnPicker.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { Check, ChevronDown, RotateCcw } from 'lucide-react'; +import type { TFunction } from 'i18next'; +import { COLUMNS } from '../../utils/albumTrackListHelpers'; + +interface Props { + pickerRef: React.RefObject; + pickerOpen: boolean; + setPickerOpen: (updater: (v: boolean) => boolean) => void; + colVisible: Set; + toggleColumn: (key: string) => void; + resetColumns: () => void; + t: TFunction; +} + +/** + * The column visibility dropdown that sits outside `.tracklist` so the + * popover menu can grow without being clipped by the tracklist's overflow + * box. Lists every non-required column and offers a reset-to-defaults + * button. + */ +export function TracklistColumnPicker({ + pickerRef, + pickerOpen, + setPickerOpen, + colVisible, + toggleColumn, + resetColumns, + t, +}: Props) { + return ( +
+
+ + {pickerOpen && ( +
+
{t('albumDetail.columns')}
+ {COLUMNS.filter(c => !c.required).map(c => { + const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : c.key; + const isOn = colVisible.has(c.key); + return ( + + ); + })} +
+ +
+ )} +
+
+ ); +} diff --git a/src/components/albumTrackList/TracklistHeaderRow.tsx b/src/components/albumTrackList/TracklistHeaderRow.tsx new file mode 100644 index 00000000..0c19c829 --- /dev/null +++ b/src/components/albumTrackList/TracklistHeaderRow.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import type { TFunction } from 'i18next'; +import type { ColDef } from '../../utils/useTracklistColumns'; +import { CENTERED_COLS, isSortable, type ColKey, type SortKey } from '../../utils/albumTrackListHelpers'; + +interface Props { + visibleCols: readonly ColDef[]; + gridStyle: React.CSSProperties; + sortKey?: SortKey; + sortDir?: 'asc' | 'desc'; + onSort?: (key: SortKey) => void; + allSelected: boolean; + inSelectMode: boolean; + toggleAll: () => void; + startResize: (e: React.MouseEvent, colIndex: number, direction: 1 | -1) => void; + t: TFunction; +} + +/** + * The fixed tracklist header row. Each cell is independently sortable + * (when the column is in `SORTABLE_COLS` and `onSort` is provided) and + * resizable via a 6px drop-target along its right edge. + * + * The `num` cell additionally hosts the bulk-selection toggle so that + * shift-click-style ranges anchor against the header. + */ +export function TracklistHeaderRow({ + visibleCols, + gridStyle, + sortKey, + sortDir, + onSort, + allSelected, + inSelectMode, + toggleAll, + startResize, + t, +}: Props) { + const handleHeaderClick = (key: ColKey | string) => { + if (!isSortable(key) || !onSort) return; + onSort(key); + }; + + const renderSortIndicator = (key: SortKey) => { + if (sortKey !== key) return null; + return ( + + {sortDir === 'asc' ? '▲' : '▼'} + + ); + }; + + const renderHeaderCell = (colDef: ColDef, colIndex: number) => { + const key = colDef.key as ColKey; + const isLastCol = colIndex === visibleCols.length - 1; + const isCentered = CENTERED_COLS.has(key); + const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : ''; + const canSort = isSortable(key) && onSort; + const isActive = canSort && sortKey === key; + + if (key === 'num') { + return ( +
+ { e.stopPropagation(); toggleAll(); }} + style={{ cursor: 'pointer' }} + /> + # +
+ ); + } + + if (key === 'title') { + const hasNextCol = colIndex + 1 < visibleCols.length; + return ( +
handleHeaderClick(key)} + className={isActive ? 'tracklist-header-cell-active' : ''} + > +
+ {label} + {canSort && renderSortIndicator(key as SortKey)} +
+ {hasNextCol && ( +
startResize(e, colIndex + 1, -1)} /> + )} +
+ ); + } + + const isResizable = !isLastCol; + return ( +
handleHeaderClick(key)} + className={isActive ? 'tracklist-header-cell-active' : ''} + > +
+ {label} + {canSort && isSortable(key) && renderSortIndicator(key as SortKey)} +
+ {isResizable && ( +
startResize(e, colIndex, 1)} /> + )} +
+ ); + }; + + return ( +
+
+ {visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))} +
+
+ ); +} diff --git a/src/hooks/useAlbumTrackListSelection.ts b/src/hooks/useAlbumTrackListSelection.ts new file mode 100644 index 00000000..f5e370bc --- /dev/null +++ b/src/hooks/useAlbumTrackListSelection.ts @@ -0,0 +1,101 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import type { SubsonicSong } from '../api/subsonicTypes'; +import { useSelectionStore } from '../store/selectionStore'; +import { useDragDrop } from '../contexts/DragDropContext'; +import { songToTrack } from '../utils/songToTrack'; + +interface UseAlbumTrackListSelectionArgs { + songs: SubsonicSong[]; + tracklistRef: React.RefObject; +} + +interface UseAlbumTrackListSelectionResult { + inSelectMode: boolean; + allSelected: boolean; + onToggleSelect: (id: string, globalIdx: number, shift: boolean) => void; + onDragStart: (song: SubsonicSong, me: MouseEvent) => void; + toggleAll: () => void; +} + +/** + * Bulk selection + drag wiring for `AlbumTrackList`: + * - Clears selection whenever the song list changes (album switch or + * filter applied) and on mousedown outside the tracklist. + * - `onToggleSelect` supports shift-click ranges anchored against the + * last toggled row. + * - `onDragStart` promotes a single-row drag into a multi-row drag when + * the dragged song is part of the active selection. + * + * Subscribes only to `selectedIds.size` so the host component re-renders + * once when select-mode flips on/off; per-row state stays inside + * `TrackRow`'s own primitive selector for O(1) toggles. + */ +export function useAlbumTrackListSelection({ + songs, + tracklistRef, +}: UseAlbumTrackListSelectionArgs): UseAlbumTrackListSelectionResult { + const psyDrag = useDragDrop(); + const selectedCount = useSelectionStore(s => s.selectedIds.size); + const inSelectMode = selectedCount > 0; + const allSelected = selectedCount === songs.length && songs.length > 0; + const lastSelectedIdxRef = useRef(null); + + useEffect(() => { + useSelectionStore.getState().clearAll(); + lastSelectedIdxRef.current = null; + }, [songs]); + + useEffect(() => { + if (!inSelectMode) return; + const handler = (e: MouseEvent) => { + if (tracklistRef.current && !tracklistRef.current.contains(e.target as Node)) { + useSelectionStore.getState().clearAll(); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [inSelectMode, tracklistRef]); + + const onToggleSelect = useCallback((id: string, globalIdx: number, shift: boolean) => { + useSelectionStore.getState().setSelectedIds(prev => { + const next = new Set(prev); + if (shift && lastSelectedIdxRef.current !== null) { + const from = Math.min(lastSelectedIdxRef.current, globalIdx); + const to = Math.max(lastSelectedIdxRef.current, globalIdx); + songs.slice(from, to + 1).forEach(s => next.add(s.id)); + } else { + next.has(id) ? next.delete(id) : next.add(id); + } + lastSelectedIdxRef.current = globalIdx; + return next; + }); + }, [songs]); + + const onDragStart = useCallback((song: SubsonicSong, me: MouseEvent) => { + const { selectedIds } = useSelectionStore.getState(); + if (selectedIds.has(song.id) && selectedIds.size > 1) { + const tracks = songs + .filter(s => selectedIds.has(s.id)) + .map(s => songToTrack(s)); + psyDrag.startDrag( + { data: JSON.stringify({ type: 'songs', tracks }), label: `${tracks.length} Songs` }, + me.clientX, me.clientY, + ); + } else { + psyDrag.startDrag( + { data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title }, + me.clientX, me.clientY, + ); + } + }, [songs, psyDrag]); + + const toggleAll = useCallback(() => { + if (allSelected) { + useSelectionStore.getState().clearAll(); + } else { + useSelectionStore.getState().setSelectedIds(() => new Set(songs.map(s => s.id))); + } + }, [allSelected, songs]); + + return { inSelectMode, allSelected, onToggleSelect, onDragStart, toggleAll }; +} diff --git a/src/utils/albumTrackListHelpers.ts b/src/utils/albumTrackListHelpers.ts new file mode 100644 index 00000000..bae08f93 --- /dev/null +++ b/src/utils/albumTrackListHelpers.ts @@ -0,0 +1,39 @@ +import type { ColDef } from './useTracklistColumns'; + +export function formatDuration(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + return `${m}:${s.toString().padStart(2, '0')}`; +} + +export function codecLabel(song: { suffix?: string; bitRate?: number }, showBitrate: boolean): string { + const parts: string[] = []; + if (song.suffix) parts.push(song.suffix.toUpperCase()); + if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`); + return parts.join(' · '); +} + +export const COLUMNS: readonly ColDef[] = [ + { key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true }, + { key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true }, + { key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false }, + { key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false }, + { key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false }, + { key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false }, + { key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false }, + { key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false }, +]; + +export type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre'; + +export const CENTERED_COLS = new Set(['favorite', 'rating', 'duration']); + +export type SortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration'; + +export const SORTABLE_COLS = new Set(['title', 'artist', 'album', 'favorite', 'rating', 'duration']); + +export function isSortable(key: ColKey | string): key is SortKey { + return SORTABLE_COLS.has(key as ColKey); +}