android: a peer in full — their card, their interests, and what they have that you don't

The peer sheet's new "About this user" row opens a screen of its own: the
card their client serves (their own words, their picture, the upload slot
and the queue), the likes and hates they declared, and the comparison this
phone makes of their share against the library. The comparison is the
point of opening a stranger — every record they have by an artist in your
library that you don't, each row opening the search that finds it, and a
way to forget the whole comparison again. Nothing is uploaded to work it
out. Their picture opens in the artwork viewer, which now takes a data:
key beside a library path and an https URL.
This commit is contained in:
KeelrLeek committed 2026-09-13 22:28:38 -05:00
1 parent 694a29eb0d
commit f792d7955d
8 files changed
+292

No files matched your search

+14
View File
@@ -231,6 +231,20 @@ played track under "On this phone"). Search corrects an obvious
misspelling from the names this phone knows (the library, the searches that worked) — the desktop's rule,
a word only when a real name is within two edits.
Tapping **About this user** on the peer sheet opens the stranger in full
(`screens/Peer.svelte`, `push("peer", { username })`): the card their own
client serves (`slsk_user_info` — what they wrote about themselves, their
picture, whether a slot is free, the queue, how much they have served),
the likes and hates they declared to the server (`slsk_user_interests`),
and the comparison this phone makes of their share against the library
(`overlap.rs`: `overlap_compute` browses them on purpose, `overlap_list`
reads what was compared before, `overlap_forget` drops it). Nothing is
uploaded to work it out — their folder names against your albums, here.
The comparison's rows are the reason to open a stranger: every record they
have by an artist in your library that you don't, each one opening the
catalog search that finds it. Their picture opens in the artwork viewer,
which takes a `data:` key now as well as a library path and an https URL.
## Playlists
The desktop's two playlist pages (`src/views/Playlists.svelte`,
+53
View File
@@ -388,6 +388,22 @@ const PEERS = [
{ username: "mp3_marge", slot: true, speed: 500, queue: 0, ext: "mp3", bitrate: 320 },
{ username: "slowpoke", slot: false, speed: 60, queue: 12, ext: "mp3", bitrate: 192 },
];
/* a peer's own card, and the comparison overlap.rs keeps per username */
const MOCK_PIC = "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAAEUlEQVR42mOQz+7HihiGlgQAV11GQceAU4sAAAAASUVORK5CYII=";
const MOCK_ABOUT = "Ripping my own vinyl since 2004. FLAC only, folders named Artist - Year - Album. Ask before you queue a whole discography.";
const MOCK_LIKES = ["french house", "vinyl rips", "detroit techno", "boards of canada"];
const MOCK_HATES = ["transcodes", "128 kbps"];
const OVERLAP_COMMON = [["Daft Punk", "Discovery"], ["Daft Punk", "Homework"], ["Caribou", "Swim"]];
const OVERLAP_GAPS = [
["Boards of Canada", "Geogaddi"],
["Aphex Twin", "Drukqs"],
["Daft Punk", "Alive 2007"],
["Burial", "Untrue"],
["Four Tet", "Rounds"],
["Autechre", "Amber"],
];
const overlaps = new Map();
const pairsOf = (list) => list.map(([artist, album]) => ({ artist, album }));
const TITLES = ["One More Time", "Aerodynamic", "Digital Love", "Harder, Better, Faster, Stronger", "Crescendolls", "Nightvision", "Superheroes", "High Life", "Something About Us", "Voyager", "Veridis Quo", "Short Circuit", "Face to Face", "Too Long"];
function peerResponse(token, peer, query) {
const artist = /daft/i.test(query) ? "Daft Punk" : (query.split(/\s+/)[0] || "Various");
@@ -601,6 +617,43 @@ function mockNet(cmd, args) {
{ name: "@@shared\\Music\\Scans", files: [{ name: "front.jpg", path: "@@shared\\Music\\Scans\\front.jpg", size: 1200000, ext: "jpg", bitrate: null, duration: null }] },
]), 600));
}
if (cmd === "slsk_user_info") {
const u = String(args?.username || "");
const peer = PEERS.find((x) => x.username === u);
return new Promise((r) => setTimeout(() => r({
username: u,
description: MOCK_ABOUT,
picture_png_base64: MOCK_PIC,
picture_note: null,
total_uploads: 18422,
queue_size: peer ? peer.queue : 0,
has_free_slot: peer ? peer.slot : true,
}), 450));
}
if (cmd === "slsk_user_interests") {
return new Promise((r) => setTimeout(() => r({ likes: MOCK_LIKES.slice(), hates: MOCK_HATES.slice() }), 400));
}
if (cmd === "overlap_list") return Promise.resolve([...overlaps.values()]);
if (cmd === "overlap_compute") {
/* the real one browses their whole share before it compares: the wait is the point */
const u = String(args?.username || "");
const o = {
username: u,
computed_at: Math.floor(Date.now() / 1000),
folders: 812,
audio_files: 9134,
their_albums: 74,
albums_in_common: pairsOf(OVERLAP_COMMON),
albums_in_common_count: OVERLAP_COMMON.length,
artists_in_common: ["Daft Punk", "Caribou"],
artists_in_common_count: 2,
gaps: pairsOf(OVERLAP_GAPS),
gaps_count: OVERLAP_GAPS.length,
score: OVERLAP_COMMON.length / 74,
};
return new Promise((r) => setTimeout(() => { overlaps.set(u.toLowerCase(), o); r(o); }, 1100));
}
if (cmd === "overlap_forget") { overlaps.delete(String(args?.username || "").toLowerCase()); return Promise.resolve(); }
if (cmd === "start_search") {
const t = ++token;
const q = String(args?.query || "");
+3
View File
@@ -511,6 +511,9 @@
{@render row("download", "Save this folder to library", "every file, as it is", act(() => saveGroup(g)))}
<div class="sheet-hair"></div>
{/if}
{#if !(top().n === "peer" && top().c.username === u)}
{@render row("users", "About this user", "their card, and what they have that you don't", act(() => push("peer", { username: u })))}
{/if}
{#if !(top().n === "browse" && top().c.username === u)}
{@render row("folder-open", "Browse their files", "everything they share", act(() => push("browse", { username: u })))}
{/if}
+4
View File
@@ -25,6 +25,7 @@
import Playlist from "./screens/Playlist.svelte";
import Import from "./screens/Import.svelte";
import Browse from "./screens/Browse.svelte";
import Peer from "./screens/Peer.svelte";
import Community from "./screens/Community.svelte";
import Room from "./screens/Room.svelte";
import Thread from "./screens/Thread.svelte";
@@ -72,6 +73,7 @@
case "transfers": return { title: "Transfers", kicker: "Downloads" };
case "import": return { title: "Import", kicker: importer.draft?.source_name || "Playlist" };
case "browse": return { title: e.c.username, kicker: "Their files" };
case "peer": return { title: e.c.username, kicker: "On Soulseek" };
case "album": {
const a = albumById(e.c.id);
return { title: a?.title ?? "", kicker: a?.artist ?? "", amb: a?.art_path ?? null, flush: true };
@@ -234,6 +236,8 @@
<Import />
{:else if entry.n === "browse"}
<Browse username={entry.c.username} />
{:else if entry.n === "peer"}
<Peer username={entry.c.username} {screenEl} />
{:else if entry.n === "community"}
<Community {entry} {screenEl} />
{:else if entry.n === "room"}
+1
View File
@@ -16,6 +16,7 @@
scale = 1; tx = 0; ty = 0;
if (!k) return;
if (/^https?:/i.test(k)) { full = k; return; }
if (/^data:/i.test(k)) { full = k; return; } /* a peer's own picture: the bytes are already here */
if (/^stream:/.test(k)) return; /* a stream's tag art: the thumbnail is all there is */
let live = true;
api.albumArt(k, 2000).then((u) => { if (live && u) full = u; }, () => { /* the thumbnail stays */ });
+185
View File
@@ -0,0 +1,185 @@
<script>
/* A stranger, in full — the desktop's person drawer as a screen: the
card their own client serves (what they wrote about themselves, their
picture, what their uploads are doing), the likes and hates they
declared to the server, and the comparison this phone makes of their
share against the library. The comparison is the reason to open a
stranger at all: every record they have by an artist in your library
that you don't. Nothing is uploaded to work it out — their
folder names against your albums, here (`overlap.rs`). */
import Icon from "../../components/Icon.svelte";
import Identicon from "../../components/Identicon.svelte";
import * as api from "../../lib/api.js";
import { slsk, openThread } from "../slsk.svelte.js";
import { push, openHalf, openViewer } from "../nav.svelte.js";
import { player } from "../player.svelte.js";
import { note } from "../notice.svelte.js";
import { ago } from "../util.js";
let { username, screenEl = null } = $props();
/* the comparison keeps 120 named pairs; a screenful shows, the rest on
demand — the cap Browse gives a big share */
const GAPS_CAP = 40;
let info = $state(null); // slsk_user_info, once their client answers
let infoErr = $state("");
let interests = $state(null); // slsk_user_interests, from the server
let intErr = $state(false);
let overlap = $state(null); // the comparison, if this device made one
let busy = $state(false); // a comparison running: it browses them first
let cap = $state(GAPS_CAP);
const online = $derived(slsk.connection === "online");
const picture = $derived(info?.picture_png_base64 ? "data:image/png;base64," + info.picture_png_base64 : null);
const likes = $derived(interests?.likes ?? []);
const hates = $derived(interests?.hates ?? []);
/* both asked at once. The card comes from THEIR client, direct and peer
to peer, so it can simply go unanswered; the interests come from the
server, which answers or doesn't. */
$effect(() => {
const u = username;
if (!u || !online) return;
let live = true;
info = null;
infoErr = "";
interests = null;
intErr = false;
api.slskUserInfo(u).then((v) => { if (live) info = v; }, (e) => { if (live) infoErr = String(e).replace(/^Error:\s*/, ""); });
api.slskUserInterests(u).then((v) => { if (live) interests = v; }, () => { if (live) intErr = true; });
return () => { live = false; };
});
/* the ledger is per username and kept on the device: opening a stranger
reads what was compared before, never compares — comparing browses
their whole share, which is a minute of someone else's bandwidth */
async function readOverlap() {
try {
const all = (await api.overlapList()) || [];
const k = String(username || "").trim().toLowerCase();
overlap = all.find((o) => String(o.username).toLowerCase() === k) ?? null;
} catch {
overlap = null;
}
}
$effect(() => { void username; cap = GAPS_CAP; void readOverlap(); });
async function compare() {
if (busy || !online) return;
busy = true;
try {
await api.overlapCompute(username);
await readOverlap();
cap = GAPS_CAP;
} catch (e) { player.error = String(e); }
busy = false;
}
async function forget() {
if (busy) return;
busy = true;
try {
await api.overlapForget(username);
overlap = null;
note("Comparison forgotten");
} catch (e) { player.error = String(e); }
busy = false;
}
/* a record they have and the library doesn't: the catalog search that
finds it, where Save can fetch it from whoever has it best */
function findRecord(g) {
push("search", { q: [g.artist, g.album].filter(Boolean).join(" ") }, screenEl);
}
const uploadLine = $derived.by(() => {
if (!online) return "not signed in to Soulseek";
if (infoErr) return "their client did not answer";
if (!info) return "asking their client…";
return (info.has_free_slot ? "a slot is free" : "no free slot")
+ " · queue " + (info.queue_size ?? 0)
+ " · " + (info.total_uploads ?? 0).toLocaleString() + " served";
});
</script>
<div class="brw-head pr-head">
{#if picture}
<button class="pr-pic" onclick={() => openViewer(picture)} aria-label="Their picture"><img src={picture} alt="" /></button>
{:else}
<Identicon seed={username} size={44} />
{/if}
<div class="b">
<div class="t">{username}</div>
<div class="m">{uploadLine}</div>
</div>
<button class="iconbtn muted" onclick={() => openHalf("peer", { username })} aria-label="More"><Icon name="more" size={22} /></button>
</div>
<div class="brw-acts">
<button class="btn btn-fill" onclick={() => { openThread(username); push("thread", { username }, screenEl); }}><Icon name="message" size={14} />Message</button>
<button class="btn btn-ghost" onclick={() => push("browse", { username }, screenEl)}><Icon name="folder-open" size={14} />Browse their files</button>
</div>
{#if !online}
<div class="sec-note">Sign in to Soulseek to ask their client for their card.</div>
{:else if infoErr}
<div class="sec-note">Their client did not answer ({infoErr}) — offline, firewalled, or not sharing a card.</div>
{:else if !info}
<div class="sec-note">Asking their client for the card — direct, peer to peer.</div>
{:else}
<div class="pr-about">{info.description?.trim() || "They wrote nothing about themselves."}</div>
{#if info.picture_note}<div class="sec-note">{info.picture_note}</div>{/if}
{/if}
<div class="sec"><h2>Interests <em>· what they declared on Soulseek</em></h2></div>
{#if likes.length || hates.length}
{#if likes.length}
<div class="sec-note">Likes</div>
<div class="chips pr-tags">{#each likes as l, i (i)}<span class="chip quiet">{l}</span>{/each}</div>
{/if}
{#if hates.length}
<div class="sec-note" style="padding-top: 10px">Hates</div>
<div class="chips pr-tags">{#each hates as h, i (i)}<span class="chip quiet">{h}</span>{/each}</div>
{/if}
{:else if intErr}
<div class="sec-note">The server did not answer.</div>
{:else if interests}
<div class="sec-note">None declared.</div>
{:else if online}
<div class="sec-note">Asking the server…</div>
{:else}
<div class="sec-note">Sign in to Soulseek to see what they declared.</div>
{/if}
<div class="sec"><h2>Library overlap <em>· compared on this phone</em></h2></div>
{#if overlap}
{@const o = overlap}
<div class="sec-note pr-sum">
{o.their_albums
? `${o.albums_in_common_count} of their ${o.their_albums} albums are in your library · ${o.artists_in_common_count} artist${o.artists_in_common_count === 1 ? "" : "s"} in common · compared ${ago(o.computed_at)}`
: `No album folders among ${o.audio_files} audio files · compared ${ago(o.computed_at)}`}
</div>
{#if o.gaps.length}
<div class="sec-note" style="padding-top: 6px">They have {o.gaps_count} record{o.gaps_count === 1 ? "" : "s"} by artists in your library that you don't.{o.gaps.length > cap ? ` The first ${cap} are here.` : ""}</div>
{#each o.gaps.slice(0, cap) as g, i (i)}
<button class="row pr-gap" onclick={() => findRecord(g)}>
<span class="lead"><Icon name="disc" size={20} /></span>
<span class="b"><span class="t">{g.album}</span><span class="m">{g.artist}</span></span>
<span class="r"><Icon name="search" size={17} /></span>
</button>
{/each}
{#if o.gaps.length > cap}
<div style="padding: 4px var(--pad) 8px"><button class="btn btn-ghost wide" onclick={() => (cap += 200)}>+ {o.gaps.length - cap} more records</button></div>
{/if}
{:else}
<div class="sec-note" style="padding-top: 6px">Nothing they have that you lack.</div>
{/if}
<div class="brw-acts" style="padding-top: 8px">
<button class="btn btn-ghost" onclick={compare} disabled={!online || busy}><Icon name="refresh" size={14} />{busy ? "Browsing…" : "Compare again"}</button>
<button class="btn btn-ghost" onclick={forget} disabled={busy}><Icon name="trash" size={14} />Forget this comparison</button>
</div>
{:else}
<div class="stub"><b>Not compared yet.</b>Their share folders against your albums, on this phone — nothing is uploaded to do it.</div>
<div style="padding: 0 var(--pad) 8px">
<button class="btn btn-ghost wide" onclick={compare} disabled={!online || busy}><Icon name="library" size={14} />{busy ? "Browsing their shares…" : "Compare with my library"}</button>
</div>
{/if}
<div class="endpad"></div>
+8
View File
@@ -1371,3 +1371,11 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
/* the nav's badges: transfers in flight on You, unread chats on Community */
.nav button .gl .nb { position: absolute; top: -3px; right: -12px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px; background: var(--accent); color: #04121a; font-size: 10px; font-weight: 800; line-height: 16px; text-align: center; box-shadow: 0 0 0 2px var(--nav-bg, #101014); }
/* A peer in full: the head wears their own picture, their own words sit in
a card, and the comparison's rows read as records they have and you don't */
.pr-head .pr-pic { width: 44px; height: 44px; flex: none; padding: 0; border: 0; border-radius: 13px; overflow: hidden; background: var(--surface); box-shadow: var(--ring); }
.pr-head .pr-pic img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pr-about { margin: 2px var(--pad) 10px; padding: 12px 14px; border-radius: var(--r-card); background: var(--surface); box-shadow: var(--ring); font-size: 13.5px; line-height: 1.55; color: var(--text-secondary); white-space: pre-wrap; overflow-wrap: anywhere; }
.chips.pr-tags { flex-wrap: wrap; overflow: visible; row-gap: 7px; }
.pr-gap .lead { color: var(--text-muted); }
+24
View File
@@ -143,6 +143,12 @@ const PROBE = `(function () {
artEdge: (function () { var ps = document.querySelectorAll('.prefs[data-mode="albumart"] .pref'); var c = ps[1] && ps[1].querySelector('.chip.on'); return c ? c.textContent.trim() : ''; })(),
brwFiles: document.querySelectorAll('.brw-dir .peer-file').length,
brwHead: (document.querySelector('.brw-head .m') || {}).textContent || '',
peerLine: (document.querySelector('.pr-head .m') || {}).textContent || '',
peerDesc: (document.querySelector('.pr-about') || {}).textContent || '',
peerPic: !!document.querySelector('.pr-pic img'),
peerTags: document.querySelectorAll('.pr-tags .chip').length,
peerGaps: document.querySelectorAll('.pr-gap').length,
peerSum: (document.querySelector('.pr-sum') || {}).textContent || '',
keep: (function () { var g = document.querySelector('.prefs[data-mode="streamcache"]'); var c = g && g.querySelector('.pref .chip.on'); return c ? c.textContent.trim() : ''; })(),
impErr: [].some.call(document.querySelectorAll('.screen .stub'), function (x) { return /didn't import/.test(x.textContent); }),
srcMax: (function () { var g = document.querySelector('.prefs[data-mode="stream"]'); var c = g && g.querySelector('.pref .chip.on'); return c ? c.textContent.trim() : ''; })(),
@@ -261,6 +267,17 @@ const MOCK_STEPS = [
{ do: `document.querySelector('.peer-file').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "peer-file-sheet", wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `document.querySelector('.peer-head .peer-menu').click()`, name: "peer-sheet", wait: 700 },
/* the stranger in full: the card their client serves, their picture in
the viewer, what they declared, and the comparison of their share
against this library — a record they have and it doesn't, found */
{ do: `__click('.halfsheet .row', 'About this user')`, name: "peer-screen", wait: 1400 },
{ do: `document.querySelector('.pr-pic').click()`, name: "peer-picture", wait: 900 },
{ do: `history.back()`, wait: 700 },
{ do: `__click('.screen .btn', 'Compare with my library')`, name: "peer-compare", wait: 2200 },
{ do: `__click('.pr-gap', 'Geogaddi')`, name: "peer-gap-open", wait: 1300 },
{ do: `history.back()`, name: "peer-again", wait: 1200 },
{ do: `__click('.screen .btn', 'Forget this comparison')`, name: "peer-forgotten", wait: 1100 },
{ do: `document.querySelector('.pr-head .iconbtn').click()`, name: "peer-sheet-again", wait: 700 },
{ do: `__click('.halfsheet .row', 'Browse their files')`, name: "browse", wait: 1400 },
{ do: `__click('.brw-dir .peer-folder', 'Homework')`, name: "browse-dir", wait: 600 },
{ do: `__click('.brw-dir .peer-file', 'Aerodynamic')`, name: "browse-play", wait: 2500 },
@@ -481,6 +498,13 @@ const MOCK_EXPECT = {
"net-filter-free": (i) => i.peers === 2,
"peer-file-sheet": (i) => i.half.includes("up") && /Save this file/.test(i.halfText) && /Play from here/.test(i.halfText),
"peer-sheet": (i) => i.half.includes("up") && /Browse their files/.test(i.halfText) && /Play this folder/.test(i.halfText),
"peer-screen": (i) => i.title.startsWith("hifi_hank") && /Ripping my own vinyl/.test(i.peerDesc) && i.peerPic && i.peerTags === 6 && /slot is free/.test(i.peerLine) && /served/.test(i.peerLine) && i.peerGaps === 0,
"peer-picture": (i) => i.viewer,
"peer-compare": (i) => i.peerGaps === 6 && /in your library/.test(i.peerSum),
"peer-gap-open": (i) => i.title.startsWith("Search") && i.sq === "Boards of Canada Geogaddi",
"peer-again": (i) => i.title.startsWith("hifi_hank") && i.peerGaps === 6,
"peer-forgotten": (i) => i.peerGaps === 0 && !i.peerSum && /forgotten/i.test(i.notice),
"peer-sheet-again": (i) => i.half.includes("up") && /Browse their files/.test(i.halfText) && !/About this user/.test(i.halfText),
browse: (i) => i.screen.includes("tr-push") && i.brwDirs >= 2 && /folders/.test(i.brwHead),
"browse-dir": (i) => i.brwFiles >= 8,
"browse-play": (i) => i.playing.includes("Aerodynamic") && /streaming from/.test(i.live),