forked from Keelrleek/SoulStream
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.
309 lines
8.7 KiB
Svelte
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>
|