From f792d7955d4c02cccda3a75e499b20907a01a337 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:28:38 -0500 Subject: [PATCH] =?UTF-8?q?android:=20a=20peer=20in=20full=20=E2=80=94=20t?= =?UTF-8?q?heir=20card,=20their=20interests,=20and=20what=20they=20have=20?= =?UTF-8?q?that=20you=20don't?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ANDROID.md | 14 +++ src/lib/mockLibrary.js | 53 ++++++++++ src/mobile/HalfSheet.svelte | 3 + src/mobile/Mobile.svelte | 4 + src/mobile/Viewer.svelte | 1 + src/mobile/screens/Peer.svelte | 185 +++++++++++++++++++++++++++++++++ src/mobile/shell.css | 8 ++ tests/mobile/smoke.mjs | 24 +++++ 8 files changed, 292 insertions(+) create mode 100644 src/mobile/screens/Peer.svelte 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."}
+ {#if info.picture_note}
{info.picture_note}
{/if} +{/if} + +

Interests · what they declared on Soulseek

+{#if likes.length || hates.length} + {#if likes.length} +
Likes
+
{#each likes as l, i (i)}{l}{/each}
+ {/if} + {#if hates.length} +
Hates
+
{#each hates as h, i (i)}{h}{/each}
+ {/if} +{:else if intErr} +
The server did not answer.
+{:else if interests} +
None declared.
+{:else if online} +
Asking the server…
+{:else} +
Sign in to Soulseek to see what they declared.
+{/if} + +

Library overlap · compared on this phone

+{#if overlap} + {@const o = overlap} +
+ {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.` : ""}
+ {#each o.gaps.slice(0, cap) as g, i (i)} + + {/each} + {#if o.gaps.length > cap} +
+ {/if} + {:else} +
Nothing they have that you lack.
+ {/if} +
+ + +
+{:else} +
Not compared yet.Their share folders against your albums, on this phone — nothing is uploaded to do it.
+
+ +
+{/if} + +
diff --git a/src/mobile/shell.css b/src/mobile/shell.css index b620170..5d2449f 100644 --- a/src/mobile/shell.css +++ b/src/mobile/shell.css @@ -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); } diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs index eddd1be..3db129f 100644 --- a/tests/mobile/smoke.mjs +++ b/tests/mobile/smoke.mjs @@ -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),