Everything written this session that has not been through the device: rounds 43 to 48 (each patch adversarially verified, 43's transient-notice expectation fixed), a partial round 49, the pipeline scripts, the Android Auto design and the icon comparison mock. Also the two harness changes that were never A/B'd — adaptive waits, which may only shorten a declared wait and never lengthen it, and a ship that runs the mock walk and the deploy at the same time. The A/B and its 255-step baseline are in handoff/tools; main stays at what the phone has actually walked.
186 lines
8.8 KiB
Plaintext
186 lines
8.8 KiB
Plaintext
<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>
|