forked from Keelrleek/SoulStream
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:
1 parent
a98c750908
commit
b8e6fb7396
11 files changed
+454
-6
No files matched your search
@@ -28,6 +28,7 @@
|
|||||||
import UploadToast from "./components/UploadToast.svelte";
|
import UploadToast from "./components/UploadToast.svelte";
|
||||||
import ProposalToast from "./components/ProposalToast.svelte";
|
import ProposalToast from "./components/ProposalToast.svelte";
|
||||||
import FollowNewsToast from "./components/FollowNewsToast.svelte";
|
import FollowNewsToast from "./components/FollowNewsToast.svelte";
|
||||||
|
import WhoHasSheet from "./components/WhoHasSheet.svelte";
|
||||||
import SetlistScanChip from "./components/SetlistScanChip.svelte";
|
import SetlistScanChip from "./components/SetlistScanChip.svelte";
|
||||||
import {
|
import {
|
||||||
ui,
|
ui,
|
||||||
@@ -188,6 +189,7 @@
|
|||||||
<UploadToast />
|
<UploadToast />
|
||||||
<ProposalToast />
|
<ProposalToast />
|
||||||
<FollowNewsToast />
|
<FollowNewsToast />
|
||||||
|
<WhoHasSheet />
|
||||||
<SetlistScanChip />
|
<SetlistScanChip />
|
||||||
<PreviewOverlay />
|
<PreviewOverlay />
|
||||||
<LyricsSyncEditor />
|
<LyricsSyncEditor />
|
||||||
|
|||||||
@@ -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.
@@ -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 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 spaceDecide = (owner, space, proposal, accept, reason) => invoke("space_decide", { owner, space, proposal, accept, reason });
|
||||||
export const overlapList = () => invoke("overlap_list");
|
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 overlapCompute = (username) => invoke("overlap_compute", { username });
|
||||||
export const overlapForget = (username) => invoke("overlap_forget", { username });
|
export const overlapForget = (username) => invoke("overlap_forget", { username });
|
||||||
export const buddyList = () => invoke("buddy_list");
|
export const buddyList = () => invoke("buddy_list");
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ export const community = $state({
|
|||||||
boardError: "",
|
boardError: "",
|
||||||
/** overlap_list: library comparisons computed on this device, best first. */
|
/** overlap_list: library comparisons computed on this device, best first. */
|
||||||
overlap: [],
|
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
|
/** persona key → ProfileView | {none} | {error} | {pending}: other
|
||||||
* people's signed profiles, read once per session when surfaced. */
|
* people's signed profiles, read once per session when surfaced. */
|
||||||
remoteProfiles: {},
|
remoteProfiles: {},
|
||||||
@@ -350,6 +355,18 @@ function wireEvents() {
|
|||||||
void refreshFollowing();
|
void refreshFollowing();
|
||||||
});
|
});
|
||||||
api.onEvent("community:overlap", () => void refreshOverlap());
|
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) => {
|
api.onEvent("community:proposal", (p) => {
|
||||||
announceProposal(p.from, p.playlist);
|
announceProposal(p.from, p.playlist);
|
||||||
// If the board is already on screen, show what it is talking about.
|
// 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) => {
|
api.onEvent("soulseek:user-state", (p) => {
|
||||||
if (p.status === 1 || p.status === 2) noteSeen(p.username);
|
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. */
|
/** Idempotent: pulls the state the Community page draws. */
|
||||||
export async function initCommunity() {
|
export async function initCommunity() {
|
||||||
wireEvents();
|
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();
|
await refreshPersona();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,6 +398,9 @@ export async function initCommunity() {
|
|||||||
* that comes up later — or an inbox published later — simply starts working
|
* that comes up later — or an inbox published later — simply starts working
|
||||||
* without anything having to restart the timer. */
|
* without anything having to restart the timer. */
|
||||||
const INBOX_POLL_MS = 90_000;
|
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
|
/** 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
|
* search that can take seconds, and an owner's next revision is a matter
|
||||||
* of days, not minutes. */
|
* 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. */
|
/** 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) {
|
export async function openSpace(owner, space) {
|
||||||
community.spaceError = "";
|
community.spaceError = "";
|
||||||
community.busy["space:" + space] = true;
|
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
|
// Dev handle beside `__soulstream` (state.svelte.js): probes reach the store
|
||||||
// through this, never through a bare import (which would be a second copy).
|
// 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.__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.
@@ -30,6 +30,8 @@
|
|||||||
import { listenerPeak, listenerShare } from "../lib/listenerBars.js";
|
import { listenerPeak, listenerShare } from "../lib/listenerBars.js";
|
||||||
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
|
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
|
||||||
import Icon from "../components/Icon.svelte";
|
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";
|
import PlaylistMenu from "../components/PlaylistMenu.svelte";
|
||||||
|
|
||||||
const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … }
|
const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … }
|
||||||
@@ -887,6 +889,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<div class="tracklist">
|
||||||
{#if tracks.length}
|
{#if tracks.length}
|
||||||
<div class="tcols" class:has-count={showCount}>
|
<div class="tcols" class:has-count={showCount}>
|
||||||
@@ -1976,4 +1985,8 @@
|
|||||||
:global(:root.glass) .stat-note {
|
:global(:root.glass) .stat-note {
|
||||||
border-color: var(--glass-hairline);
|
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>
|
</style>
|
||||||
@@ -52,6 +52,8 @@
|
|||||||
import { openPreview } from "../lib/preview/previewSession.svelte.js";
|
import { openPreview } from "../lib/preview/previewSession.svelte.js";
|
||||||
import { traceToSvg, svgDataUrl } from "../lib/trace.js";
|
import { traceToSvg, svgDataUrl } from "../lib/trace.js";
|
||||||
import Icon from "../components/Icon.svelte";
|
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 PlaylistMenu from "../components/PlaylistMenu.svelte";
|
||||||
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
|
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
|
||||||
import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte";
|
import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte";
|
||||||
@@ -2536,6 +2538,17 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const visibleTabs = $derived(tabs.filter((tab) => tab.key === activeTabKey));
|
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
|
// 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
|
// 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); }}
|
onclick={(e) => { e.stopPropagation(); streamRelease(al, al); }}
|
||||||
><Icon name="play" size={13} /></button>
|
><Icon name="play" size={13} /></button>
|
||||||
{/if}
|
{/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>
|
||||||
<div class="title" title={al.title}>
|
<div class="title" title={al.title}>
|
||||||
{al.title}
|
{al.title}
|
||||||
@@ -5565,6 +5581,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="xp-tracks">
|
<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}
|
{#if openLoading}
|
||||||
<!-- Nothing at all until the load
|
<!-- Nothing at all until the load
|
||||||
has outlasted LOADING_GRACE:
|
has outlasted LOADING_GRACE:
|
||||||
@@ -9602,4 +9624,11 @@
|
|||||||
:global(:root.glass) .notch {
|
:global(:root.glass) .notch {
|
||||||
background: var(--glass-surface);
|
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>
|
</style>
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
import { onMount, untrack } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import Icon from "../components/Icon.svelte";
|
import Icon from "../components/Icon.svelte";
|
||||||
import Identicon, { hueOf } from "../components/Identicon.svelte";
|
import Identicon, { hueOf } from "../components/Identicon.svelte";
|
||||||
|
import WhoHas from "../components/WhoHas.svelte";
|
||||||
import { rankPeople, reasonParts } from "../lib/discover.js";
|
import { rankPeople, reasonParts } from "../lib/discover.js";
|
||||||
import Soulseek from "./Soulseek.svelte";
|
import Soulseek from "./Soulseek.svelte";
|
||||||
import * as api from "../lib/api.js";
|
import * as api from "../lib/api.js";
|
||||||
@@ -96,6 +97,9 @@
|
|||||||
pinToSpace,
|
pinToSpace,
|
||||||
leaveSpace,
|
leaveSpace,
|
||||||
setShareListens,
|
setShareListens,
|
||||||
|
refreshHoldings,
|
||||||
|
holdingsFor,
|
||||||
|
setShareHoldings,
|
||||||
openBoard,
|
openBoard,
|
||||||
proposeToBoard,
|
proposeToBoard,
|
||||||
decideProposal,
|
decideProposal,
|
||||||
@@ -548,6 +552,18 @@
|
|||||||
let spaceCreating = $state(false);
|
let spaceCreating = $state(false);
|
||||||
const sv = $derived(community.space);
|
const sv = $derived(community.space);
|
||||||
const spaceOwner = $derived(sv?.my_role === "owner");
|
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);
|
let spaceEl = $state(null);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const n = sv?.messages.length;
|
const n = sv?.messages.length;
|
||||||
@@ -1362,6 +1378,7 @@
|
|||||||
<div class="sptabs">
|
<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 === "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 === "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 === "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 === "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>
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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"}
|
{:else if spaceTab === "members"}
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="members">
|
<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 .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 { font-size: 9.5px; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }
|
||||||
.mem .role.own { color: #d9a6ff; }
|
.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 ── */
|
/* ── you ── */
|
||||||
/* The You section sized by the PANE (container query), never the window:
|
/* The You section sized by the PANE (container query), never the window:
|
||||||
three columns from ~1240px of pane, two down to ~820, then one. */
|
three columns from ~1240px of pane, two down to ~820, then one. */
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
// is CatalogArtist's class for class with its animation numbers unchanged.
|
// is CatalogArtist's class for class with its animation numbers unchanged.
|
||||||
// Styles are copied because Svelte scopes them per component — the same
|
// Styles are copied because Svelte scopes them per component — the same
|
||||||
// trade CatalogDiscography made, with provenance comments at each block.
|
// trade CatalogDiscography made, with provenance comments at each block.
|
||||||
import { tick } from "svelte";
|
import { tick, untrack } from "svelte";
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import Icon from "../components/Icon.svelte";
|
import Icon from "../components/Icon.svelte";
|
||||||
import PlaylistMenu from "../components/PlaylistMenu.svelte";
|
import PlaylistMenu from "../components/PlaylistMenu.svelte";
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
importResume,
|
importResume,
|
||||||
goToView,
|
goToView,
|
||||||
} from "../lib/state.svelte.js";
|
} from "../lib/state.svelte.js";
|
||||||
|
import { openWhoHas, holdersFor, pairKey, holderCount } from "../lib/whoHas.svelte.js";
|
||||||
import {
|
import {
|
||||||
fileRelease,
|
fileRelease,
|
||||||
CAT_ORDER,
|
CAT_ORDER,
|
||||||
@@ -121,6 +122,17 @@
|
|||||||
} catch { return {}; }
|
} catch { return {}; }
|
||||||
})());
|
})());
|
||||||
let gapRows = $state({}); // foldKey(artist) -> catalog rows not owned
|
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 gapOpen = $state({}); // foldKey(artist) -> line expanded
|
||||||
let gapLoading = $state({});
|
let gapLoading = $state({});
|
||||||
|
|
||||||
@@ -182,6 +194,7 @@
|
|||||||
...gapRows,
|
...gapRows,
|
||||||
[key]: released.filter((r) => !ownedTitles.has(normKey(r.title))),
|
[key]: released.filter((r) => !ownedTitles.has(normKey(r.title))),
|
||||||
};
|
};
|
||||||
|
gapNames = { ...gapNames, [key]: artistName };
|
||||||
gapTotals = { ...gapTotals, [key]: { total: released.length } };
|
gapTotals = { ...gapTotals, [key]: { total: released.length } };
|
||||||
try { localStorage.setItem(GAPS_KEY, JSON.stringify(gapTotals)); } catch { /* ignore */ }
|
try { localStorage.setItem(GAPS_KEY, JSON.stringify(gapTotals)); } catch { /* ignore */ }
|
||||||
} catch { /* the line stays collapsed; nothing is invented */ }
|
} catch { /* the line stays collapsed; nothing is invented */ }
|
||||||
@@ -1544,7 +1557,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet gapCard(r)}
|
{#snippet gapCard(r, artist)}
|
||||||
<!-- A record you do NOT own, at full size and dimmed. It is filed by the
|
<!-- 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
|
same classifier as everything else, off the CatalogAlbum payload's own
|
||||||
fields (`record_type`/`track_count`/`release_date`, plus the is_live and
|
fields (`record_type`/`track_count`/`release_date`, plus the is_live and
|
||||||
@@ -1558,7 +1571,8 @@
|
|||||||
is_live: r.is_live,
|
is_live: r.is_live,
|
||||||
is_soundtrack: r.is_soundtrack,
|
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">
|
<div class="cover-wrap">
|
||||||
{#if r.release_date?.slice(0, 4)}
|
{#if r.release_date?.slice(0, 4)}
|
||||||
<span class="ed-badge">{r.release_date.slice(0, 4)}</span>
|
<span class="ed-badge">{r.release_date.slice(0, 4)}</span>
|
||||||
@@ -1568,6 +1582,9 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="cover placeholder"><Icon name="disc" size={30} /></div>
|
<div class="cover placeholder"><Icon name="disc" size={30} /></div>
|
||||||
{/if}
|
{/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>
|
||||||
<div class="title" title={r.title}>{r.title}</div>
|
<div class="title" title={r.title}>{r.title}</div>
|
||||||
<div class="sub">{cat === "other" ? "" : CAT_ONE[cat]}</div>
|
<div class="sub">{cat === "other" ? "" : CAT_ONE[cat]}</div>
|
||||||
@@ -1826,7 +1843,7 @@
|
|||||||
<div class="lib-gaps" role="list">
|
<div class="lib-gaps" role="list">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each missing as r (r.id)}
|
{#each missing as r (r.id)}
|
||||||
{@render gapCard(r)}
|
{@render gapCard(r, name)}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2942,6 +2959,10 @@
|
|||||||
.lib-gaps { margin-top: 12px; }
|
.lib-gaps { margin-top: 12px; }
|
||||||
.lib-gaps .release.gap { opacity: 0.44; transition: opacity 150ms ease; cursor: default; }
|
.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: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 table
|
||||||
The power-user surface: every column is something the files carry. The
|
The power-user surface: every column is something the files carry. The
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
} from "../lib/state.svelte.js";
|
} from "../lib/state.svelte.js";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import * as api from "../lib/api.js";
|
import * as api from "../lib/api.js";
|
||||||
|
import { community, forgetWait } from "../lib/community.svelte.js";
|
||||||
import Icon from "../components/Icon.svelte";
|
import Icon from "../components/Icon.svelte";
|
||||||
import PlaylistMenu from "../components/PlaylistMenu.svelte";
|
import PlaylistMenu from "../components/PlaylistMenu.svelte";
|
||||||
|
|
||||||
@@ -408,6 +409,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if sub === "downloads"}
|
{#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}
|
{#if dlItems.length === 0}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<p class="big">No downloads yet</p>
|
<p class="big">No downloads yet</p>
|
||||||
@@ -1622,4 +1635,14 @@
|
|||||||
:global(:root.glass) .bub.me {
|
:global(:root.glass) .bub.me {
|
||||||
background: var(--accent-soft);
|
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>
|
</style>
|
||||||
Reference in new issue
Block a user