diff --git a/ANDROID.md b/ANDROID.md
index 4571f92..127e8a6 100644
--- a/ANDROID.md
+++ b/ANDROID.md
@@ -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`,
diff --git a/src/lib/mockLibrary.js b/src/lib/mockLibrary.js
index e75ed81..a779a0c 100644
--- a/src/lib/mockLibrary.js
+++ b/src/lib/mockLibrary.js
@@ -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 || "");
diff --git a/src/mobile/HalfSheet.svelte b/src/mobile/HalfSheet.svelte
index 3c27465..722021e 100644
--- a/src/mobile/HalfSheet.svelte
+++ b/src/mobile/HalfSheet.svelte
@@ -511,6 +511,9 @@
{@render row("download", "Save this folder to library", "every file, as it is", act(() => saveGroup(g)))}
{/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}
diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte
index 5ff1dfc..b8b50c0 100644
--- a/src/mobile/Mobile.svelte
+++ b/src/mobile/Mobile.svelte
@@ -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 @@
{:else if entry.n === "browse"}
+ {:else if entry.n === "peer"}
+
{:else if entry.n === "community"}
{:else if entry.n === "room"}
diff --git a/src/mobile/Viewer.svelte b/src/mobile/Viewer.svelte
index b333d14..eec4ba1 100644
--- a/src/mobile/Viewer.svelte
+++ b/src/mobile/Viewer.svelte
@@ -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 */ });
diff --git a/src/mobile/screens/Peer.svelte b/src/mobile/screens/Peer.svelte
new file mode 100644
index 0000000..53ba9b5
--- /dev/null
+++ b/src/mobile/screens/Peer.svelte
@@ -0,0 +1,185 @@
+
+
+
+ {#if picture}
+
+ {:else}
+
+ {/if}
+
+
{username}
+
{uploadLine}
+
+
+
+
+
+
+
+
+
+{#if !online}
+
Sign in to Soulseek to ask their client for their card.
+{:else if infoErr}
+
Their client did not answer ({infoErr}) — offline, firewalled, or not sharing a card.
+{:else if !info}
+
Asking their client for the card — direct, peer to peer.
+{:else}
+
{info.description?.trim() || "They wrote nothing about themselves."}
+ {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)}`}
+
+ {#if o.gaps.length}
+
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.` : ""}