Files
SoulStream/src/components/WhoHas.svelte
T

197 lines
11 KiB
Svelte

<script>
// The answer to "who has this?": the members of your rooms whose holdings
// name this album, grouped by room, each with the line Discover already
// writes for that person and the action the answer allows —
//
// Get it a linked holder: browse their share, find the
// folder the room said they hold, queue the grab
// Wait for them the grab could not reach them: a local wait
// that fires when their presence flips online
// Ask in chat a room-local member with no Soulseek name;
// nothing is ever pushed to them
//
// Reads the cached holdings only; the network was read when the room was.
import Icon from "./Icon.svelte";
import Identicon, { hueOf } from "./Identicon.svelte";
import { slsk } from "../lib/state.svelte.js";
import { community, askHoldings, holdingsGrab, waitFor, forgetWait, waitingOn } from "../lib/community.svelte.js";
let { artist, album, mbid = null, compact = false, onanswer = null } = $props();
let holders = $state(null);
let asking = $state(false);
let grabbed = $state({});
let failed = $state({});
$effect(() => {
const a = artist;
const b = album;
const m = mbid;
if (!a || !b) {
holders = [];
return;
}
let live = true;
asking = true;
askHoldings([{ artist: a, album: b, mbid: m }])
.then((r) => {
if (!live) return;
holders = r?.[0]?.holders ?? [];
onanswer?.(holders);
})
.catch(() => {
if (live) holders = [];
})
.finally(() => {
if (live) asking = false;
});
return () => {
live = false;
};
});
/** Rooms in the order they appear, each with its holders. */
const rooms = $derived.by(() => {
const by = new Map();
for (const h of holders ?? []) {
const k = h.owner + ":" + h.space_id;
if (!by.has(k)) by.set(k, { key: k, name: h.space_name, holders: [] });
by.get(k).holders.push(h);
}
return [...by.values()];
});
const people = $derived.by(() => {
const s = new Set();
for (const h of holders ?? []) s.add((h.username ?? h.member).toLowerCase());
return s.size;
});
const display = (u) => community.contacts[u]?.petname || u;
const fpShort = (k) => (k ? `${k.slice(0, 4)}…${k.slice(-4)}` : "");
function presenceOf(u) {
const st = slsk.buddyState[u];
if (!st || st.exists === false) return null;
if (st.status === 2) return "online";
if (st.status === 1) return "away";
return "off";
}
const fmtClock = (ms) => new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
function seenLine(u) {
const ms = community.lastSeen[u];
if (!ms) return null;
const d = new Date(ms);
const now = new Date();
if (d.toDateString() === now.toDateString()) return `last seen by this device today · ${fmtClock(ms)}`;
if (new Date(now - 86400000).toDateString() === d.toDateString()) return `last seen by this device yesterday · ${fmtClock(ms)}`;
return `last seen by this device ${d.toLocaleDateString([], { month: "short", day: "numeric" })}`;
}
/** The second line: presence, then what Discover knows about them. */
function line(h) {
const u = h.username;
if (!u) return "room-local · not linked to a Soulseek account";
const parts = [];
const p = presenceOf(u);
if (p === "online") parts.push("online now");
else if (p === "away") parts.push("away");
else {
const s = seenLine(u);
if (s) parts.push(s);
}
const o = community.overlap.find((x) => x.username.toLowerCase() === u.toLowerCase());
if (o?.albums_in_common_count) parts.push(`shares ${o.albums_in_common_count} ${o.albums_in_common_count === 1 ? "album" : "albums"} you own`);
if (o?.gaps_count) parts.push(`${o.gaps_count} you lack`);
return parts.join(" · ") || "linked to Soulseek";
}
const tierWord = (t) => (t === 2 ? "FLAC" : t === 1 ? "MP3" : null);
async function getIt(h) {
const u = h.username;
delete failed[u];
try {
const m = await holdingsGrab(u, artist, album);
grabbed[u] = m?.files?.length ?? 0;
} catch (e) {
failed[u] = String(e?.message ?? e);
}
}
const busy = (h) => !!community.busy[`grab:${h.username}:${album}`] || !!community.busy[`wait:${h.username}:${album}`];
</script>
<div class="whohas" class:compact>
{#if asking && holders === null}
<div class="quiet"><span class="bl"></span> Asking your rooms…</div>
{:else if !holders?.length}
<div class="quiet">
<Icon name="users" size={16} />
<span>{community.spaces.length ? "Nobody in your rooms has published this one." : "You are in no rooms yet — holdings are asked room by room."}</span>
</div>
{:else}
{#each rooms as r (r.key)}
<div class="grp"><span class="src">{r.name}</span><span class="n">{r.holders.length} of the room</span><span class="ln"></span></div>
{#each r.holders as h (h.member)}
{@const u = h.username}
<div class="row">
<span class="av" style:background="hsl({hueOf(u ?? h.member)} 30% 14%)"><Identicon seed={u ?? h.member} size={compact ? 30 : 36} presence={u ? presenceOf(u) : null} /></span>
<span class="tx">
<span class="l1"><span class="nm">{u ? display(u) : fpShort(h.member)}</span>{#if u}<span class="badge">Soulseek</span>{/if}</span>
<span class="l2">{line(h)}</span>
{#if failed[u]}<span class="l2 warn">{failed[u]}</span>{/if}
</span>
<span class="r">
{#if tierWord(h.tier)}<span class="tier" class:lossless={h.tier === 2}>{tierWord(h.tier)}</span>{/if}
{#if !u}
<button class="ax-ghost sm" disabled title="They have no Soulseek name here; ask in the room's chat">Ask in chat</button>
{:else if grabbed[u] != null}
<span class="chip ok"><Icon name="check" size={11} /> {grabbed[u]} {grabbed[u] === 1 ? "file" : "files"} queued</span>
{:else if waitingOn(u, artist, album)}
<span class="chip acc">waiting</span>
<button class="ax-ghost sm" onclick={() => forgetWait(u, artist, album)} disabled={busy(h)}>Forget</button>
{:else if failed[u]}
<button class="ax-ghost sm" onclick={() => waitFor(u, artist, album, h.space_name)} disabled={busy(h)}>Wait for them</button>
<button class="ax-fill sm" onclick={() => getIt(h)} disabled={busy(h)}>Try again</button>
{:else}
<button class="ax-fill sm" onclick={() => getIt(h)} disabled={busy(h)}>{busy(h) ? "Browsing…" : "Get it"}</button>
{/if}
</span>
</div>
{/each}
{/each}
<div class="foot"><span class="prov"><Icon name="compass" size={11} /> from your rooms' holdings · {people} {people === 1 ? "person" : "people"}</span><span class="note">names compared, never files</span></div>
{/if}
</div>
<style>
.whohas { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.quiet { display: flex; align-items: center; gap: 10px; padding: 12px 10px; font-size: 12.5px; color: var(--text-muted); line-height: 1.5; }
.bl { width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--text-muted); border-top-color: transparent; animation: spin 0.8s linear infinite; flex: none; }
@keyframes spin { to { transform: rotate(360deg); } }
.grp { display: flex; align-items: center; gap: 8px; height: 26px; padding: 0 2px; font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); margin-top: 4px; }
.grp .n { font-weight: 700; letter-spacing: 0; text-transform: none; }
.grp .ln { flex: 1; height: 1px; background: var(--hair); }
.src { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 99px; background: color-mix(in srgb, var(--space, #c77dff) 14%, transparent); color: #d9a6ff; text-transform: none; }
.row { display: flex; align-items: center; gap: 10px; min-height: 46px; padding: 5px 8px; border-radius: 8px; }
.compact .row { min-height: 40px; padding: 3px 8px; }
.row:hover { background: var(--surf-hi, var(--bg-highlight)); }
.av { display: grid; place-items: center; border-radius: 50%; flex: none; }
.tx { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
.l1 { display: flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 800; }
.l1 .nm { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.badge { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 99px; background: var(--surf-hi, var(--bg-highlight)); color: var(--text-secondary); }
.l2 { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.l2.warn { color: #f0c070; white-space: normal; }
.r { display: flex; align-items: center; gap: 8px; flex: none; }
.tier { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 7px; border-radius: 5px; background: rgba(255, 255, 255, 0.08); color: var(--text-secondary); }
.tier.lossless { background: color-mix(in srgb, var(--ok, #3ddc84) 12%, transparent); color: #5ee89a; }
.chip { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 99px; white-space: nowrap; display: inline-flex; align-items: center; gap: 5px; line-height: 1.5; }
.chip.ok { background: color-mix(in srgb, var(--ok, #3ddc84) 14%, transparent); color: #5ee89a; }
.chip.acc { background: var(--accent-soft, color-mix(in srgb, var(--accent) 15%, transparent)); color: var(--accent-hover); }
.ax-ghost { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 11px; border-radius: 99px; font-size: 11px; font-weight: 800; border: 1px solid rgba(255, 255, 255, 0.28); transition: 0.18s; white-space: nowrap; }
.ax-ghost:hover:not(:disabled) { border-color: #fff; background: rgba(255, 255, 255, 0.08); }
.ax-ghost:disabled { opacity: 0.45; cursor: default; }
.ax-fill { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 12px; border-radius: 99px; font-size: 11px; font-weight: 800; background: var(--accent); color: #000; white-space: nowrap; }
.ax-fill:disabled { opacity: 0.55; cursor: default; }
.foot { display: flex; align-items: center; gap: 10px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--hair); }
.prov { font-size: 11.5px; color: var(--text-muted); display: flex; align-items: center; gap: 6px; }
.note { margin-left: auto; font-size: 11px; color: var(--text-muted); }
</style>