Files
SoulStream/src/views/Downloads.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

309 lines
8.7 KiB
Svelte

<script>
import { downloads, network, playLocalFile } from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "../components/Icon.svelte";
const uploads = $derived(
Object.values(network.uploads)
.sort((a, b) => b.id - a.id)
.slice(0, 20),
);
const items = $derived(
Object.values(downloads.items).sort((a, b) => b.id - a.id),
);
const active = $derived(
items.filter((d) => !["complete", "failed", "cancelled"].includes(d.state)),
);
const finished = $derived(
items.filter((d) => ["complete", "failed", "cancelled"].includes(d.state)),
);
function pct(d) {
return d.total > 0 ? Math.min(100, (d.received / d.total) * 100) : 0;
}
function fmtSize(bytes) {
if (bytes >= 1e9) return (bytes / 1e9).toFixed(2) + " GB";
return (bytes / 1e6).toFixed(1) + " MB";
}
function stateLabel(d) {
switch (d.state) {
case "connecting": return "connecting…";
case "requested": return "requested…";
case "queued":
return d.queue_place != null ? `in queue (#${d.queue_place})` : "in queue";
case "transferring":
return `${fmtSize(d.received)} / ${fmtSize(d.total)} · ${(d.speed_bps / 1e6).toFixed(1)} MB/s`;
case "complete": return "done";
case "failed": return d.error || "failed";
case "cancelled": return "cancelled";
default: return d.state;
}
}
</script>
<div class="downloads">
<h1>Downloads</h1>
{#if network.shareFiles > 0}
<div class="sharing-banner">
<span class="dot"></span>
Sharing {network.shareFiles} files with the network
{#if network.respondedCount > 0}
· answered {network.respondedCount} searches
{/if}
{#if uploads.length > 0}
· {uploads.filter((u) => u.state === "transferring").length} uploading
{/if}
</div>
{/if}
{#if uploads.length > 0}
<h2>Uploads</h2>
<div class="list">
{#each uploads as u (u.id)}
<div class="row">
<div class="info">
<div class="name">{u.name}</div>
<div class="sub">
to {u.username} ·
<span class:ok={u.state === "complete"} class:err={u.state === "failed"}>
{u.state === "transferring" && u.total > 0
? `${((u.sent / u.total) * 100).toFixed(0)}%`
: u.state}
</span>
</div>
{#if u.state === "transferring"}
<div class="bar">
<div class="fill up" style="width: {u.total > 0 ? (u.sent / u.total) * 100 : 0}%"></div>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{#if items.length === 0}
{#if uploads.length === 0}
<div class="empty">
<p class="big">No downloads yet</p>
<p class="sub">Grab something from Search — files land in your library folder.</p>
</div>
{/if}
{:else}
{#if active.length > 0}
<h2>Active</h2>
<div class="list">
{#each active as d (d.id)}
<div class="row">
<div class="info">
<div class="name">{d.name}</div>
<div class="sub">{d.username} · {stateLabel(d)}</div>
<div class="bar">
<div
class="fill"
class:indeterminate={d.state !== "transferring"}
style="width: {d.state === 'transferring' ? pct(d) : 100}%"
></div>
</div>
</div>
<button class="cancel" title="Cancel" onclick={() => api.cancelDownload(d.id)}>
<Icon name="x" size={16} />
</button>
</div>
{/each}
</div>
{/if}
{#if finished.length > 0}
<h2>Finished</h2>
<div class="list">
{#each finished as d (d.id)}
<div class="row done-row">
<div class="info">
<div class="name" class:dim={d.state !== "complete"}>{d.name}</div>
<div class="sub">
{d.username} ·
<span class:ok={d.state === "complete"} class:err={d.state === "failed"}>
{stateLabel(d)}
</span>
</div>
</div>
{#if d.state === "complete" && d.local_path}
<button
class="reveal"
title="Play"
onclick={() =>
playLocalFile(
d.local_path,
d.name.replace(/\.[^.]+$/, ""),
d.username,
)}
><Icon name="play" size={15} /></button>
<button
class="reveal"
title="Show in folder"
onclick={() => api.showInFolder(d.local_path)}
><Icon name="folder-open" size={16} /></button>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
</div>
<style>
h1 {
font-size: 28px;
font-weight: 800;
margin-bottom: 24px;
}
h2 {
font-size: 14px;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 18px 0 10px;
}
.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);
font-size: 12px;
}
.list {
display: flex;
flex-direction: column;
gap: 6px;
}
.row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: var(--radius);
background: var(--bg-elevated);
}
.row.done-row {
background: transparent;
}
.row.done-row:hover {
background: var(--bg-highlight);
}
.info {
flex: 1;
min-width: 0;
}
.name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.name.dim {
color: var(--text-muted);
}
.bar {
margin-top: 8px;
height: 4px;
border-radius: 2px;
background: hsla(0, 0%, 100%, 0.15);
overflow: hidden;
}
.fill {
height: 100%;
border-radius: 2px;
background: var(--accent);
transition: width 0.25s linear;
}
.fill.indeterminate {
opacity: 0.25;
animation: pulse 1.4s ease-in-out infinite;
}
.fill.up {
background: var(--accent-hover);
}
.sharing-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 20px;
border-radius: var(--radius);
background: var(--accent-soft);
color: var(--text-secondary);
font-size: 13px;
}
.sharing-banner .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
flex-shrink: 0;
}
@keyframes pulse {
50% { opacity: 0.55; }
}
.ok {
color: var(--accent-hover);
}
.err {
color: #f15e6c;
}
.cancel {
color: var(--text-muted);
font-size: 14px;
padding: 6px;
}
.cancel:hover {
color: #f15e6c;
}
.reveal {
font-size: 15px;
opacity: 0.8;
}
.reveal:hover {
opacity: 1;
}
</style>