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),