From 7eae0ee9459c640f5c540d3a87cf9411ed78422e Mon Sep 17 00:00:00 2001
From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com>
Date: Sun, 13 Sep 2026 22:40:17 -0500
Subject: [PATCH] android: a name of your own for anyone on the network
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The desktop's local contact metadata on the phone: a peer's sheet ends in
Name and notes — a petname, a group, a private note and the Favorite and
Trusted marks, kept in the app's own config and published nowhere. The
petname stands in for the username on the buddy list, in the chat list, on
a thread's bar, in the peer sheet, in a room's roster and on a room line,
and the real username stays with it — on the line beneath, or beside the
name in a room — so a stranger is never quietly turned into someone you
know. Favourites sit at the top of the buddy list, and clearing a person's
details drops the entry rather than storing an empty one — the backend's
own rule.
---
ANDROID.md | 14 ++++++
src/lib/mockLibrary.js | 13 ++++++
src/mobile/HalfSheet.svelte | 45 ++++++++++++++++++-
src/mobile/Mobile.svelte | 6 ++-
src/mobile/contacts.svelte.js | 70 +++++++++++++++++++++++++++++
src/mobile/screens/Community.svelte | 18 +++++---
src/mobile/screens/Room.svelte | 3 +-
src/mobile/screens/Thread.svelte | 7 +--
src/mobile/shell.css | 8 ++++
tests/mobile/smoke.mjs | 43 +++++++++++++++++-
10 files changed, 211 insertions(+), 16 deletions(-)
create mode 100644 src/mobile/contacts.svelte.js
diff --git a/ANDROID.md b/ANDROID.md
index 127e8a6..ad2a1e5 100644
--- a/ANDROID.md
+++ b/ANDROID.md
@@ -19,6 +19,7 @@ implements is `research/mobile-mockup/` (gitignored; see its README).
| `src/mobile/catalog.svelte.js` | the catalog lookups, cached per session: an artist by name (the desktop's most-followed-exact-match rule), a catalog record by id |
| `src/mobile/media.svelte.js` | the lock-screen card's wiring: mirrors the bar into the media session plugin on every transport change (plus a top-up every 30 s), carries out the card's, the headset's and audio focus's actions |
| `src/mobile/slsk.svelte.js` | the Soulseek hub: the connection, the rooms, the chats, the buddies — the desktop's `slsk` store and events |
+| `src/mobile/contacts.svelte.js` | the local view of a person on the network: a petname, a group, a note and the two marks (`contacts_meta_list` / `contact_meta_set`) — this phone only, published nowhere |
| `crates/tauri-plugin-media-session/` | a Tauri plugin with a Kotlin foreground service (`MediaService`) that owns the `MediaSession`, the MediaStyle notification, audio focus and the becoming-noisy pause. The audio never passes through it; it is what keeps the process alive in the background and what the lock screen shows |
| `src/mobile/Sheet.svelte`, `HalfSheet.svelte`, `Lane.svelte`, `Cover.svelte`, `Release.svelte` | the player (art / lyrics / queue faces, container transform from the mini bar, drags), the bottom sheets (track, record, sort, the page overflow, the sleep timer), the waveform lane, the two card pieces (a long press on a card is its sheet) |
| `src/mobile/screens/` | Home, Search (the library half: records and artists here, tracks through `library_track_search`), Library (albums / artists / tracks / playlists), Album, Artist (the library's shell at once, the catalog dissolving in: portrait, Popular, discography with what is here marked), CatalogAlbum (a catalog record, rows the library has play), Playlist, Community (sign in; rooms, chat, buddies — the Soulseek side; the private layer waits), Room, Thread, You (Appearance switches, real) |
@@ -193,6 +194,19 @@ filter and renders 80 at a time with a "more" button; rendering all of
them at once cost an audio dropout. The phone walk browses the first peer
of its network search.
+Every name in Community can wear one you gave it. The peer's sheet ends in
+**Name and notes** (`src/mobile/contacts.svelte.js`, `contacts_meta_list` /
+`contact_meta_set`): a petname, a group, a private note and the two marks,
+Favorite and Trusted. All of it sits in the app's own config on this phone
+and is published nowhere — Soulseek never learns it, and neither does the
+person it describes. The petname stands in for the username on the buddy
+list, in the chat list, on a thread's bar, in the peer sheet, in a room's
+roster and on a room line, and the real username stays with it — on the
+line beneath, or beside the name in a room — so a stranger is never quietly
+turned into someone you know. Favourites sit at the top of the buddy list,
+a star and a shield mark the two, and clearing a person's details drops the
+entry rather than storing an empty one — the backend's own rule.
+
More long presses: a catalog song in the search results opens the catalog
track's sheet (play, save, add to a playlist, the record, the artist); a
row of the queue face opens the track's sheet with Play now, Move to next,
diff --git a/src/lib/mockLibrary.js b/src/lib/mockLibrary.js
index a779a0c..bc44ad0 100644
--- a/src/lib/mockLibrary.js
+++ b/src/lib/mockLibrary.js
@@ -381,6 +381,8 @@ const MOCK_USER = "mockuser";
const ROOMS = [["Lounge", 412], ["Electronic", 98], ["Vinyl Rips", 37], ["Jazz", 61], ["Lossless Only", 23], ...Array.from({ length: 90 }, (_, i) => ["Room " + (i + 1), 90 - i])];
const PEOPLE = ["ada", "bo", "cy", "dee"];
const LINES = ["anyone got the 2024 remaster?", "the vinyl rip is warmer imo", "that folder is complete btw", "brb"];
+/* one person already named, so a cold read of the list has something in it */
+const CONTACTS = [{ username: "dusty_crates", petname: "Dusty the digger", group: "Traders", note: "always has the vinyl rips", trusted: true, favorite: false }];
const PEERS = [
{ username: "hifi_hank", slot: true, speed: 2400, queue: 0, ext: "flac", depth: 24, rate: 96000 },
{ username: "dusty_crates", slot: true, speed: 900, queue: 0, ext: "flac", depth: 16, rate: 44100 },
@@ -579,6 +581,17 @@ function mockNet(cmd, args) {
if (cmd === "buddy_list") return Promise.resolve(["ada", "cy"]);
if (cmd === "buddy_add") return Promise.resolve(["ada", "cy", args?.username].filter(Boolean));
if (cmd === "buddy_remove") return Promise.resolve(["ada", "cy"].filter((u) => u !== args?.username));
+ if (cmd === "contacts_meta_list") return Promise.resolve(CONTACTS.slice());
+ if (cmd === "contact_meta_set") {
+ /* the backend's own rule: an entry with nothing set is dropped */
+ const meta = args?.meta || {};
+ const txt = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
+ const i = CONTACTS.findIndex((c) => c.username === meta.username);
+ if (i >= 0) CONTACTS.splice(i, 1);
+ const kept = { username: meta.username, petname: txt(meta.petname), group: txt(meta.group), note: txt(meta.note), trusted: !!meta.trusted, favorite: !!meta.favorite };
+ if (kept.petname || kept.group || kept.note || kept.trusted || kept.favorite) CONTACTS.push(kept);
+ return Promise.resolve(CONTACTS.slice());
+ }
if (cmd === "slsk_room_list") {
later(200, () => mockEmit("soulseek:room-list", ROOMS.map(([name, users]) => ({ name, users }))));
return Promise.resolve();
diff --git a/src/mobile/HalfSheet.svelte b/src/mobile/HalfSheet.svelte
index 722021e..db92ea3 100644
--- a/src/mobile/HalfSheet.svelte
+++ b/src/mobile/HalfSheet.svelte
@@ -18,6 +18,7 @@
import { lib, albumById, albumsBy, tracksOf, loadLibrary, refreshPlaylists, refreshRecents } from "./lib.svelte.js";
import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics, pasteLyrics } from "./lyrics.svelte.js";
import { art, wantArt } from "./art.svelte.js";
+ import { metaOf, display, realName, setMeta, clearMeta } from "./contacts.svelte.js";
import { downloadsAllowed } from "./network.svelte.js";
import { note } from "./notice.svelte.js";
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur, mmss, titleFromName } from "./util.js";
@@ -208,6 +209,16 @@
note(`Saving ${g.files.length} file${g.files.length === 1 ? "" : "s"} from ${g.username}`);
} catch (e) { oops(e); }
}
+ /* the contact editor's three fields go through the prompt sheet. The
+ username travels as a VALUE, the way the playlist prompts do: the
+ sheet switches kind under the prompt, so the contact block's
+ {@const} is a destroyed derived by the time Save lands. */
+ function metaPrompt(u, field, title, placeholder, value, multiline = false) {
+ openHalf("prompt", {
+ title, value, placeholder, ok: "Save", multiline, allowEmpty: true,
+ onsubmit: (v) => { void setMeta(u, { [field]: v }); openHalf("contact", { username: u }); },
+ });
+ }
/* the desktop's per-record cover refresh; the thumbnail is asked for again */
async function refetchCover(album) {
try {
@@ -501,9 +512,10 @@
{@const u = shown.arg.username}
{@const g = shown.arg.group || null}
{@const buddy = slsk.buddies.includes(u)}
+ {@const m = metaOf(u)}
{#if g}
@@ -519,6 +531,35 @@
{/if}
{@render row("message", "Message", "", act(() => { openThread(u); push("thread", { username: u }); }))}
{@render row(buddy ? "x" : "heart", buddy ? "Remove buddy" : "Add buddy", buddy ? "" : "buddies stay on the Community screen", act(() => (buddy ? removeBuddy : addBuddy)(u).catch(oops)))}
+
+ {@render row("pencil", "Name and notes", "a petname, a group, a note — this phone only", () => openHalf("contact", { username: u }))}
+
+ {:else if kind === "contact" && shown?.arg?.username}
+ {@const u = shown.arg.username}
+ {@const m = metaOf(u)}
+
+
+ {display(u)}{realName(u) ? realName(u) + " · " : ""}kept on this phone, published nowhere
+
+
+ {@render row("pencil", "Petname", m.petname || "what you call them here", () => metaPrompt(u, "petname", "Petname", u, m.petname || ""))}
+ {@render row("tag", "Group", m.group || "a word to sort them by", () => metaPrompt(u, "group", "Group", "Traders", m.group || ""))}
+ {@render row("file-text", "Note", m.note || "a private note, for you only", () => metaPrompt(u, "note", "Note", "What you want to remember about them", m.note || "", true))}
+
+
+
+ {#if m.petname || m.group || m.note || m.trusted || m.favorite}
+
+ {@render row("x", "Clear these details", "everything above leaves the list", act(() => { void clearMeta(u); }))}
+ {/if}
{:else if (kind === "catalogAlbum-more" || kind === "catalogAlbum") && catPage}
{@const al = catPage.album}
@@ -634,7 +675,7 @@
{@const buddy = slsk.buddies.includes(u.username)}
{/each}
diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte
index b8b50c0..26d30c4 100644
--- a/src/mobile/Mobile.svelte
+++ b/src/mobile/Mobile.svelte
@@ -49,6 +49,7 @@
import { importer, bindImporter } from "./importer.svelte.js";
import { bindShare } from "./share.svelte.js";
import { downloadsAllowed, cellular } from "./network.svelte.js";
+ import { contacts, loadContacts, display } from "./contacts.svelte.js";
import * as sources from "../lib/sources.js";
import { greeting, drag } from "./util.js";
@@ -68,7 +69,7 @@
case "library": return { title: "Your library", kicker: "Library" };
case "community": return { title: "Community", kicker: slsk.connection === "online" ? "online as " + slsk.username : "Soulseek" };
case "room": return { title: "#" + e.c.name, kicker: "Room", flush: true };
- case "thread": return { title: e.c.username, kicker: "Chat", flush: true };
+ case "thread": return { title: display(e.c.username), kicker: "Chat", flush: true };
case "you": return { title: "You", kicker: "You" };
case "transfers": return { title: "Transfers", kicker: "Downloads" };
case "import": return { title: "Import", kicker: importer.draft?.source_name || "Playlist" };
@@ -188,13 +189,14 @@
bindHome();
bindImporter();
bindShare();
+ loadContacts();
/* the desktop's library art prewarm: every cover cut to the cards'
edge, one decode at a time behind the tiles' gate, a moment after
boot so the first screen and a restored track come first */
setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000);
/* the probe handle — the desktop has __soulstream for the same reason:
tests/mobile/smoke.mjs drives the real app through it */
- window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, pls, notice, importer, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch };
+ window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, pls, notice, importer, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts };
return () => { if (unlisten) unlisten(); };
});
diff --git a/src/mobile/contacts.svelte.js b/src/mobile/contacts.svelte.js
new file mode 100644
index 0000000..7de86a5
--- /dev/null
+++ b/src/mobile/contacts.svelte.js
@@ -0,0 +1,70 @@
+/* The local view of a person on the network: a petname, a group, a private
+ note and the two marks. All of it lives in this app's own config and is
+ published nowhere — Soulseek never learns it, and neither does the person
+ it describes. The desktop keeps the same index by username
+ (src/lib/community.svelte.js), and the same rule: the backend drops an
+ entry with nothing set, so the list it answers with is the one we take. */
+import * as api from "../lib/api.js";
+import { player } from "./player.svelte.js";
+
+export const contacts = $state({
+ /** username → { username, petname, group, note, trusted, favorite } */
+ by: {},
+ /** the list came back; false until it does, and after a failure */
+ loaded: false,
+ error: "",
+});
+
+const BLANK = { petname: null, group: null, note: null, trusted: false, favorite: false };
+/** what the backend stores: a trimmed string, or nothing at all */
+const trimmed = (v) => { const s = typeof v === "string" ? v.trim() : v; return s ? s : null; };
+
+function index(list) {
+ contacts.by = Object.fromEntries((list || []).map((c) => [c.username, c]));
+ contacts.loaded = true;
+ contacts.error = "";
+}
+
+let inflight = null;
+/** Read the whole list — once at boot, and again whenever anyone asks. A
+ * failure is quiet: a petname is an enhancement, not the screen. */
+export function loadContacts() {
+ if (inflight) return inflight;
+ inflight = api
+ .contactsMetaList()
+ .then(index)
+ .catch((e) => { contacts.loaded = false; contacts.error = String(e); })
+ .finally(() => { inflight = null; });
+ return inflight;
+}
+
+export const metaOf = (u) => contacts.by[u] ?? { username: u, ...BLANK };
+/** The name to show: the petname when there is one, the username otherwise. */
+export const display = (u) => contacts.by[u]?.petname || u;
+/** The username, but only while a petname stands in front of it — a stranger
+ * is never quietly turned into someone you know. */
+export const realName = (u) => (contacts.by[u]?.petname ? u : "");
+
+/** Upsert one person. The answer is the whole list after the write, so it is
+ * taken as it comes rather than patched into our own copy: that is how an
+ * entry with nothing left in it disappears. A failure goes to the bar. */
+export async function setMeta(username, patch) {
+ const m = { ...metaOf(username), ...patch };
+ try {
+ index(
+ await api.contactMetaSet({
+ username,
+ petname: trimmed(m.petname),
+ group: trimmed(m.group),
+ note: trimmed(m.note),
+ trusted: !!m.trusted,
+ favorite: !!m.favorite,
+ }),
+ );
+ } catch (e) {
+ player.error = String(e);
+ }
+}
+
+/** Everything blank: the entry leaves the list rather than being stored empty. */
+export const clearMeta = (u) => setMeta(u, { petname: "", group: "", note: "", trusted: false, favorite: false });
diff --git a/src/mobile/screens/Community.svelte b/src/mobile/screens/Community.svelte
index 19c1ee1..bb358ad 100644
--- a/src/mobile/screens/Community.svelte
+++ b/src/mobile/screens/Community.svelte
@@ -7,6 +7,7 @@
import Identicon from "../../components/Identicon.svelte";
import { slsk, connect, connectStored, disconnect, refreshRooms, joinRoom, leaveRoom, openThread, unreadTotal } from "../slsk.svelte.js";
import { push, patch, openHalf } from "../nav.svelte.js";
+ import { metaOf, display, realName } from "../contacts.svelte.js";
import { player } from "../player.svelte.js";
import { ago } from "../util.js";
@@ -36,7 +37,12 @@
.map(([u, c]) => ({ u, c, last: c.messages[c.messages.length - 1] }))
.sort((a, b) => (b.last?.ts || 0) - (a.last?.ts || 0)),
);
- const buddies = $derived(slsk.buddies.map((u) => ({ u, st: slsk.buddyState[u] || {} })));
+ /* favourites at the top, then by the name you gave them */
+ const buddies = $derived(
+ slsk.buddies
+ .map((u) => ({ u, st: slsk.buddyState[u] || {}, m: metaOf(u) }))
+ .sort((a, b) => (b.m.favorite ? 1 : 0) - (a.m.favorite ? 1 : 0) || display(a.u).localeCompare(display(b.u))),
+ );
/* the hub's browse-by-name: a typed name opens the peer sheet */
function lookUp() {
openHalf("prompt", { title: "Look up a user", placeholder: "Soulseek username", ok: "Open", onsubmit: (v) => { const u = v.trim(); if (u) openHalf("peer", { username: u }); } });
@@ -157,8 +163,8 @@
@@ -173,11 +179,11 @@
No buddies on this account yet.Hold a name in a room to add one, or look one up above.