diff --git a/src/App.svelte b/src/App.svelte index 10d8aa8..ada0b01 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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 @@ + diff --git a/src/components/WhoHas.svelte b/src/components/WhoHas.svelte new file mode 100644 index 0000000..cdbe265 --- /dev/null +++ b/src/components/WhoHas.svelte @@ -0,0 +1,196 @@ + + +
+ {#if asking && holders === null} +
Asking your rooms…
+ {:else if !holders?.length} +
+ + {community.spaces.length ? "Nobody in your rooms has published this one." : "You are in no rooms yet — holdings are asked room by room."} +
+ {:else} + {#each rooms as r (r.key)} +
{r.name}{r.holders.length} of the room
+ {#each r.holders as h (h.member)} + {@const u = h.username} +
+ + + {u ? display(u) : fpShort(h.member)}{#if u}Soulseek{/if} + {line(h)} + {#if failed[u]}{failed[u]}{/if} + + + {#if tierWord(h.tier)}{tierWord(h.tier)}{/if} + {#if !u} + + {:else if grabbed[u] != null} + {grabbed[u]} {grabbed[u] === 1 ? "file" : "files"} queued + {:else if waitingOn(u, artist, album)} + waiting + + {:else if failed[u]} + + + {:else} + + {/if} + +
+ {/each} + {/each} +
from your rooms' holdings · {people} {people === 1 ? "person" : "people"}names compared, never files
+ {/if} +
+ + diff --git a/src/components/WhoHasSheet.svelte b/src/components/WhoHasSheet.svelte new file mode 100644 index 0000000..0e447b7 Binary files /dev/null and b/src/components/WhoHasSheet.svelte differ diff --git a/src/lib/api.js b/src/lib/api.js index 3ebe5b7..00fa487 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -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"); diff --git a/src/lib/community.svelte.js b/src/lib/community.svelte.js index bbf6315..e33012f 100644 --- a/src/lib/community.svelte.js +++ b/src/lib/community.svelte.js @@ -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 }; diff --git a/src/lib/whoHas.svelte.js b/src/lib/whoHas.svelte.js new file mode 100644 index 0000000..2c9b38b Binary files /dev/null and b/src/lib/whoHas.svelte.js differ diff --git a/src/views/CatalogAlbum.svelte b/src/views/CatalogAlbum.svelte index 53ed647..5cba703 100644 --- a/src/views/CatalogAlbum.svelte +++ b/src/views/CatalogAlbum.svelte @@ -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 @@ {/if} + {#if !inLibrary && community.spaces.length} +
+

Who has this

from your rooms' holdings · names compared, never files
+ {#key album.artist + "\u0000" + album.title}{/key} +
+ {/if} +
{#if tracks.length}
@@ -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); } diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte index 088730f..49c17bf 100644 --- a/src/views/CatalogArtist.svelte +++ b/src/views/CatalogArtist.svelte @@ -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); }} > {/if} + {#if heldFor(al.title)?.length} + + {/if}
{al.title} @@ -5565,6 +5581,12 @@ {/if}
+ {#if openAlbum && !inLibrary(openAlbum) && heldFor(openAlbum.title)?.length} +
+
Who has thisfrom your rooms' holdings · names compared, never files
+ {#key openAlbum.id}{/key} +
+ {/if} {#if openLoading}