diff --git a/src/components/SongRow.tsx b/src/components/SongRow.tsx new file mode 100644 index 00000000..cfffc486 --- /dev/null +++ b/src/components/SongRow.tsx @@ -0,0 +1,116 @@ +import React, { memo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Play, ListPlus } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { SubsonicSong } from '../api/subsonic'; +import { usePlayerStore, songToTrack } from '../store/playerStore'; +import { enqueueAndPlay } from '../utils/playSong'; +import { useDragDrop } from '../contexts/DragDropContext'; + +function fmtDuration(s: number): string { + if (!s || !isFinite(s)) return '–'; + const m = Math.floor(s / 60); + const sec = Math.floor(s % 60); + return `${m}:${sec.toString().padStart(2, '0')}`; +} + +interface Props { + song: SubsonicSong; +} + +function SongRow({ song }: Props) { + const navigate = useNavigate(); + const enqueue = usePlayerStore(s => s.enqueue); + const openContextMenu = usePlayerStore(s => s.openContextMenu); + const isCurrent = usePlayerStore(s => s.currentTrack?.id === song.id); + const psyDrag = useDragDrop(); + + return ( +
enqueueAndPlay(song)} + onContextMenu={(e) => { + e.preventDefault(); + openContextMenu(e.clientX, e.clientY, song, 'song'); + }} + onMouseDown={(e) => { + if (e.button !== 0) return; + const sx = e.clientX, sy = e.clientY; + const track = songToTrack(song); + 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); + psyDrag.startDrag( + { data: JSON.stringify({ type: 'song', track }), label: song.title }, + me.clientX, me.clientY, + ); + } + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }} + > +
+ + +
+
{song.title}
+
+ { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }} + title={song.artist} + >{song.artist} +
+
+ {song.albumId ? ( + { e.stopPropagation(); navigate(`/album/${song.albumId}`); }} + title={song.album} + >{song.album} + ) : {song.album}} +
+
+ {song.genre ?? '—'} +
+
{fmtDuration(song.duration)}
+
+ ); +} + +/** Column header with the same grid as . Optional — pages can render it above the list. */ +export function SongListHeader() { + const { t } = useTranslation(); + return ( +
+
+
{t('albumDetail.trackTitle')}
+
{t('albumDetail.trackArtist')}
+
{t('albumDetail.trackAlbum')}
+
{t('randomMix.trackGenre')}
+
{t('albumDetail.trackDuration')}
+
+ ); +} + +export default memo(SongRow); diff --git a/src/components/VirtualSongList.tsx b/src/components/VirtualSongList.tsx index e5174bd2..fb155c59 100644 --- a/src/components/VirtualSongList.tsx +++ b/src/components/VirtualSongList.tsx @@ -1,12 +1,10 @@ -import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Play, Search as SearchIcon, X, ListPlus } from 'lucide-react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Search as SearchIcon, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useVirtualizer } from '@tanstack/react-virtual'; import { SubsonicSong, searchSongsPaged } from '../api/subsonic'; import { ndListSongs } from '../api/navidromeBrowse'; -import { usePlayerStore, songToTrack } from '../store/playerStore'; -import { enqueueAndPlay } from '../utils/playSong'; +import SongRow, { SongListHeader } from './SongRow'; const PAGE_SIZE = 50; const SEARCH_DEBOUNCE_MS = 300; @@ -29,72 +27,6 @@ async function fetchSongPage(query: string, offset: number): Promise s.enqueue); - const openContextMenu = usePlayerStore(s => s.openContextMenu); - - return ( -
enqueueAndPlay(song)} - onContextMenu={(e) => { - e.preventDefault(); - openContextMenu(e.clientX, e.clientY, song, 'song'); - }} - > -
- - -
-
- {song.title} - { - if (!song.artistId) return; - e.stopPropagation(); - navigate(`/artist/${song.artistId}`); - }} - >{song.artist} -
-
- {song.albumId ? ( - { e.stopPropagation(); navigate(`/album/${song.albumId}`); }} - >{song.album} - ) : {song.album}} -
-
{fmtDuration(song.duration)}
-
- ); -}); - interface Props { title?: string; emptyBrowseText?: string; @@ -110,8 +42,6 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) { const [hasMore, setHasMore] = useState(true); const [browseUnsupported, setBrowseUnsupported] = useState(false); - const currentTrackId = usePlayerStore(s => s.currentTrack?.id ?? null); - const scrollParentRef = useRef(null); const requestSeqRef = useRef(0); @@ -250,8 +180,10 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) { {emptyBrowseText ?? t('tracks.browseUnsupported')}
) : ( -
-
+ <> + +
+
{virtualizer.getVirtualItems().map(vi => { const song = songs[vi.index]; if (!song) return null; @@ -267,21 +199,21 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) { transform: `translateY(${vi.start}px)`, }} > -
); })}
- {loading && ( -
-
- {t('common.loadingMore')} -
- )} -
+ {loading && ( +
+
+ {t('common.loadingMore')} +
+ )} +
+ )} ); diff --git a/src/pages/AdvancedSearch.tsx b/src/pages/AdvancedSearch.tsx index 8ee5c83c..27b84ce6 100644 --- a/src/pages/AdvancedSearch.tsx +++ b/src/pages/AdvancedSearch.tsx @@ -1,18 +1,16 @@ -import React, { useEffect, useState } from 'react'; -import { useSearchParams, useNavigate } from 'react-router-dom'; -import { Play, SlidersVertical } from 'lucide-react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { SlidersVertical } from 'lucide-react'; import { - search, getGenres, getAlbumsByGenre, getAlbumList, getRandomSongs, + search, searchSongsPaged, getGenres, getAlbumsByGenre, getAlbumList, getRandomSongs, SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong, } from '../api/subsonic'; -import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; import AlbumRow from '../components/AlbumRow'; import ArtistRow from '../components/ArtistRow'; +import SongRow, { SongListHeader } from '../components/SongRow'; import CustomSelect from '../components/CustomSelect'; -import { useDragDrop } from '../contexts/DragDropContext'; import { useAuthStore } from '../store/authStore'; -import { useShallow } from 'zustand/react/shallow'; type ResultType = 'all' | 'artists' | 'albums' | 'songs'; @@ -34,23 +32,6 @@ export default function AdvancedSearch() { const { t } = useTranslation(); const [params] = useSearchParams(); const qFromUrl = params.get('q') ?? ''; - const navigate = useNavigate(); - const psyDrag = useDragDrop(); - - const { playTrack, openContextMenu } = usePlayerStore( - useShallow(s => ({ - playTrack: s.playTrack, - openContextMenu: s.openContextMenu, - })) - ); - - const [contextMenuSongId, setContextMenuSongId] = useState(null); - const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); - - useEffect(() => { - if (!contextMenuOpen) setContextMenuSongId(null); - }, [contextMenuOpen]); - const [query, setQuery] = useState(params.get('q') ?? ''); const [genre, setGenre] = useState(''); const [yearFrom, setYearFrom] = useState(''); @@ -66,10 +47,35 @@ export default function AdvancedSearch() { const [genreNote, setGenreNote] = useState(false); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + // Pagination — only the free-text-query branch uses search3 with offset + const SONGS_INITIAL = 100; + const SONGS_PAGE_SIZE = 50; + const [activeSearch, setActiveSearch] = useState(null); + const [songsServerOffset, setSongsServerOffset] = useState(0); + const [songsHasMore, setSongsHasMore] = useState(false); + const [loadingMoreSongs, setLoadingMoreSongs] = useState(false); + const songsSentinelRef = useRef(null); + + const applySongFilters = ( + list: SubsonicSong[], + g: string, + from: number | null, + to: number | null, + ): SubsonicSong[] => { + let r = list; + if (g) r = r.filter(s => s.genre?.toLowerCase() === g.toLowerCase()); + if (from !== null) r = r.filter(s => !s.year || s.year >= from); + if (to !== null) r = r.filter(s => !s.year || s.year <= to); + return r; + }; + const runSearch = async (opts: SearchOpts) => { setLoading(true); setHasSearched(true); setGenreNote(false); + setActiveSearch(opts); + setSongsServerOffset(0); + setSongsHasMore(false); const { query: q, genre: g, yearFrom: yf, yearTo: yt, resultType: rt } = opts; const from = yf ? parseInt(yf) : null; const to = yt ? parseInt(yt) : null; @@ -80,23 +86,25 @@ export default function AdvancedSearch() { try { if (q.trim()) { - const r = await search(q.trim(), { artistCount: 30, albumCount: 50, songCount: 100 }); + const r = await search(q.trim(), { artistCount: 30, albumCount: 50, songCount: SONGS_INITIAL }); artists = r.artists; albums = r.albums; - songs = r.songs; + songs = applySongFilters(r.songs, g, from, to); if (g) { albums = albums.filter(a => a.genre?.toLowerCase() === g.toLowerCase()); - songs = songs.filter(s => s.genre?.toLowerCase() === g.toLowerCase()); } if (from !== null) { albums = albums.filter(a => !a.year || a.year >= from); - songs = songs.filter(s => !s.year || s.year >= from); } if (to !== null) { albums = albums.filter(a => !a.year || a.year <= to); - songs = songs.filter(s => !s.year || s.year <= to); } + + // Only the free-text branch supports server-side pagination via search3 offset. + // If the server returned a full page, more probably exist. + setSongsServerOffset(r.songs.length); + setSongsHasMore(r.songs.length === SONGS_INITIAL); } else if (g) { const [albumRes, songRes] = await Promise.all([ rt === 'songs' || rt === 'artists' ? Promise.resolve([]) : getAlbumsByGenre(g, 50), @@ -131,6 +139,39 @@ export default function AdvancedSearch() { if (qFromUrl) runSearch({ query: qFromUrl, genre: '', yearFrom: '', yearTo: '', resultType: 'all' }); }, [musicLibraryFilterVersion, qFromUrl]); + const loadMoreSongs = useCallback(async () => { + if (loadingMoreSongs || !songsHasMore) return; + if (!activeSearch || !activeSearch.query.trim()) return; + setLoadingMoreSongs(true); + try { + const q = activeSearch.query.trim(); + const g = activeSearch.genre; + const from = activeSearch.yearFrom ? parseInt(activeSearch.yearFrom) : null; + const to = activeSearch.yearTo ? parseInt(activeSearch.yearTo) : null; + const page = await searchSongsPaged(q, SONGS_PAGE_SIZE, songsServerOffset); + const filtered = applySongFilters(page, g, from, to); + setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...filtered] } : prev); + setSongsServerOffset(o => o + page.length); + // No more pages when the server returned a non-full page (regardless of how many survived filtering). + if (page.length < SONGS_PAGE_SIZE) setSongsHasMore(false); + } catch { + setSongsHasMore(false); + } finally { + setLoadingMoreSongs(false); + } + }, [loadingMoreSongs, songsHasMore, activeSearch, songsServerOffset]); + + // IntersectionObserver on the bottom sentinel — fires loadMoreSongs as it nears the viewport. + useEffect(() => { + const el = songsSentinelRef.current; + if (!el) return; + const obs = new IntersectionObserver(entries => { + if (entries[0]?.isIntersecting) loadMoreSongs(); + }, { rootMargin: '600px' }); + obs.observe(el); + return () => obs.disconnect(); + }, [loadMoreSongs]); + const handleSubmit = (e?: React.FormEvent) => { e?.preventDefault(); runSearch({ query, genre, yearFrom, yearTo, resultType }); @@ -279,90 +320,22 @@ export default function AdvancedSearch() { {results && results.songs.length > 0 && (

- {t('search.songs')} ({results.songs.length}) + {t('search.songs')} {genreNote && ( — {t('search.advancedGenreNote')} )}

-
-
- - {t('randomMix.trackTitle')} - {t('randomMix.trackArtist')} - {t('randomMix.trackAlbum')} - {t('randomMix.trackGenre')} - {t('randomMix.trackDuration')} + + {results.songs.map(song => ( + + ))} + {songsHasMore && ( +
+ {loadingMoreSongs &&
}
- {results.songs.map(song => { - const track = songToTrack(song); - return ( -
playTrack(track, results.songs.map(songToTrack))} - role="row" - onContextMenu={e => { - e.preventDefault(); - setContextMenuSongId(song.id); - openContextMenu(e.clientX, e.clientY, track, 'song'); - }} - 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); - psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY); - } - }; - const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }} - > - -
- {song.title} -
-
- song.artistId && navigate(`/artist/${song.artistId}`)} - > - {song.artist} - -
-
- navigate(`/album/${song.albumId}`)} - > - {song.album} - -
-
- {song.genre ?? '—'} -
- - {Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')} - -
- ); - })} -
+ )}
)}
diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index e854edc6..8229ff4f 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -1,19 +1,15 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { Play, Search } from 'lucide-react'; -import { search, SearchResults as ISearchResults, SubsonicSong } from '../api/subsonic'; -import { usePlayerStore, songToTrack } from '../store/playerStore'; +import { Search } from 'lucide-react'; +import { search, searchSongsPaged, SearchResults as ISearchResults } from '../api/subsonic'; import AlbumRow from '../components/AlbumRow'; import ArtistRow from '../components/ArtistRow'; +import SongRow, { SongListHeader } from '../components/SongRow'; import { useTranslation } from 'react-i18next'; -import { useDragDrop } from '../contexts/DragDropContext'; import { useAuthStore } from '../store/authStore'; -import { useThemeStore } from '../store/themeStore'; -import { useShallow } from 'zustand/react/shallow'; -function formatDuration(s: number) { - return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`; -} +const SONGS_INITIAL = 50; +const SONGS_PAGE_SIZE = 50; export default function SearchResults() { const { t } = useTranslation(); @@ -21,39 +17,52 @@ export default function SearchResults() { const query = params.get('q') ?? ''; const [results, setResults] = useState(null); const [loading, setLoading] = useState(false); + const [songsServerOffset, setSongsServerOffset] = useState(0); + const [songsHasMore, setSongsHasMore] = useState(false); + const [loadingMoreSongs, setLoadingMoreSongs] = useState(false); + const songsSentinelRef = useRef(null); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); - const showBitrate = useThemeStore(s => s.showBitrate); - const psyDrag = useDragDrop(); - - const { playTrack, enqueue, openContextMenu, currentTrack } = usePlayerStore( - useShallow(s => ({ - playTrack: s.playTrack, - enqueue: s.enqueue, - openContextMenu: s.openContextMenu, - currentTrack: s.currentTrack, - })) - ); - - const [contextMenuSongId, setContextMenuSongId] = useState(null); - const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); - - useEffect(() => { - if (!contextMenuOpen) setContextMenuSongId(null); - }, [contextMenuOpen]); useEffect(() => { + setSongsServerOffset(0); + setSongsHasMore(false); if (!query.trim()) { setResults(null); return; } setLoading(true); - search(query, { artistCount: 20, albumCount: 20, songCount: 50 }) - .then(r => setResults(r)) + search(query, { artistCount: 20, albumCount: 20, songCount: SONGS_INITIAL }) + .then(r => { + setResults(r); + setSongsServerOffset(r.songs.length); + setSongsHasMore(r.songs.length === SONGS_INITIAL); + }) .finally(() => setLoading(false)); }, [query, musicLibraryFilterVersion]); - const hasResults = results && (results.artists.length || results.albums.length || results.songs.length); + const loadMoreSongs = useCallback(async () => { + if (loadingMoreSongs || !songsHasMore || !query.trim()) return; + setLoadingMoreSongs(true); + try { + const page = await searchSongsPaged(query.trim(), SONGS_PAGE_SIZE, songsServerOffset); + setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...page] } : prev); + setSongsServerOffset(o => o + page.length); + if (page.length < SONGS_PAGE_SIZE) setSongsHasMore(false); + } catch { + setSongsHasMore(false); + } finally { + setLoadingMoreSongs(false); + } + }, [loadingMoreSongs, songsHasMore, query, songsServerOffset]); - const playSong = (song: SubsonicSong, list: SubsonicSong[]) => { - playTrack(songToTrack(song), list.map(songToTrack)); - }; + useEffect(() => { + const el = songsSentinelRef.current; + if (!el) return; + const obs = new IntersectionObserver(entries => { + if (entries[0]?.isIntersecting) loadMoreSongs(); + }, { rootMargin: '600px' }); + obs.observe(el); + return () => obs.disconnect(); + }, [loadMoreSongs]); + + const hasResults = results && (results.artists.length || results.albums.length || results.songs.length); return (
@@ -83,69 +92,17 @@ export default function SearchResults() { )} {results.songs.length > 0 && ( -
-
-

{t('search.songs')}

-
-
-
-
-
{t('albumDetail.trackTitle')}
-
{t('albumDetail.trackArtist')}
-
{t('search.album')}
-
{t('albumDetail.trackFormat')}
-
{t('albumDetail.trackDuration')}
+
+

{t('search.songs')}

+ + {results.songs.map(song => ( + + ))} + {songsHasMore && ( +
+ {loadingMoreSongs &&
}
- {results.songs.map(song => ( -
playSong(song, results.songs)} - onContextMenu={e => { - e.preventDefault(); - setContextMenuSongId(song.id); - openContextMenu(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 track = songToTrack(song); - 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); - psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY); - } - }; - const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }} - > - -
- {song.title} -
-
{song.artist}
-
{song.album}
- - {[song.suffix?.toUpperCase(), showBitrate && song.bitRate ? `${song.bitRate} kbps` : ''].filter(Boolean).join(' · ')} - - - {formatDuration(song.duration)} - -
- ))} -
+ )}
)} diff --git a/src/styles/tracks.css b/src/styles/tracks.css index edeb431b..435d0a65 100644 --- a/src/styles/tracks.css +++ b/src/styles/tracks.css @@ -392,88 +392,77 @@ color: var(--text-muted); } -/* ─ Row layout ─ */ +/* ─ Shared SongRow (used by Tracks Hub, SearchResults, AdvancedSearch) ─ */ -.virtual-song-row { +.song-list-row { display: grid; - grid-template-columns: 64px minmax(0, 1.6fr) minmax(0, 1.2fr) 56px; + grid-template-columns: 64px minmax(0, 1.5fr) minmax(0, 1fr) minmax(0, 1fr) 110px 56px; gap: var(--space-3); align-items: center; height: 52px; padding: 0 var(--space-3); + font-size: 13px; cursor: default; transition: background var(--transition-fast); border-bottom: 1px solid var(--border-subtle); } -.virtual-song-row:hover { +.song-list-row:hover { background: var(--bg-hover); } -.virtual-song-row.is-current { +.song-list-row.is-current { background: color-mix(in srgb, var(--accent) 14%, transparent); } -.virtual-song-row.is-current .virtual-song-title { +.song-list-row.is-current .song-list-row-title { color: var(--accent); } -.virtual-song-cell { +.song-list-row--header { + height: 36px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + cursor: default; + background: transparent; + border-bottom: 1px solid var(--border-subtle); +} + +.song-list-row--header:hover { + background: transparent; +} + +.song-list-row-cell { min-width: 0; } -.virtual-song-cell-actions-left { +.song-list-row-actions { display: flex; align-items: center; gap: 4px; } -.virtual-song-cell-title { - display: flex; - flex-direction: column; - min-width: 0; - gap: 1px; -} - -.virtual-song-title { - font-size: 13px; +.song-list-row-title { font-weight: 500; color: var(--text-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } -.virtual-song-artist { - font-size: 11px; - color: var(--text-secondary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.virtual-song-cell-album { - font-size: 12px; - color: var(--text-secondary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.virtual-song-cell-duration { - font-size: 12px; +.song-list-row-genre { color: var(--text-muted); - font-variant-numeric: tabular-nums; + font-size: 12px; +} + +.song-list-row-duration { text-align: right; + font-variant-numeric: tabular-nums; + color: var(--text-muted); + font-size: 12px; } -.virtual-song-cell-actions { - display: flex; - align-items: center; - justify-content: center; -} - -.virtual-song-action-btn { +.song-list-row-btn { background: transparent; border: none; color: var(--text-muted); @@ -488,21 +477,21 @@ transition: background var(--transition-fast), color var(--transition-fast); } -.virtual-song-action-btn:hover { +.song-list-row-btn:hover { background: var(--bg-hover); color: var(--text-primary); } -.virtual-song-action-btn--play { +.song-list-row-btn--play { color: var(--accent); } -.virtual-song-action-btn--play:hover { +.song-list-row-btn--play:hover { background: var(--accent); color: var(--ctp-crust, #11111b); } -.virtual-song-row.is-current .virtual-song-action-btn--play { +.song-list-row.is-current .song-list-row-btn--play { color: var(--accent); } @@ -525,11 +514,12 @@ justify-content: center; } - .virtual-song-row { - grid-template-columns: 64px minmax(0, 1fr) 56px; + .song-list-row { + grid-template-columns: 64px minmax(0, 1.5fr) minmax(0, 1fr) 56px; } - .virtual-song-cell-album { + .song-list-row-cell:nth-child(4), /* album */ + .song-list-row-cell:nth-child(5) { /* genre */ display: none; } }