soulseek: let a filtered result expand back to its whole folder
Filtering a big search is how you find an album, not just how you hide files: you type one track you know, and the folder that comes back is the one you want. But the filter had also stripped that folder down to the one track, and "download folder" then queued only that track — so finding the album and losing it were the same act. A folder now keeps its whole self alongside its matching files, and says "3 of 24". Expanding shows the album, keeps the matched track highlighted, and widens every download button in it to the full folder. A peer whose other folders matched nothing can be expanded the same way, since the album you found usually sits next to the rest of the artist. Nothing queues what isn't on screen: each button downloads exactly what is shown under it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,20 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { toast } from 'sonner';
|
||||
import { ArrowLeft, Download, Lock, Loader2, Folder, Zap, Users, Search, X, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Download,
|
||||
Lock,
|
||||
Loader2,
|
||||
Folder,
|
||||
Zap,
|
||||
Users,
|
||||
Search,
|
||||
X,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList, Pill } from './Cards';
|
||||
import { UserMenu } from './UserMenu';
|
||||
import {
|
||||
@@ -24,6 +37,10 @@ import {
|
||||
// each user a collapsible card with its upload speed / free-slot / queue / file-count, and per-file plus
|
||||
// per-folder download actions.
|
||||
//
|
||||
// Filtering is a way of *finding* a folder as much as of hiding files, so nothing it hides is out of
|
||||
// reach: a matching folder expands to the whole album in place, and a matching peer expands to the rest
|
||||
// of its folders. Download buttons always queue exactly what is on screen.
|
||||
//
|
||||
// Caching is stale-while-revalidate: the cached grouping paints immediately, then we always re-fetch in
|
||||
// the background. Never serve the cache alone — opening a search before its first responses land used to
|
||||
// freeze that empty snapshot forever. While the search is still running we also poll, so results fill in
|
||||
@@ -56,7 +73,18 @@ const EMPTY_FILTERS: Filters = { text: '', freeOnly: false, hideLocked: false, e
|
||||
const filtersActive = (f: Filters) =>
|
||||
f.text.trim() !== '' || f.freeOnly || f.hideLocked || f.exts.size > 0 || f.minBitrate !== null;
|
||||
|
||||
const applyFilters = (users: ResultUser[], f: Filters): ResultUser[] => {
|
||||
// Filtering narrows *files*, but the reason to filter is usually to find a folder: you type one track
|
||||
// name you know to pick your album out of 300 unrelated ones. So a filtered folder keeps a reference to
|
||||
// the whole folder alongside its matching files, and can be expanded back to the full album in place —
|
||||
// otherwise finding it would be the same act as hiding the rest of it.
|
||||
type FolderMatch = { folder: ResultFolder; matched: ResultFile[]; matchedSize: number };
|
||||
type UserMatch = { user: ResultUser; folders: FolderMatch[]; matchedCount: number };
|
||||
|
||||
const folderKey = (username: string, path: string) => `${username}::${path}`;
|
||||
// Same reveal set, for "this peer's folders that matched nothing" — the discography around the album.
|
||||
const userKey = (username: string) => `user::${username}`;
|
||||
|
||||
const applyFilters = (users: ResultUser[], f: Filters): UserMatch[] => {
|
||||
const terms = f.text.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const pos = terms.filter((t) => !t.startsWith('-'));
|
||||
const neg = terms
|
||||
@@ -72,16 +100,16 @@ const applyFilters = (users: ResultUser[], f: Filters): ResultUser[] => {
|
||||
if (neg.some((t) => hay.includes(t))) return false;
|
||||
return true;
|
||||
};
|
||||
const out: ResultUser[] = [];
|
||||
const out: UserMatch[] = [];
|
||||
for (const user of users) {
|
||||
if (f.freeOnly && !user.hasFreeUploadSlot) continue;
|
||||
const folders: ResultFolder[] = [];
|
||||
const folders: FolderMatch[] = [];
|
||||
for (const folder of user.folders) {
|
||||
const files = folder.files.filter(fileOk);
|
||||
if (files.length) folders.push({ ...folder, files, size: files.reduce((n, x) => n + x.size, 0) });
|
||||
const matched = folder.files.filter(fileOk);
|
||||
if (matched.length) folders.push({ folder, matched, matchedSize: matched.reduce((n, x) => n + x.size, 0) });
|
||||
}
|
||||
if (folders.length) {
|
||||
out.push({ ...user, folders, fileCount: folders.reduce((n, fd) => n + fd.files.length, 0) });
|
||||
out.push({ user, folders, matchedCount: folders.reduce((n, fd) => n + fd.matched.length, 0) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -105,6 +133,8 @@ export const SearchResults = ({ searchId, initial, onBack }: SearchResultsProps)
|
||||
const [open, setOpen] = useState<Set<string>>(() => new Set(cachedInit?.map((u) => u.username)));
|
||||
const [queued, setQueued] = useState<Set<string>>(new Set());
|
||||
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
|
||||
// Folders the reader has expanded past the filter, keyed username::path.
|
||||
const [revealed, setRevealed] = useState<Set<string>>(new Set());
|
||||
// Usernames we've already auto-expanded — lets a revalidation expand newcomers without re-opening
|
||||
// the cards the reader deliberately collapsed.
|
||||
const seenRef = useRef<Set<string>>(new Set(cachedInit?.map((u) => u.username) ?? []));
|
||||
@@ -165,6 +195,7 @@ export const SearchResults = ({ searchId, initial, onBack }: SearchResultsProps)
|
||||
pollsRef.current = 0;
|
||||
setUsers(cached);
|
||||
setOpen(new Set(cached?.map((u) => u.username)));
|
||||
setRevealed(new Set());
|
||||
setError(null);
|
||||
// Assume running until the summary says otherwise — a search opened straight after being created has
|
||||
// no row to tell us, and guessing "complete" would mean never polling for its first responses.
|
||||
@@ -195,6 +226,14 @@ export const SearchResults = ({ searchId, initial, onBack }: SearchResultsProps)
|
||||
return next;
|
||||
});
|
||||
|
||||
const toggleReveal = (key: string) =>
|
||||
setRevealed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
const queue = async (username: string, files: ResultFile[]) => {
|
||||
const usable = files.filter((f) => !f.isLocked);
|
||||
if (usable.length === 0) return;
|
||||
@@ -225,7 +264,7 @@ export const SearchResults = ({ searchId, initial, onBack }: SearchResultsProps)
|
||||
const setMinBitrate = (n: number) => setFilters((f) => ({ ...f, minBitrate: f.minBitrate === n ? null : n }));
|
||||
|
||||
const shown = filtered ? filtered.slice(0, MAX_USERS) : [];
|
||||
const totalFiles = filtered?.reduce((n, u) => n + u.fileCount, 0) ?? 0;
|
||||
const totalFiles = filtered?.reduce((n, u) => n + u.matchedCount, 0) ?? 0;
|
||||
const anyFilter = filtersActive(filters);
|
||||
|
||||
return (
|
||||
@@ -343,14 +382,16 @@ export const SearchResults = ({ searchId, initial, onBack }: SearchResultsProps)
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{shown.map((user) => (
|
||||
{shown.map((match) => (
|
||||
<UserCard
|
||||
key={user.username}
|
||||
user={user}
|
||||
open={open.has(user.username)}
|
||||
onToggle={() => toggle(user.username)}
|
||||
key={match.user.username}
|
||||
match={match}
|
||||
open={open.has(match.user.username)}
|
||||
onToggle={() => toggle(match.user.username)}
|
||||
queued={queued}
|
||||
onDownload={queue}
|
||||
revealed={revealed}
|
||||
onToggleReveal={toggleReveal}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -360,15 +401,35 @@ export const SearchResults = ({ searchId, initial, onBack }: SearchResultsProps)
|
||||
};
|
||||
|
||||
type UserCardProps = {
|
||||
user: ResultUser;
|
||||
match: UserMatch;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
queued: Set<string>;
|
||||
onDownload: (username: string, files: ResultFile[]) => void;
|
||||
revealed: Set<string>;
|
||||
onToggleReveal: (key: string) => void;
|
||||
};
|
||||
|
||||
const UserCard = ({ user, open, onToggle, queued, onDownload }: UserCardProps) => {
|
||||
const allFiles = user.folders.flatMap((f) => f.files);
|
||||
const UserCard = ({ match, open, onToggle, queued, onDownload, revealed, onToggleReveal }: UserCardProps) => {
|
||||
const { user, folders, matchedCount } = match;
|
||||
// A peer with the album usually has more of the artist, and the filter hid all of it. Expanding brings
|
||||
// the non-matching folders back, each already whole.
|
||||
const allFolders = revealed.has(userKey(user.username));
|
||||
const hiddenFolders = user.folders.length - folders.length;
|
||||
const visible: FolderMatch[] = allFolders
|
||||
? user.folders.map(
|
||||
(folder) =>
|
||||
folders.find((fm) => fm.folder.path === folder.path) ?? {
|
||||
folder,
|
||||
matched: folder.files,
|
||||
matchedSize: folder.size,
|
||||
},
|
||||
)
|
||||
: folders;
|
||||
// Every action queues what is on screen, expanded folders included — never the hidden remainder.
|
||||
const shownFiles = visible.flatMap((fm) =>
|
||||
revealed.has(folderKey(user.username, fm.folder.path)) ? fm.folder.files : fm.matched,
|
||||
);
|
||||
const meta = (
|
||||
<>
|
||||
{user.hasFreeUploadSlot ? (
|
||||
@@ -381,7 +442,11 @@ const UserCard = ({ user, open, onToggle, queued, onDownload }: UserCardProps) =
|
||||
</span>
|
||||
)}
|
||||
{user.uploadSpeed > 0 && <span>{formatSpeed(user.uploadSpeed)}</span>}
|
||||
<span className="tabular-nums">{user.fileCount.toLocaleString()} files</span>
|
||||
<span className="tabular-nums">
|
||||
{matchedCount < user.fileCount
|
||||
? `${matchedCount.toLocaleString()} of ${user.fileCount.toLocaleString()} files`
|
||||
: `${user.fileCount.toLocaleString()} files`}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
@@ -389,22 +454,43 @@ const UserCard = ({ user, open, onToggle, queued, onDownload }: UserCardProps) =
|
||||
<CardHeader open={open} onToggle={onToggle} titleMenu={<UserMenu username={user.username} />} meta={meta} />
|
||||
{open && (
|
||||
<CardBody>
|
||||
{user.folders.map((folder) => (
|
||||
{visible.map((fm) => (
|
||||
<FolderBlock
|
||||
key={folder.path}
|
||||
key={fm.folder.path}
|
||||
username={user.username}
|
||||
folder={folder}
|
||||
match={fm}
|
||||
revealed={revealed.has(folderKey(user.username, fm.folder.path))}
|
||||
onToggleReveal={() => onToggleReveal(folderKey(user.username, fm.folder.path))}
|
||||
queued={queued}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
))}
|
||||
{allFiles.length > 1 && (
|
||||
{hiddenFolders > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDownload(user.username, allFiles)}
|
||||
onClick={() => onToggleReveal(userKey(user.username))}
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg border border-dashed border-white/10 py-1.5 text-xs text-zinc-400 transition hover:bg-white/5 hover:text-zinc-100"
|
||||
>
|
||||
{allFolders ? (
|
||||
<>
|
||||
<ChevronUp className="h-3.5 w-3.5" /> Show only the {folders.length} matching{' '}
|
||||
{folders.length === 1 ? 'folder' : 'folders'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3.5 w-3.5" /> Show this peer's other {hiddenFolders}{' '}
|
||||
{hiddenFolders === 1 ? 'folder' : 'folders'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{shownFiles.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDownload(user.username, shownFiles)}
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.02] py-1.5 text-xs text-zinc-300 transition hover:bg-white/5 hover:text-zinc-100"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" /> Download all ({allFiles.filter((f) => !f.isLocked).length})
|
||||
<Download className="h-3.5 w-3.5" /> Download all ({shownFiles.filter((f) => !f.isLocked).length})
|
||||
</button>
|
||||
)}
|
||||
</CardBody>
|
||||
@@ -415,14 +501,20 @@ const UserCard = ({ user, open, onToggle, queued, onDownload }: UserCardProps) =
|
||||
|
||||
type FolderBlockProps = {
|
||||
username: string;
|
||||
folder: ResultFolder;
|
||||
match: FolderMatch;
|
||||
revealed: boolean;
|
||||
onToggleReveal: () => void;
|
||||
queued: Set<string>;
|
||||
onDownload: (username: string, files: ResultFile[]) => void;
|
||||
};
|
||||
|
||||
const FolderBlock = ({ username, folder, queued, onDownload }: FolderBlockProps) => {
|
||||
const FolderBlock = ({ username, match, revealed, onToggleReveal, queued, onDownload }: FolderBlockProps) => {
|
||||
const { folder, matched, matchedSize } = match;
|
||||
const [open, setOpen] = useState(true);
|
||||
const unlocked = folder.files.filter((f) => !f.isLocked).length;
|
||||
const files = revealed ? folder.files : matched;
|
||||
const hidden = folder.files.length - matched.length;
|
||||
const matchedNames = revealed && hidden > 0 ? new Set(matched.map((f) => f.filename)) : null;
|
||||
const unlocked = files.filter((f) => !f.isLocked).length;
|
||||
return (
|
||||
<SubCard>
|
||||
<SubCardHeader
|
||||
@@ -431,13 +523,15 @@ const FolderBlock = ({ username, folder, queued, onDownload }: FolderBlockProps)
|
||||
icon={<Folder className="h-3.5 w-3.5 shrink-0 text-zinc-500" />}
|
||||
label={folder.label}
|
||||
title={folder.path}
|
||||
meta={`${folder.files.length} · ${formatSize(folder.size)}`}
|
||||
meta={`${hidden > 0 && !revealed ? `${files.length} of ${folder.files.length}` : files.length} · ${formatSize(
|
||||
revealed ? folder.size : matchedSize,
|
||||
)}`}
|
||||
action={
|
||||
folder.files.length > 1 ? (
|
||||
files.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDownload(username, folder.files)}
|
||||
title="Download folder"
|
||||
onClick={() => onDownload(username, files)}
|
||||
title={revealed || hidden === 0 ? 'Download folder' : `Download the ${files.length} matching files`}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-zinc-400 transition hover:bg-white/10 hover:text-zinc-100"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
@@ -447,15 +541,43 @@ const FolderBlock = ({ username, folder, queued, onDownload }: FolderBlockProps)
|
||||
/>
|
||||
{open && (
|
||||
<>
|
||||
{/* The point of the whole exercise: the track you filtered for has found the album, so open the
|
||||
album. Revealing also widens every download button below to the full folder. */}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleReveal}
|
||||
className="flex w-full items-center justify-center gap-1.5 border-b border-white/5 bg-white/[0.02] py-1.5 text-xs text-zinc-400 transition hover:bg-white/5 hover:text-zinc-100"
|
||||
>
|
||||
{revealed ? (
|
||||
<>
|
||||
<ChevronUp className="h-3.5 w-3.5" /> Show only the {matched.length} matching
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3.5 w-3.5" /> Show the whole folder ({folder.files.length} files)
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<RowList>
|
||||
{folder.files.map((file) => {
|
||||
{files.map((file) => {
|
||||
const isQueued = queued.has(fileKey(username, file.filename));
|
||||
const duration = formatDuration(file.length);
|
||||
// In an expanded folder, keep the file you filtered for legible among its neighbours.
|
||||
const isMatch = !matchedNames || matchedNames.has(file.filename);
|
||||
return (
|
||||
<div key={file.filename} className="flex items-center gap-3 px-3 py-1.5 pl-8 text-sm text-zinc-200">
|
||||
<div
|
||||
key={file.filename}
|
||||
className={`flex items-center gap-3 px-3 py-1.5 pl-8 text-sm ${
|
||||
isMatch ? 'text-zinc-200' : 'text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate">
|
||||
{file.isLocked && <Lock className="h-3 w-3 shrink-0 text-zinc-500" />}
|
||||
<span className="truncate">{file.name}</span>
|
||||
<span className={`truncate ${isMatch && matchedNames ? 'font-medium text-zinc-100' : ''}`}>
|
||||
{file.name}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-3 text-xs tabular-nums text-zinc-500">
|
||||
{file.bitRate ? <span>{file.bitRate} kbps</span> : null}
|
||||
@@ -478,10 +600,11 @@ const FolderBlock = ({ username, folder, queued, onDownload }: FolderBlockProps)
|
||||
{unlocked > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDownload(username, folder.files)}
|
||||
onClick={() => onDownload(username, files)}
|
||||
className="flex w-full items-center justify-center gap-1.5 border-t border-white/5 py-1.5 text-xs text-zinc-300 transition hover:bg-white/5 hover:text-zinc-100"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" /> Download folder ({unlocked})
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{revealed || hidden === 0 ? `Download folder (${unlocked})` : `Download ${unlocked} matching`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user