who has this: a gap card says who holds it, the sheet and the open album name them, a Space grows a Holdings tab, and Get it is the hub's own grab or a wait for their presence

This commit is contained in:
KeelrLeek committed 2026-09-08 12:10:49 -05:00
1 parent a98c750908
commit b8e6fb7396
11 files changed
+454 -6

No files matched your search

+2
View File
@@ -28,6 +28,7 @@
import UploadToast from "./components/UploadToast.svelte";
import ProposalToast from "./components/ProposalToast.svelte";
import FollowNewsToast from "./components/FollowNewsToast.svelte";
import WhoHasSheet from "./components/WhoHasSheet.svelte";
import SetlistScanChip from "./components/SetlistScanChip.svelte";
import {
ui,
@@ -188,6 +189,7 @@
<UploadToast />
<ProposalToast />
<FollowNewsToast />
<WhoHasSheet />
<SetlistScanChip />
<PreviewOverlay />
<LyricsSyncEditor />
+196
View File
@@ -0,0 +1,196 @@
<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>
Binary file not shown.
+8
View File
@@ -196,6 +196,14 @@ export const spaceBoard = (owner, space) => invoke("space_board", { owner, space
export const spacePropose = (owner, space, base, ops, note) => invoke("space_propose", { owner, space, base, ops, note });
export const spaceDecide = (owner, space, proposal, accept, reason) => invoke("space_decide", { owner, space, proposal, accept, reason });
export const overlapList = () => invoke("overlap_list");
export const holdingsSetShare = (owner, space, on) => invoke("holdings_set_share", { owner, space, on });
export const holdingsRoom = (owner, space, refresh = false) => invoke("holdings_room", { owner, space, refresh });
export const holdingsAsk = (items) => invoke("holdings_ask", { items });
export const holdingsFindFolder = (username, artist, album) => invoke("holdings_find_folder", { username, artist, album });
export const holdingsRepublish = () => invoke("holdings_republish");
export const holdingsWaits = () => invoke("holdings_waits");
export const holdingsWaitAdd = (username, artist, album, via = null) => invoke("holdings_wait_add", { username, artist, album, via });
export const holdingsWaitRemove = (username, artist, album) => invoke("holdings_wait_remove", { username, artist, album });
export const overlapCompute = (username) => invoke("overlap_compute", { username });
export const overlapForget = (username) => invoke("overlap_forget", { username });
export const buddyList = () => invoke("buddy_list");
+85 -2
View File
@@ -91,6 +91,11 @@ export const community = $state({
boardError: "",
/** overlap_list: library comparisons computed on this device, best first. */
overlap: [],
/** `owner:space` → holdings_room: who in that room publishes what they
* hold, and whether this device does. Read when a Space opens. */
holdings: {},
/** holdings_waits: albums to grab from someone the moment they come online. */
waits: [],
/** persona key → ProfileView | {none} | {error} | {pending}: other
* people's signed profiles, read once per session when surfaced. */
remoteProfiles: {},
@@ -350,6 +355,18 @@ function wireEvents() {
void refreshFollowing();
});
api.onEvent("community:overlap", () => void refreshOverlap());
api.onEvent("community:holdings", () => {
// A read finished, a publish landed, a wait changed: re-read the
// open room's lists from the cache (fresh members cost no network).
if (community.space) void refreshHoldings(community.space.owner, community.space.space_id);
void refreshWaits();
});
// The library changed: every room with the switch on gets the new
// list, coalesced so a scan's burst of changes is one publish.
api.onEvent("soulseek:library-changed", () => {
clearTimeout(republishTimer);
republishTimer = setTimeout(() => void api.holdingsRepublish().catch(() => {}), REPUBLISH_DEBOUNCE_MS);
});
api.onEvent("community:proposal", (p) => {
announceProposal(p.from, p.playlist);
// If the board is already on screen, show what it is talking about.
@@ -357,6 +374,8 @@ function wireEvents() {
});
api.onEvent("soulseek:user-state", (p) => {
if (p.status === 1 || p.status === 2) noteSeen(p.username);
// Someone we are waiting on came online: the grab runs now.
if (p.status === 2) void drainWaits(p.username);
});
}
}
@@ -364,7 +383,7 @@ function wireEvents() {
/** Idempotent: pulls the state the Community page draws. */
export async function initCommunity() {
wireEvents();
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshFollowing(), refreshInbox(), refreshSpaces(), refreshOverlap()]);
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshFollowing(), refreshInbox(), refreshSpaces(), refreshOverlap(), refreshWaits()]);
await refreshPersona();
}
@@ -379,6 +398,9 @@ export async function initCommunity() {
* that comes up later — or an inbox published later — simply starts working
* without anything having to restart the timer. */
const INBOX_POLL_MS = 90_000;
/** A library scan raises many change events in a row; one publish serves them all. */
const REPUBLISH_DEBOUNCE_MS = 30_000;
let republishTimer = 0;
/** Followed playlists are read far less often: every pointer read is a ring
* search that can take seconds, and an owner's next revision is a matter
* of days, not minutes. */
@@ -705,6 +727,67 @@ export async function refreshSpaces() {
}
}
/** Read a Space through the node; the view is what the checkpoint says. */
// ---- holdings: who has this, and get it from them ---------------------------------
const holdingsId = (owner, space) => `${owner}:${space}`;
/** The room's lists as the cache has them; the backend reads stale members
* in the background and the event brings the result back here. */
export async function refreshHoldings(owner, space, refresh = false) {
try {
community.holdings[holdingsId(owner, space)] = await api.holdingsRoom(owner, space, refresh);
} catch {
// stays as it was
}
}
export const holdingsFor = (owner, space) => community.holdings[holdingsId(owner, space)] ?? null;
/** The switch: publish this device's list into the room, or withdraw it. */
export const setShareHoldings = (owner, space, on) =>
run("holdings:share", async () => {
community.holdings[holdingsId(owner, space)] = await api.holdingsSetShare(owner, space, on);
if (community.space?.owner === owner && community.space?.space_id === space) await openSpace(owner, space);
});
/** Who holds each (artist, album), across every room — local, instant. */
export const askHoldings = (items) => api.holdingsAsk(items);
/** Get it: browse the holder, find the folder the room said they hold, and
* queue every audio file through the hub's own download — the transfers
* page shows them like any album grab. */
export const holdingsGrab = (username, artist, album) =>
run(`grab:${username}:${album}`, async () => {
const m = await api.holdingsFindFolder(username, artist, album);
if (!m) throw new Error(`${username}'s share does not name ${album} any more`);
for (const f of m.files) await api.downloadFile(username, f.path, f.size);
return m;
});
export async function refreshWaits() {
try {
community.waits = await api.holdingsWaits();
} catch {
// stays as it was
}
}
export const waitFor = (username, artist, album, via = null) =>
run(`wait:${username}:${album}`, async () => {
community.waits = await api.holdingsWaitAdd(username, artist, album, via);
});
export const forgetWait = (username, artist, album) =>
run(`wait:${username}:${album}`, async () => {
community.waits = await api.holdingsWaitRemove(username, artist, album);
});
export const waitingOn = (username, artist, album) =>
community.waits.some((w) => w.username.toLowerCase() === (username ?? "").toLowerCase() && w.artist === artist && w.album === album);
/** A waited-on person came online: run their grabs, each once. */
async function drainWaits(username) {
const mine = community.waits.filter((w) => w.username.toLowerCase() === (username ?? "").toLowerCase());
for (const w of mine) {
try {
await holdingsGrab(w.username, w.artist, w.album);
await forgetWait(w.username, w.artist, w.album);
} catch {
// their share no longer names it, or the browse failed: the wait
// stays, and the row says what happened when it is looked at
}
}
}
export async function openSpace(owner, space) {
community.spaceError = "";
community.busy["space:" + space] = true;
@@ -814,4 +897,4 @@ export const decideProposal = (owner, space, proposal, accept, reason) =>
// Dev handle beside `__soulstream` (state.svelte.js): probes reach the store
// through this, never through a bare import (which would be a second copy).
if (import.meta.env.DEV) globalThis.__community = community;
if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, refreshFollowing, followFor, followNews, checkFollows, followUpdate, unfollow, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, setShareListens, openBoard, proposeToBoard, decideProposal, refreshOverlap, computeOverlap, forgetOverlap, refreshInterests, setInterest };
if (import.meta.env.DEV) globalThis.__communityActions = { refreshHoldings, holdingsFor, setShareHoldings, askHoldings, holdingsGrab, refreshWaits, waitFor, forgetWait, waitingOn, friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, refreshFollowing, followFor, followNews, checkFollows, followUpdate, unfollow, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, setShareListens, openBoard, proposeToBoard, decideProposal, refreshOverlap, computeOverlap, forgetOverlap, refreshInterests, setInterest };
Binary file not shown.
+13
View File
@@ -30,6 +30,8 @@
import { listenerPeak, listenerShare } from "../lib/listenerBars.js";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import Icon from "../components/Icon.svelte";
import WhoHas from "../components/WhoHas.svelte";
import { community } from "../lib/community.svelte.js";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … }
@@ -887,6 +889,13 @@
</div>
{/if}
{#if !inLibrary && community.spaces.length}
<section class="whohas-sec">
<div class="whohas-head"><h2>Who has this</h2><span class="p">from your rooms' holdings · names compared, never files</span></div>
{#key album.artist + "\u0000" + album.title}<WhoHas artist={album.artist} album={album.title} />{/key}
</section>
{/if}
<div class="tracklist">
{#if tracks.length}
<div class="tcols" class:has-count={showCount}>
@@ -1976,4 +1985,8 @@
:global(:root.glass) .stat-note {
border-color: var(--glass-hairline);
}
.whohas-sec { margin: 8px 0 18px; padding: 12px 14px; border-radius: 12px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.07); }
.whohas-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 6px; }
.whohas-head h2 { margin: 0; font-size: 15px; font-weight: 800; }
.whohas-head .p { font-size: 11.5px; color: var(--text-muted); }
</style>
+29
View File
@@ -52,6 +52,8 @@
import { openPreview } from "../lib/preview/previewSession.svelte.js";
import { traceToSvg, svgDataUrl } from "../lib/trace.js";
import Icon from "../components/Icon.svelte";
import WhoHas from "../components/WhoHas.svelte";
import { openWhoHas, holdersFor, pairKey, holderCount } from "../lib/whoHas.svelte.js";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte";
@@ -2536,6 +2538,17 @@
});
const visibleTabs = $derived(tabs.filter((tab) => tab.key === activeTabKey));
// Who in your rooms holds a release you lack: asked for the visible tab's
// families at once, filed by pairKey; the count rides the card, and the
// open album shows the people.
let heldBy = $state({});
const heldFor = (title) => (page ? heldBy[pairKey(page.artist.name, title)] : null);
$effect(() => {
const name = page?.artist?.name;
const items = visibleTabs.flatMap((g) => g.items).filter((al) => !inLibrary(al)).map((al) => ({ artist: name, album: al.title }));
if (!name || !items.length) return;
untrack(() => void holdersFor(items).then((m) => { for (const [k, v] of m) heldBy[k] = v; }));
});
// FLIP for the stage-2 correction. Svelte's `animate:` directive can't be
// used here — it requires the animated element to be the ONLY child of the
@@ -5399,6 +5412,9 @@
onclick={(e) => { e.stopPropagation(); streamRelease(al, al); }}
><Icon name="play" size={13} /></button>
{/if}
{#if heldFor(al.title)?.length}
<button class="have" title="In your rooms — who has it?" onclick={(e) => { e.stopPropagation(); openWhoHas({ artist: page.artist.name, album: al.title, cover: al.cover ?? null }, e.currentTarget.closest(".release")); }}>{holderCount(heldFor(al.title))} {holderCount(heldFor(al.title)) === 1 ? "has it" : "have it"}</button>
{/if}
</div>
<div class="title" title={al.title}>
{al.title}
@@ -5565,6 +5581,12 @@
{/if}
</div>
<div class="xp-tracks">
{#if openAlbum && !inLibrary(openAlbum) && heldFor(openAlbum.title)?.length}
<div class="xp-whohas">
<div class="xp-whohas-head"><span class="t">Who has this</span><span class="p">from your rooms' holdings · names compared, never files</span></div>
{#key openAlbum.id}<WhoHas artist={page.artist.name} album={openAlbum.title} compact />{/key}
</div>
{/if}
{#if openLoading}
<!-- Nothing at all until the load
has outlasted LOADING_GRACE:
@@ -9602,4 +9624,11 @@
:global(:root.glass) .notch {
background: var(--glass-surface);
}
/* who in your rooms holds a release you lack — the count rides the card */
.have { position: absolute; right: 6px; bottom: 6px; z-index: 2; display: inline-flex; align-items: center; gap: 4px; height: 20px; padding: 0 7px; border-radius: 99px; background: rgba(0, 0, 0, 0.62); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); color: #fff; font-size: 10.5px; font-weight: 900; border: 1px solid rgba(255, 255, 255, 0.14); cursor: pointer; white-space: nowrap; }
.have:hover { background: rgba(0, 0, 0, 0.82); border-color: rgba(255, 255, 255, 0.34); }
.xp-whohas { margin: 0 0 12px; padding: 10px 12px; border-radius: 10px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.07); }
.xp-whohas-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 4px; flex-wrap: wrap; }
.xp-whohas-head .t { font-size: 12.5px; font-weight: 800; white-space: nowrap; }
.xp-whohas-head .p { font-size: 11px; color: var(--text-muted); }
</style>
+73
View File
@@ -13,6 +13,7 @@
import { onMount, untrack } from "svelte";
import Icon from "../components/Icon.svelte";
import Identicon, { hueOf } from "../components/Identicon.svelte";
import WhoHas from "../components/WhoHas.svelte";
import { rankPeople, reasonParts } from "../lib/discover.js";
import Soulseek from "./Soulseek.svelte";
import * as api from "../lib/api.js";
@@ -96,6 +97,9 @@
pinToSpace,
leaveSpace,
setShareListens,
refreshHoldings,
holdingsFor,
setShareHoldings,
openBoard,
proposeToBoard,
decideProposal,
@@ -548,6 +552,18 @@
let spaceCreating = $state(false);
const sv = $derived(community.space);
const spaceOwner = $derived(sv?.my_role === "owner");
// Holdings: the room's lists are read when a Space opens (stale members
// in the background; the event brings the result back), and the ask box
// answers from the cache.
const hv = $derived(sv ? holdingsFor(sv.owner, sv.space_id) : null);
let askArtist = $state("");
let askAlbum = $state("");
let asked = $state(null);
$effect(() => {
const id = sv?.id;
if (!id) return;
untrack(() => void refreshHoldings(sv.owner, sv.space_id));
});
let spaceEl = $state(null);
$effect(() => {
const n = sv?.messages.length;
@@ -1362,6 +1378,7 @@
<div class="sptabs">
<button class="subtab" class:on={spaceTab === "chat"} onclick={() => (spaceTab = "chat")}>Chat<b>{sv.messages.length}</b></button>
<button class="subtab" class:on={spaceTab === "listening"} onclick={() => (spaceTab = "listening")}>Listening{#if sv.listens.length}<b>{sv.listens.length}</b>{/if}</button>
<button class="subtab" class:on={spaceTab === "holdings"} onclick={() => (spaceTab = "holdings")}>Holdings{#if hv?.sharing}<b>{hv.sharing}</b>{/if}</button>
<button class="subtab" class:on={spaceTab === "members"} onclick={() => (spaceTab = "members")}>Members<b>{sv.members.length}</b></button>
<button class="subtab" class:on={spaceTab === "about"} onclick={() => (spaceTab = "about")}>About</button>
<button class="subtab" class:on={spaceTab === "proposals"} onclick={() => (spaceTab = "proposals")}>Proposals{#if bv && bv.uuid === sv.pinned_uuid && bv.proposals.length}<b>{bv.proposals.length}</b>{/if}</button>
@@ -1446,6 +1463,50 @@
</div>
{/if}
</div>
{:else if spaceTab === "holdings"}
<div class="panel sharebar" style="margin-bottom:12px">
<button class="toggle" class:on={sv.share_holdings} aria-pressed={sv.share_holdings} title="Share what I hold in this Space"
onclick={() => setShareHoldings(sv.owner, sv.space_id, !sv.share_holdings)} disabled={!nodeUp || !sv.can_post || !!community.busy["holdings:share"]}><i></i></button>
<div class="grow">
<div class="ttl">Share what I hold in this Space</div>
<p class="hint">{#if sv.share_holdings && hv?.mine && !hv.mine.withdrawn}<b>{fmtCount(hv.mine.count)} albums</b> · published{#if sv.private} sealed to this room's epoch key{/if} · updated when your library changes · <b>synced as of {fmtClock(hv.mine.published_at * 1000)}</b>{:else}Album names as keys, one quality word each, and your Soulseek name so members can get it from you — never files. Per Space, off until you say so.{/if}</p>
</div>
{#if community.busy["holdings:share"]}<span class="chip off">working…</span>{:else if sv.share_holdings}<span class="chip acc">sharing</span>{:else}<span class="chip off">not sharing</span>{/if}
</div>
<div class="disc"><Icon name="alert" size={14} /><span><b>What the room learns:</b> members can tell whether you hold any album they can name, and roughly how many you hold. They never see files, paths, or formats beyond a lossless/lossy word. Turning this off withdraws the list; copies members already read stay with them.</span></div>
<div class="panel" style="margin-top:12px">
<h3><Icon name="search" size={13} /> Ask this room{#if hv}<span class="chip up">{hv.sharing} of {sv.members.length} sharing</span>{/if}</h3>
<form class="inl askinl" style="margin-top:8px" onsubmit={(e) => { e.preventDefault(); if (askArtist.trim() && askAlbum.trim()) asked = { artist: askArtist.trim(), album: askAlbum.trim() }; }}>
<input placeholder="Artist" bind:value={askArtist} />
<input placeholder="Album" bind:value={askAlbum} />
<button class="ax-ghost sm" type="submit" disabled={!askArtist.trim() || !askAlbum.trim()}>Ask</button>
</form>
{#if asked}{#key asked.artist + "\u0000" + asked.album}<div style="margin-top:8px"><WhoHas artist={asked.artist} album={asked.album} /></div>{/key}{/if}
</div>
<div class="panel" style="margin-top:12px">
<h3><Icon name="users" size={13} /> Who shares here <button class="ax-ghost sm" style="margin-left:auto" onclick={() => refreshHoldings(sv.owner, sv.space_id, true)} disabled={!nodeUp}><Icon name="refresh" size={12} /> Refresh</button></h3>
{#if hv}
<div class="hlist">
<div class="hrow">
<Identicon seed={key ?? "you"} size={30} />
<span class="tx"><span class="l1">you</span>
<span class="l2">{#if sv.share_holdings && hv.mine && !hv.mine.withdrawn}<b>{fmtCount(hv.mine.count)}</b> albums · list {hv.mine.sequence} · published {fmtClock(hv.mine.published_at * 1000)}{:else}not sharing{/if}</span></span>
<span class="chip" class:acc={sv.share_holdings && hv.mine && !hv.mine.withdrawn} class:off={!(sv.share_holdings && hv.mine && !hv.mine.withdrawn)}>{sv.share_holdings && hv.mine && !hv.mine.withdrawn ? "shared" : "none"}</span>
</div>
{#each hv.members.filter((m) => !m.is_me) as m (m.key)}
<div class="hrow">
<Identicon seed={m.username ?? m.key} size={30} />
<span class="tx"><span class="l1">{m.is_me ? "you" : m.username ? display(m.username) : fpShort(m.key)}</span>
<span class="l2">{#if m.state === "shared"}<b>{fmtCount(m.count)}</b> albums · list {m.sequence}{#if m.read_at} · read {fmtClock(m.read_at * 1000)}{/if}{:else if m.state === "none"}not sharing{:else if m.state === "withdrawn"}withdrew their list{:else if m.state === "sealed"}sealed to an epoch you have no key for{:else}not read yet{/if}</span></span>
<span class="chip" class:acc={m.state === "shared"} class:off={m.state !== "shared"}>{m.state}</span>
</div>
{/each}
</div>
{:else}
<p class="hint" style="margin-top:8px">Reading the room's lists…</p>
{/if}
<p class="hint" style="margin-top:8px">A member's list is read when the Space opens and again after ten minutes; a member who never published costs a slow search, so they are asked again only hourly.</p>
</div>
{:else if spaceTab === "members"}
<div class="panel">
<div class="members">
@@ -2390,6 +2451,18 @@
.mem .nm { font-size: 11.5px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 96px; }
.mem .role { font-size: 9.5px; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }
.mem .role.own { color: #d9a6ff; }
/* holdings: the disclosure and the who-shares list */
.disc { display: flex; gap: 8px; align-items: flex-start; padding: 9px 11px; border-radius: 9px; background: color-mix(in srgb, var(--warn) 7%, transparent); border: 1px solid color-mix(in srgb, var(--warn) 25%, transparent); font-size: 11.5px; color: var(--text-secondary); line-height: 1.5; }
.disc b { color: #f0c070; }
.askinl input { height: 30px; padding: 0 10px; border-radius: 8px; background: var(--surf-hi); border: none; color: var(--text-primary); font-size: 12px; outline: none; }
.askinl input::placeholder { color: var(--text-muted); }
.hlist { display: flex; flex-direction: column; gap: 2px; margin-top: 8px; }
.hrow { display: flex; align-items: center; gap: 10px; padding: 5px 8px; border-radius: 8px; min-height: 40px; }
.hrow:hover { background: var(--surf-hi); }
.hrow .tx { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
.hrow .l1 { font-size: 13px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hrow .l2 { font-size: 11.5px; color: var(--text-muted); }
.hrow .l2 b { color: var(--text-secondary); }
/* ── you ── */
/* The You section sized by the PANE (container query), never the window:
three columns from ~1240px of pane, two down to ~820, then one. */
+25 -4
View File
@@ -19,7 +19,7 @@
// is CatalogArtist's class for class with its animation numbers unchanged.
// Styles are copied because Svelte scopes them per component — the same
// trade CatalogDiscography made, with provenance comments at each block.
import { tick } from "svelte";
import { tick, untrack } from "svelte";
import { fade } from "svelte/transition";
import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
@@ -41,6 +41,7 @@
importResume,
goToView,
} from "../lib/state.svelte.js";
import { openWhoHas, holdersFor, pairKey, holderCount } from "../lib/whoHas.svelte.js";
import {
fileRelease,
CAT_ORDER,
@@ -121,6 +122,17 @@
} catch { return {}; }
})());
let gapRows = $state({}); // foldKey(artist) -> catalog rows not owned
let gapNames = $state({}); // foldKey(artist) -> the artist's display name
// Who in your rooms holds a gap: asked for every open shelf's rows at
// once, filed by pairKey. A marked card opens the ask sheet.
let heldBy = $state({});
$effect(() => {
const items = Object.keys(gapOpen)
.filter((k) => gapOpen[k] && gapRows[k]?.length && gapNames[k])
.flatMap((k) => gapRows[k].map((r) => ({ artist: gapNames[k], album: r.title })));
if (!items.length) return;
untrack(() => void holdersFor(items).then((m) => { for (const [k, v] of m) heldBy[k] = v; }));
});
let gapOpen = $state({}); // foldKey(artist) -> line expanded
let gapLoading = $state({});
@@ -182,6 +194,7 @@
...gapRows,
[key]: released.filter((r) => !ownedTitles.has(normKey(r.title))),
};
gapNames = { ...gapNames, [key]: artistName };
gapTotals = { ...gapTotals, [key]: { total: released.length } };
try { localStorage.setItem(GAPS_KEY, JSON.stringify(gapTotals)); } catch { /* ignore */ }
} catch { /* the line stays collapsed; nothing is invented */ }
@@ -1544,7 +1557,7 @@
</div>
{/snippet}
{#snippet gapCard(r)}
{#snippet gapCard(r, artist)}
<!-- A record you do NOT own, at full size and dimmed. It is filed by the
same classifier as everything else, off the CatalogAlbum payload's own
fields (`record_type`/`track_count`/`release_date`, plus the is_live and
@@ -1558,7 +1571,8 @@
is_live: r.is_live,
is_soundtrack: r.is_soundtrack,
})}
<div class="release gap" role="listitem" title={`${r.title} — not in your library`}>
{@const held = heldBy[pairKey(artist, r.title)]}
<div class="release gap" class:held={!!held?.length} role="listitem" title={`${r.title} — not in your library`}>
<div class="cover-wrap">
{#if r.release_date?.slice(0, 4)}
<span class="ed-badge">{r.release_date.slice(0, 4)}</span>
@@ -1568,6 +1582,9 @@
{:else}
<div class="cover placeholder"><Icon name="disc" size={30} /></div>
{/if}
{#if held?.length}
<button class="have" title="{holderCount(held)} in your rooms {holderCount(held) === 1 ? "has" : "have"} this — who?" onclick={(e) => { e.stopPropagation(); openWhoHas({ artist, album: r.title, cover: r.cover ?? null }, e.currentTarget.closest(".release")); }}>{holderCount(held)} {holderCount(held) === 1 ? "has it" : "have it"}</button>
{/if}
</div>
<div class="title" title={r.title}>{r.title}</div>
<div class="sub">{cat === "other" ? "" : CAT_ONE[cat]}</div>
@@ -1826,7 +1843,7 @@
<div class="lib-gaps" role="list">
<div class="grid">
{#each missing as r (r.id)}
{@render gapCard(r)}
{@render gapCard(r, name)}
{/each}
</div>
</div>
@@ -2942,6 +2959,10 @@
.lib-gaps { margin-top: 12px; }
.lib-gaps .release.gap { opacity: 0.44; transition: opacity 150ms ease; cursor: default; }
.lib-gaps .release.gap:hover { opacity: 1; background: none; }
.lib-gaps .release.gap.held { opacity: 0.72; }
/* who in your rooms holds a release you lack — the count rides the card */
.have { position: absolute; right: 6px; bottom: 6px; z-index: 2; display: inline-flex; align-items: center; gap: 4px; height: 20px; padding: 0 7px; border-radius: 99px; background: rgba(0, 0, 0, 0.62); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); color: #fff; font-size: 10.5px; font-weight: 900; border: 1px solid rgba(255, 255, 255, 0.14); cursor: pointer; white-space: nowrap; }
.have:hover { background: rgba(0, 0, 0, 0.82); border-color: rgba(255, 255, 255, 0.34); }
/* ============================================================ the table
The power-user surface: every column is something the files carry. The
+23
View File
@@ -39,6 +39,7 @@
} from "../lib/state.svelte.js";
import { untrack } from "svelte";
import * as api from "../lib/api.js";
import { community, forgetWait } from "../lib/community.svelte.js";
import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
@@ -408,6 +409,18 @@
</div>
{#if sub === "downloads"}
{#if community.waits.length}
<div class="waits">
<div class="waits-head">Waiting for <b>{community.waits.length}</b> · grabs that start the moment the person comes online</div>
{#each community.waits as w (w.username + "\u0000" + w.artist + "\u0000" + w.album)}
<div class="wait">
<span class="tx"><span class="t">{w.album}</span><span class="a">{w.artist} · from <b>{w.username}</b>{#if w.via} · via {w.via}{/if}</span></span>
<span class="chip">waiting</span>
<button class="toolbtn" onclick={() => forgetWait(w.username, w.artist, w.album)}>Forget</button>
</div>
{/each}
</div>
{/if}
{#if dlItems.length === 0}
<div class="empty">
<p class="big">No downloads yet</p>
@@ -1622,4 +1635,14 @@
:global(:root.glass) .bub.me {
background: var(--accent-soft);
}
/* holdings: albums waiting on a person's presence */
.waits { margin: 0 0 12px; padding: 8px 10px; border: 1px dashed rgba(255, 255, 255, 0.16); border-radius: 10px; }
.waits-head { font-size: 11.5px; color: var(--text-muted); margin-bottom: 4px; }
.waits-head b { color: var(--text-secondary); }
.wait { display: flex; align-items: center; gap: 10px; padding: 6px 4px; }
.wait .tx { min-width: 0; flex: 1; display: flex; flex-direction: column; }
.wait .t { font-size: 12.5px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wait .a { font-size: 11px; color: var(--text-muted); }
.wait .a b { color: var(--text-secondary); }
.wait .chip { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 99px; background: var(--bg-highlight); color: var(--text-secondary); }
</style>