Files
SoulStream/src/views/Search.svelte
T
KeelrLeek a667b4b28e Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so
history is unrecoverable locally. This commit restores version control
with the tree exactly as it stands: the working app, the preview-tracks
launcher on the artist hero, the scrollable-track-previews design bundle
(docs + approved mockups), and the Phase 0 validation artifacts.
2026-07-22 17:57:00 -05:00

386 lines
11 KiB
Svelte

<script>
import {
app,
search,
runSearch,
downloadFile,
downloadFolder,
streamFile,
filesForGroup,
} from "../lib/state.svelte.js";
import Icon from "../components/Icon.svelte";
let queuedKeys = $state({});
// Seeded from the store so "get on soulseek" hand-offs show their query.
let query = $state(search.query);
let expanded = $state({});
const groups = $derived(
Object.values(search.groups).sort(
(a, b) => (b.hasFreeSlot - a.hasFreeSlot) || (b.speed - a.speed),
),
);
async function submit(e) {
e.preventDefault();
const q = query.trim();
if (!q) return;
if (app.connection !== "online") {
app.showLogin = true;
return;
}
expanded = {};
try {
await runSearch(q);
} catch (err) {
app.loginError = String(err);
app.showLogin = true;
}
}
function fmtSize(bytes) {
if (bytes >= 1e9) return (bytes / 1e9).toFixed(2) + " GB";
return (bytes / 1e6).toFixed(1) + " MB";
}
function fmtDuration(s) {
if (s == null) return "";
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function fmtSpeed(bps) {
if (!bps) return "";
return (bps / 1e6).toFixed(1) + " MB/s";
}
// "FLAC", "MP3 320"… from the group's incrementally-kept summary fields.
function quality(g) {
if (g.topExt === "flac") return "FLAC";
const label = g.topExt.toUpperCase();
return g.maxBitrate ? `${label} ${g.maxBitrate}` : label;
}
</script>
<div class="search">
<form onsubmit={submit}>
<input
type="text"
placeholder="What do you want to listen to?"
bind:value={query}
spellcheck="false"
/>
</form>
{#if search.token == null}
<div class="empty">
<p class="big">Search the Soulseek network</p>
<p class="sub">
{app.connection === "online"
? `Connected as ${app.username} — search for an artist or album.`
: "You'll be asked to log in on your first search."}
</p>
</div>
{:else}
<div class="meta">
<span>
{groups.length} folders · {search.responses} users
{#if search.searching}<span class="pulse">· searching…</span>{/if}
</span>
</div>
<div class="results">
{#each groups.slice(0, 120) as g (g.key)}
<div class="group">
<button
class="group-head"
onclick={() => (expanded[g.key] = !expanded[g.key])}
>
<div class="art-mini"><Icon name="music" size={20} /></div>
<div class="ginfo">
<div class="gtitle" title={g.folder}>{g.folderName}</div>
<div class="gsub">
{g.username} · {g.audioCount} tracks · {fmtSize(g.totalSize)}
</div>
</div>
<div class="badges">
{#if quality(g)}
<span class="badge quality">{quality(g)}</span>
{/if}
{#if g.hasFreeSlot}
<span class="badge slot">open slot</span>
{:else}
<span class="badge queue">queue {g.queue}</span>
{/if}
{#if g.speed}
<span class="badge">{fmtSpeed(g.speed)}</span>
{/if}
</div>
<span
class="dl-all"
class:queued={queuedKeys[g.key]}
title="Download folder"
role="button"
tabindex="0"
onclick={(e) => {
e.stopPropagation();
if (queuedKeys[g.key]) return;
queuedKeys[g.key] = true;
downloadFolder(g);
}}
onkeydown={(e) => e.key === "Enter" && e.currentTarget.click()}
>
{#if queuedKeys[g.key]}
<Icon name="check" size={13} /> queued
{:else}
<Icon name="download" size={13} /> all
{/if}
</span>
</button>
{#if expanded[g.key]}
<div class="files">
<!-- g.fileCount in the key keeps this list refreshing
as new (non-reactive) files arrive for the group -->
{#each filesForGroup(g.key, g.fileCount) as f (f.path)}
<div class="file">
<span class="fname" title={f.name}>{f.name}</span>
<span class="fmeta">
{f.bitrate ? `${f.bitrate} kbps` : ""}
{f.duration != null ? " · " + fmtDuration(f.duration) : ""}
· {fmtSize(f.size)}
</span>
<button
class="mini live"
title="Play (streams while downloading)"
onclick={() => streamFile(g.username, f, g.folderName)}
><Icon name="play" size={13} /></button>
<button
class="mini live"
title="Download"
onclick={() => {
queuedKeys[f.path] = true;
downloadFile(g.username, f);
}}
>
<Icon name={queuedKeys[f.path] ? "check" : "download"} size={14} />
</button>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<style>
form {
margin-bottom: 20px;
}
input {
width: min(100%, 420px);
padding: 12px 18px;
border: none;
border-radius: 24px;
background: var(--bg-highlight);
color: var(--text-primary);
outline: none;
}
input::placeholder {
color: var(--text-muted);
}
input:focus {
box-shadow: 0 0 0 2px var(--text-primary);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding-top: 15vh;
text-align: center;
}
.big {
font-size: 22px;
font-weight: 700;
}
.sub {
color: var(--text-secondary);
}
.meta {
color: var(--text-secondary);
font-size: 12px;
margin-bottom: 12px;
}
.pulse {
color: var(--accent);
}
.results {
display: flex;
flex-direction: column;
gap: 4px;
}
.group {
border-radius: var(--radius);
background: transparent;
}
.group-head {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 8px 10px;
border-radius: var(--radius);
text-align: left;
}
.group-head:hover {
background: var(--bg-highlight);
}
.art-mini {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 6px;
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
color: var(--text-secondary);
font-size: 18px;
flex-shrink: 0;
}
.ginfo {
flex: 1;
min-width: 0;
}
.gtitle {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gsub {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badges {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.badge {
padding: 3px 8px;
border-radius: 10px;
background: var(--bg-highlight);
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
}
.badge.quality {
background: var(--accent-soft);
color: var(--accent-hover);
}
.badge.slot {
color: var(--accent-hover);
}
.badge.queue {
color: #e8a33d;
}
.files {
padding: 2px 10px 8px 66px;
display: flex;
flex-direction: column;
}
.file {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 8px;
border-radius: 4px;
font-size: 13px;
}
.file:hover {
background: var(--bg-highlight);
}
.fname {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fmeta {
color: var(--text-muted);
font-size: 11px;
flex-shrink: 0;
}
.mini {
color: var(--text-secondary);
opacity: 0.5;
flex-shrink: 0;
}
.mini.live {
opacity: 1;
}
.mini.live:hover {
color: var(--accent-hover);
}
.dl-all {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 12px;
background: var(--accent);
color: #000;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.dl-all:hover {
background: var(--accent-hover);
}
.dl-all.queued {
background: var(--bg-highlight);
color: var(--accent-hover);
cursor: default;
}
</style>