Nothing here changes what the phone does, and the walks are untouched on
purpose: they are the guard, not the subject. Out go nine imports no file
ever names again, two things nothing in src, tests, crates or src-tauri
has ever referenced — `forgetAccount` in the Soulseek store, whose Sign
out row calls `disconnect`, and `isLossless` in util.js — and two CSS
rules no markup can reach, `.cardpad` and `.iconbtn.liked`; `stamp` and
`fmtCount` had each been written twice word for word, so both bodies move
into util.js unchanged and four screens gain a name on an import line they
already had. The byte is in player.svelte.js, where the play-history key
joins artist and title on a NUL that was written into the source as the
character itself rather than as the escape catalog.svelte.js already uses
— that made the file non-text, so every grep of it since has answered
"Binary file matches" and shown nothing. One line is added rather than
removed: `.mini .mini-lane { width: auto }` reads exactly like the strays
and is the only thing keeping the lane from coming out 32px wider than the
strip it was cut for, so it now says so — and `.feat`, declared twice in
shell.css with different values, is left alone, because untangling that
moves pixels and this round may not. A tenth dead import is missing from
that first list on purpose: Sheet.svelte's `import * as api` is a namespace
import, the one shape Rollup cannot shake, so its reason was the bundle
rather than drift and it went out with the round before.
486 lines
28 KiB
Svelte
486 lines
28 KiB
Svelte
<script>
|
|
/* Search — the desktop's top bar, on the phone: the CATALOG first
|
|
(Deezer, MusicBrainz and Apple behind one field), led by the desktop's
|
|
TOP RESULT — the most popular exact match of its kind, an artist, a
|
|
record or a song, scored as a share of its own kind's leader — then
|
|
artists, records and songs, then what the library holds, then the raw
|
|
network, offered rather than run. A song row plays through the
|
|
resolver (the source rules pick a peer's copy); a record opens its
|
|
page; an artist theirs. */
|
|
import { untrack } from "svelte";
|
|
import Icon from "../../components/Icon.svelte";
|
|
import Release from "../Release.svelte";
|
|
import Cover from "../Cover.svelte";
|
|
import Identicon from "../../components/Identicon.svelte";
|
|
import * as api from "../../lib/api.js";
|
|
import { lib, artists } from "../lib.svelte.js";
|
|
import { push, go, openHalf, patch } from "../nav.svelte.js";
|
|
import { player, playIndexRow, albumOfRow, playEntries } from "../player.svelte.js";
|
|
import { slsk } from "../slsk.svelte.js";
|
|
import { net, search as netSearch, speedLabel, groupFormat, groupEntries } from "../net.svelte.js";
|
|
import { resolver, streamSong, streamCatalogAlbum } from "../resolve.svelte.js";
|
|
import { catalog, artistKey, loadProfile, seedFor, lookupArtist, yearOf, fans, picture } from "../catalog.svelte.js";
|
|
import { latestOf } from "../disco.js";
|
|
import { say } from "../trouble.js";
|
|
import { mmss, fmtBytes, titleFromName } from "../util.js";
|
|
|
|
let { entry, screenEl = null } = $props();
|
|
|
|
let q = $state(entry.c.q || "");
|
|
let inputEl = $state(null);
|
|
/** the screen remembers its query across the tab switch (nav's entry) */
|
|
function setQ(v) { q = v; patch({ q: v }); }
|
|
/* recent searches — the desktop's empty page: the last eight queries
|
|
that were USED (the field committed with Enter, or the screen left
|
|
with a query in it), kept on the device */
|
|
const RECENT_KEY = "search.recent.v1";
|
|
let recents = $state((() => { try { return JSON.parse(localStorage.getItem(RECENT_KEY)) ?? []; } catch { return []; } })());
|
|
function keepRecents() { try { localStorage.setItem(RECENT_KEY, JSON.stringify(recents)); } catch { /* storage off */ } }
|
|
function remember(v) {
|
|
const t = String(v || "").trim();
|
|
if (t.length < 2) return;
|
|
recents = [t, ...recents.filter((x) => x.toLowerCase() !== t.toLowerCase())].slice(0, 8);
|
|
keepRecents();
|
|
}
|
|
function forget(v) { recents = recents.filter((x) => x !== v); keepRecents(); }
|
|
let lastQ = "";
|
|
$effect(() => { lastQ = q.trim(); });
|
|
$effect(() => () => remember(lastQ));
|
|
const query = $derived(q.trim());
|
|
const asking = $derived(query.length >= 2);
|
|
|
|
/* ── the catalog, asked once the typing pauses ─────────────────── */
|
|
let cat = $state(null);
|
|
let catBusy = $state(false);
|
|
let catErr = $state("");
|
|
let catSeq = 0;
|
|
let catTimer = null;
|
|
$effect(() => {
|
|
const qq = query;
|
|
clearTimeout(catTimer);
|
|
if (qq.length < 2) { cat = null; catBusy = false; catErr = ""; return; }
|
|
catBusy = true;
|
|
catErr = "";
|
|
const my = ++catSeq;
|
|
catTimer = setTimeout(() => {
|
|
api.catalogSearch(qq).then(
|
|
(r) => { if (my === catSeq) { cat = r || { artists: [], albums: [], tracks: [] }; catBusy = false; } },
|
|
(e) => { if (my === catSeq) { cat = null; catErr = String(e); catBusy = false; } },
|
|
);
|
|
}, 350);
|
|
return () => clearTimeout(catTimer);
|
|
});
|
|
const allArtists = $derived(cat?.artists ?? []);
|
|
const allAlbums = $derived(cat?.albums ?? []);
|
|
const allTracks = $derived(cat?.tracks ?? []);
|
|
const catArtists = $derived(allArtists.slice(0, 8));
|
|
const catAlbums = $derived(allAlbums.slice(0, 12));
|
|
const catTracks = $derived(allTracks.slice(0, 10));
|
|
const catEmpty = $derived(!!cat && !allArtists.length && !allAlbums.length && !allTracks.length);
|
|
/* a spelling correction, when one is obvious — the desktop's rule: the
|
|
only trustworthy vocabulary offline is the names this phone knows
|
|
(the library, the searches that worked), a word is corrected only
|
|
when a real name is within two edits, nothing is guessed otherwise */
|
|
const vocabulary = $derived.by(() => {
|
|
const words = new Set();
|
|
const add = (text) => { for (const w of String(text ?? "").toLowerCase().split(/[^a-z0-9']+/)) if (w.length >= 3) words.add(w); };
|
|
for (const a of lib.albums) { add(a.artist); add(a.title); }
|
|
for (const r of recents) add(r);
|
|
return [...words];
|
|
});
|
|
const didYouMean = $derived.by(() => {
|
|
if (!catEmpty || catBusy) return null;
|
|
const typed = query.split(/\s+/);
|
|
if (!typed.length || !vocabulary.length) return null;
|
|
let corrected = false;
|
|
const fixed = typed.map((word) => {
|
|
const lower = word.toLowerCase();
|
|
if (lower.length < 3 || vocabulary.includes(lower)) return word;
|
|
let best = null;
|
|
let bestDist = 3;
|
|
for (const c of vocabulary) { const d = editDistance(lower, c, bestDist); if (d < bestDist) { bestDist = d; best = c; } }
|
|
if (!best) return word;
|
|
corrected = true;
|
|
return best;
|
|
});
|
|
return corrected ? fixed.join(" ") : null;
|
|
});
|
|
/** Levenshtein, abandoned as soon as every cell exceeds the limit. */
|
|
function editDistance(a, b, limit) {
|
|
if (Math.abs(a.length - b.length) >= limit) return limit;
|
|
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
for (let i = 1; i <= a.length; i++) {
|
|
let diag = prev[0];
|
|
prev[0] = i;
|
|
let row = i;
|
|
for (let j = 1; j <= b.length; j++) {
|
|
const tmp = prev[j];
|
|
prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
diag = tmp;
|
|
row = Math.min(row, prev[j]);
|
|
}
|
|
if (row >= limit) return limit;
|
|
}
|
|
return prev[b.length];
|
|
}
|
|
|
|
/* ── the top result: the desktop's rule, verbatim ──────────────────
|
|
Each candidate scores as a SHARE OF ITS OWN KIND'S LEADER, never as a
|
|
raw number — fans and rank measure different things on different
|
|
scales. An outright tie falls back to artist → album → song, the
|
|
order of how much a page can say about the thing you named; nothing
|
|
named outright falls back to each list's own most popular entry. */
|
|
const norm = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
const rankOf = (t) => t?.rank ?? 0;
|
|
const top = $derived.by(() => {
|
|
const qn = norm(query);
|
|
if (!qn || !cat) return null;
|
|
const exact = (s) => norm(s) === qn;
|
|
const artist = allArtists.filter((a) => exact(a.name)).sort((a, b) => (b.fans ?? 0) - (a.fans ?? 0))[0] ?? null;
|
|
const song = allTracks.filter((t) => exact(t.title)).sort((a, b) => rankOf(b) - rankOf(a))[0] ?? null;
|
|
const albumRank = (al) => Math.max(0, ...allTracks.filter((t) => t.album_id === al.id).map(rankOf));
|
|
const album = allAlbums.filter((al) => exact(al.title)).sort((a, b) => albumRank(b) - albumRank(a))[0] ?? null;
|
|
const maxFans = Math.max(1, ...allArtists.map((a) => a.fans ?? 0));
|
|
const maxRank = Math.max(1, ...allTracks.map(rankOf));
|
|
const candidates = [
|
|
artist && { kind: "artist", data: artist, score: (artist.fans ?? 0) / maxFans, tie: 3 },
|
|
album && { kind: "album", data: album, score: albumRank(album) / maxRank, tie: 2 },
|
|
song && { kind: "song", data: song, score: rankOf(song) / maxRank, tie: 1 },
|
|
].filter(Boolean);
|
|
if (candidates.length) {
|
|
candidates.sort((a, b) => b.score - a.score || b.tie - a.tie);
|
|
return candidates[0];
|
|
}
|
|
if (allArtists.length) return { kind: "artist", data: allArtists[0] };
|
|
if (allAlbums.length) return { kind: "album", data: allAlbums[0] };
|
|
if (allTracks.length) return { kind: "song", data: [...allTracks].sort((a, b) => rankOf(b) - rankOf(a))[0] };
|
|
return null;
|
|
});
|
|
const topArt = $derived(top?.kind === "artist" ? picture(top.data.picture) : (top?.data.cover ?? null));
|
|
/* only the kind that won pays for its supplement: an artist's profile
|
|
(tags, totals) and its page — warmed while the results are read, as
|
|
the desktop does, so "Open artist" is instant and the latest release
|
|
is the real one (a Deezer SEARCH carries no dates) — or a record's or
|
|
song's artist portrait for the byline */
|
|
$effect(() => {
|
|
const t = top;
|
|
if (!t) return;
|
|
untrack(() => {
|
|
if (t.kind === "artist") { loadProfile(t.data.id); lookupArtist(t.data.name, t.data); }
|
|
else seedFor(t.data.artist);
|
|
});
|
|
});
|
|
const profile = $derived(top?.kind === "artist" ? catalog.profiles[top.data.id] ?? null : null);
|
|
const heroTags = $derived((profile?.tags ?? []).slice(0, 4));
|
|
const byline = $derived(top && top.kind !== "artist" ? catalog.seeds[artistKey(top.data.artist)] ?? null : null);
|
|
const topReleases = $derived(top?.kind === "artist" ? allAlbums.filter((al) => norm(al.artist) === norm(top.data.name)) : []);
|
|
/** The newest record by the hero artist — the other thing a search about
|
|
* an artist is asking. From the artist's own page once it has landed
|
|
* (dated); until then nothing, rather than a guess. */
|
|
const latestRelease = $derived.by(() => {
|
|
if (top?.kind !== "artist") return null;
|
|
const pg = catalog.artists[artistKey(top.data.name)]?.page;
|
|
return pg ? latestOf(pg.albums) : null;
|
|
});
|
|
|
|
function openArtist(name, seed = null) { push("artist", { name, seed }, screenEl); }
|
|
function openAlbum(a) { push("catalogAlbum", { id: a.id, artist: a.artist, title: a.title }, screenEl); }
|
|
function openTop() {
|
|
if (!top) return;
|
|
if (top.kind === "artist") openArtist(top.data.name, top.data);
|
|
else if (top.kind === "album") openAlbum(top.data);
|
|
else if (top.data.album_id) push("catalogAlbum", { id: top.data.album_id, artist: top.data.artist, title: top.data.album }, screenEl);
|
|
}
|
|
function playTop() {
|
|
if (!top) return;
|
|
if (top.kind === "artist") {
|
|
const t = allTracks.find((x) => norm(x.artist) === norm(top.data.name)) ?? allTracks[0];
|
|
if (t) playSong(t);
|
|
} else if (top.kind === "album") {
|
|
streamCatalogAlbum(top.data).catch((e) => { player.error = String(e); });
|
|
} else {
|
|
playSong(top.data);
|
|
}
|
|
}
|
|
function playSong(t) {
|
|
streamSong(t.artist, t.title, {
|
|
trackId: t.id, albumId: t.album_id ?? null, album: t.album || null,
|
|
cover: t.cover ?? null, expectSecs: t.duration_secs ?? null,
|
|
}).catch((e) => { player.error = String(e); });
|
|
}
|
|
const current = (t) => player.track?.id === "cat:" + t.id;
|
|
/* a long press: the catalog track's sheet — play, save, add to a
|
|
playlist, the record, the artist */
|
|
function songSheet(t) {
|
|
openHalf("track", {
|
|
album: { id: null, catalogId: t.album_id ?? null, title: t.album || null, artist: t.artist, art_path: t.cover ?? null },
|
|
track: { id: "cat:" + t.id, title: t.title, artist: t.artist, path: null, duration_secs: t.duration_secs ?? null, catalog: { trackId: t.id, albumId: t.album_id ?? null, cover: t.cover ?? null } },
|
|
});
|
|
}
|
|
|
|
/* ── the library ───────────────────────────────────────────────── */
|
|
const lnorm = (s) => String(s || "").toLowerCase();
|
|
const words = $derived(lnorm(q).split(/\s+/).filter(Boolean));
|
|
const albumHits = $derived(
|
|
words.length
|
|
? lib.albums.filter((a) => { const hay = lnorm(a.title + " " + a.artist); return words.every((w) => hay.includes(w)); }).slice(0, 12)
|
|
: [],
|
|
);
|
|
const artistHits = $derived(
|
|
words.length
|
|
? artists().filter((ar) => { const hay = lnorm(ar.name); return words.every((w) => hay.includes(w)); }).slice(0, 6)
|
|
: [],
|
|
);
|
|
let tracks = $state([]);
|
|
let searching = $state(false);
|
|
let timer = null;
|
|
let seq = 0;
|
|
$effect(() => {
|
|
const qq = query;
|
|
clearTimeout(timer);
|
|
if (qq.length < 2) { tracks = []; searching = false; return; }
|
|
searching = true;
|
|
const my = ++seq;
|
|
timer = setTimeout(() => {
|
|
api.libraryTrackSearch(qq).then(
|
|
(rows) => { if (my === seq) { tracks = (rows || []).slice(0, 20); searching = false; } },
|
|
() => { if (my === seq) { tracks = []; searching = false; } },
|
|
);
|
|
}, 160);
|
|
});
|
|
const libAny = $derived(artistHits.length || albumHits.length || tracks.length);
|
|
const playHit = playIndexRow;
|
|
function moreHit(row) {
|
|
const album = albumOfRow(row) || { id: null, title: row.album, artist: row.artist, art_path: row.art_path ?? null };
|
|
openHalf("track", { album, track: { id: row.path, title: row.title, artist: row.artist, path: row.path, duration_secs: row.duration_secs ?? null } });
|
|
}
|
|
|
|
/* ── the raw network, on request ───────────────────────────────── */
|
|
const online = $derived(slsk.connection === "online");
|
|
const netHere = $derived(online && net.query === query && (net.searching || net.groups.length > 0));
|
|
let expanded = $state(new Set());
|
|
function askNetwork() {
|
|
if (!online || query.length < 2) return;
|
|
netSearch(query).catch(() => {});
|
|
inputEl?.blur();
|
|
}
|
|
/* the hub's filters over the raw results: a free slot only, a format */
|
|
let netFree = $state(false);
|
|
let netFmt = $state("any");
|
|
const shownGroups = $derived(net.groups.filter((g) => {
|
|
if (netFree && !g.slot) return false;
|
|
if (netFmt === "flac") return g.topExt === "flac";
|
|
if (netFmt === "mp3320") return g.topExt !== "flac" && (g.maxBitrate ?? 0) >= 320;
|
|
return true;
|
|
}));
|
|
function playPeer(g, i) {
|
|
const es = groupEntries(g, i);
|
|
if (es.length) playEntries(es, 0, g.folderName).catch((e) => { player.error = String(e); });
|
|
}
|
|
function expand(key) { expanded = new Set([...expanded, key]); }
|
|
function enter(e) {
|
|
if (e.key !== "Enter") return;
|
|
e.preventDefault();
|
|
remember(q);
|
|
inputEl?.blur();
|
|
}
|
|
</script>
|
|
|
|
<div class="phead"><div class="row"><h1>Search</h1></div></div>
|
|
|
|
<div class="sfield">
|
|
<Icon name="search" size={18} />
|
|
<input bind:this={inputEl} value={q} oninput={(e) => setQ(e.currentTarget.value)} type="search" placeholder="Artists, records, songs" autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="search" aria-label="Search" onkeydown={enter} />
|
|
{#if q}
|
|
<button class="iconbtn sm" onclick={() => { setQ(""); inputEl?.focus(); }} aria-label="Clear"><Icon name="x" size={18} /></button>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if !asking}
|
|
<div class="stub">
|
|
<b>The catalog, your library{online ? " and the network" : ""}.</b>
|
|
Artists, records and songs from the catalog; a song plays from the best copy the network has, a record opens its page.
|
|
{#if !online}Sign in on Community to play what the library does not hold.{/if}
|
|
</div>
|
|
{#if lib.recents.length}
|
|
<div class="sec"><h2>Jump back in <em>· picks up where you left off</em></h2></div>
|
|
<div class="rail">
|
|
{#each lib.recents.slice(0, 8) as r (r.album_id)}
|
|
<Release title={r.title} sub={r.artist} path={r.art_path} onclick={() => push("album", { id: r.album_id }, screenEl)} />
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{#if recents.length}
|
|
<div class="sec"><h2>Recent</h2><button class="all" onclick={() => { recents = []; keepRecents(); }}>Clear</button></div>
|
|
<div class="chips recent-chips" style="padding-bottom: 12px">
|
|
{#each recents as r (r)}<button class="chip quiet" onclick={() => setQ(r)} oncontextmenu={(e) => { e.preventDefault(); forget(r); }}>{r}</button>{/each}
|
|
</div>
|
|
{/if}
|
|
{:else}
|
|
{#if catErr}<div class="sec-note">{say(catErr)}</div>{/if}
|
|
{#if catBusy && !cat}<div class="sec-note">Asking the catalog…</div>{/if}
|
|
{#if catEmpty && !catBusy}<div class="sec-note">Nothing on the catalog for “{query}”.{#if didYouMean} Did you mean <button class="dym" onclick={() => setQ(didYouMean)}>{didYouMean}</button>?{/if}</div>{/if}
|
|
|
|
{#if top}
|
|
<div class="top" class:round={top.kind === "artist"}>
|
|
<div class="top-bg" style:background-image={topArt ? `url("${topArt}")` : null}></div>
|
|
<div class="top-in">
|
|
<button class="top-art" onclick={openTop} aria-label={top.kind === "artist" ? "Open artist" : "Open record"}>
|
|
{#if topArt}<img src={topArt} alt="" />{:else}<span class="ph"><Icon name={top.kind === "artist" ? "users" : "disc"} size={30} /></span>{/if}
|
|
</button>
|
|
<div class="top-text">
|
|
<div class="k">Top result · {top.kind === "artist" ? "Artist" : top.kind === "album" ? "Record" : "Song"}</div>
|
|
<div class="top-name">{top.kind === "artist" ? top.data.name : top.data.title}</div>
|
|
{#if top.kind === "artist"}
|
|
<div class="m">{[fans(top.data.fans), topReleases.length ? topReleases.length + " release" + (topReleases.length === 1 ? "" : "s") : "", profile?.listeners ? fans(profile.listeners).replace("fans", "listeners") : ""].filter(Boolean).join(" · ")}</div>
|
|
{#if heroTags.length}<div class="tags">{#each heroTags as t (t)}<span class="chip quiet">{t}</span>{/each}</div>{/if}
|
|
{:else if top.kind === "album"}
|
|
<div class="m">
|
|
<button class="top-who" onclick={() => openArtist(top.data.artist, byline)}>{#if picture(byline?.picture)}<img src={picture(byline.picture)} alt="" />{/if}{top.data.artist}</button>
|
|
{[yearOf(top.data.release_date), top.data.track_count ? `${top.data.track_count} tracks` : ""].filter(Boolean).map((x) => " · " + x).join("")}
|
|
</div>
|
|
{:else}
|
|
<div class="m">
|
|
<button class="top-who" onclick={() => openArtist(top.data.artist, byline)}>{#if picture(byline?.picture)}<img src={picture(byline.picture)} alt="" />{/if}{top.data.artist}</button>
|
|
{top.data.album ? " · " + top.data.album : ""}{top.data.duration_secs ? " · " + mmss(top.data.duration_secs) : ""}
|
|
</div>
|
|
{/if}
|
|
<div class="top-acts">
|
|
<button class="btn btn-fill" onclick={playTop}><Icon name="play" size={14} />{top.kind === "song" && current(top.data) && resolver.active ? "Finding…" : "Play"}</button>
|
|
<button class="btn btn-ghost" onclick={openTop}>{top.kind === "artist" ? "Open artist" : top.kind === "album" ? "Open record" : "Go to record"}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{#if latestRelease}
|
|
<button class="row" onclick={() => openAlbum(latestRelease)}>
|
|
<span class="art">{#if latestRelease.cover}<img src={latestRelease.cover} alt="" loading="lazy" />{:else}<Icon name="disc" size={20} />{/if}</span>
|
|
<span class="b"><span class="t">{latestRelease.title}</span><span class="m">Latest release{yearOf(latestRelease.release_date) ? " · " + yearOf(latestRelease.release_date) : ""}{latestRelease.record_type && latestRelease.record_type !== "album" ? " · " + latestRelease.record_type.toUpperCase() : ""}</span></span>
|
|
<span class="r"><Icon name="chevron-right" size={17} /></span>
|
|
</button>
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if catArtists.length}
|
|
<div class="sec"><h2>Artists</h2></div>
|
|
<div class="rail">
|
|
{#each catArtists as ar (ar.id)}
|
|
<button class="acard cat-artist" onclick={() => openArtist(ar.name, ar)}>
|
|
<span class="por">{#if picture(ar.picture)}<img src={picture(ar.picture)} alt="" loading="lazy" />{:else}<Icon name="users" size={36} />{/if}</span>
|
|
<span class="nm">{ar.name}</span>
|
|
<span class="kd">{fans(ar.fans) || "Artist"}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if catAlbums.length}
|
|
<div class="sec"><h2>Records</h2></div>
|
|
<div class="rail">
|
|
{#each catAlbums as a (a.id)}
|
|
<Release title={a.title} sub={[a.artist, yearOf(a.release_date)].filter(Boolean).join(" · ")} url={a.cover} onclick={() => openAlbum(a)} onmore={() => openHalf("catalogAlbum", { album: a })} />
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if catTracks.length}
|
|
<div class="sec"><h2>Songs</h2></div>
|
|
{#each catTracks as t (t.id)}
|
|
<button class="row cat-song" class:playing={current(t)} onclick={() => playSong(t)} oncontextmenu={(e) => { e.preventDefault(); songSheet(t); }}>
|
|
<span class="art">{#if t.cover}<img src={t.cover} alt="" loading="lazy" />{:else}<Icon name="disc" size={20} />{/if}</span>
|
|
<span class="b"><span class="t">{t.title}</span><span class="m">{t.artist}{t.album ? " · " + t.album : ""}</span></span>
|
|
<span class="r">{current(t) && resolver.active ? "finding…" : mmss(t.duration_secs)}</span>
|
|
</button>
|
|
{/each}
|
|
{/if}
|
|
|
|
{#if libAny}
|
|
<div class="sec"><h2>In your library{#if searching} <em>· searching…</em>{/if}</h2></div>
|
|
{#each artistHits as ar (ar.name)}
|
|
<button class="row" onclick={() => openArtist(ar.name)}>
|
|
<span class="art round"><Cover path={ar.art || ar.cover} size={20} /></span>
|
|
<span class="b"><span class="t">{ar.name}</span><span class="m">{ar.albums} record{ar.albums === 1 ? "" : "s"} · {ar.tracks} tracks</span></span>
|
|
<span class="r"><Icon name="chevron-right" size={17} /></span>
|
|
</button>
|
|
{/each}
|
|
{#if albumHits.length}
|
|
<div class="rail">
|
|
{#each albumHits as a (a.id)}
|
|
<Release title={a.title} sub={a.artist} path={a.art_path} onclick={() => push("album", { id: a.id }, screenEl)} onmore={() => openHalf("album", { album: a })} />
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{#each tracks as row (row.path)}
|
|
<button class="trk" class:playing={player.track?.path === row.path} onclick={() => playHit(row)} oncontextmenu={(e) => { e.preventDefault(); moreHit(row); }}>
|
|
<span class="n"><Icon name="play" size={12} /></span>
|
|
<span class="b"><span class="t">{row.title}</span><span class="m">{row.artist} · {row.album}</span></span>
|
|
<span class="d">{mmss(row.duration_secs)}</span>
|
|
<span class="r" role="button" tabindex="0" aria-label={"More for " + row.title} onkeydown={() => {}} onclick={(e) => { e.stopPropagation(); moreHit(row); }}><Icon name="more" size={17} /></span>
|
|
</button>
|
|
{/each}
|
|
{/if}
|
|
|
|
{#if online}
|
|
{#if !netHere}
|
|
<div style="padding: 18px var(--pad) 0">
|
|
<button class="btn btn-ghost wide net-ask" onclick={askNetwork}><Icon name="globe" size={16} />Search the network for “{query}”</button>
|
|
</div>
|
|
{:else}
|
|
<div class="sec">
|
|
<h2>The network <em>· {net.searching ? "searching…" : net.responses ? net.responses + " people · " + net.files + " files" : "nothing yet"}</em></h2>
|
|
{#if !net.searching}<button class="all net-ask" onclick={askNetwork}>Again<Icon name="refresh" size={14} /></button>{/if}
|
|
</div>
|
|
{#if net.error}<div class="sec-note">{say(net.error)}</div>{/if}
|
|
{#if net.groups.length}
|
|
<div class="chips netfilters" style="padding-bottom: 10px">
|
|
<button class="chip" class:on={netFree} class:quiet={!netFree} aria-pressed={netFree} onclick={() => (netFree = !netFree)}>Free slot</button>
|
|
{#each [["any", "Any format"], ["flac", "FLAC"], ["mp3320", "MP3 320+"]] as [v, l] (v)}
|
|
<button class="chip" class:on={netFmt === v} class:quiet={netFmt !== v} aria-pressed={netFmt === v} onclick={() => (netFmt = v)}>{l}</button>
|
|
{/each}
|
|
</div>
|
|
{#if shownGroups.length !== net.groups.length}<div class="sec-note">{shownGroups.length} of {net.groups.length} folders</div>{/if}
|
|
{/if}
|
|
{#if net.searching && !net.groups.length}
|
|
<div class="sec-note">Asking the network as {slsk.username}…</div>
|
|
{/if}
|
|
{#each shownGroups.slice(0, 40) as g (g.key)}
|
|
<div class="peer">
|
|
<div class="peer-head" oncontextmenu={(e) => { e.preventDefault(); openHalf("peer", { username: g.username, group: g }); }} role="presentation">
|
|
<span class="peer-who">
|
|
<Identicon seed={g.username} size={30} />
|
|
<span><b>{g.username}</b><i>{g.slot ? "slot free" : "queue " + g.queue} · {speedLabel(g.speed)}</i></span>
|
|
</span>
|
|
<span class="dot" class:warn={!g.slot}></span>
|
|
<button class="iconbtn sm muted peer-menu" onclick={() => openHalf("peer", { username: g.username, group: g })} aria-label="More"><Icon name="more" size={18} /></button>
|
|
</div>
|
|
<button class="peer-folder" onclick={() => playPeer(g, 0)}>
|
|
<span class="peer-path">{g.folderName}</span>
|
|
<span class="peer-meta">{groupFormat(g)} · {g.files.length} file{g.files.length === 1 ? "" : "s"} · {fmtBytes(g.size)}</span>
|
|
<span class="peer-get"><Icon name="play" size={14} />Play</span>
|
|
</button>
|
|
<div class="peer-files">
|
|
{#each g.files.slice(0, expanded.has(g.key) ? 200 : 3) as f, i (f.path)}
|
|
<button class="peer-file" class:playing={player.track?.path === f.path} onclick={() => playPeer(g, i)} oncontextmenu={(e) => { e.preventDefault(); openHalf("peerfile", { group: g, index: i }); }}>
|
|
<span>{i + 1}</span>
|
|
<span class="pf-t">{titleFromName(f.name)}</span>
|
|
<span class="pf-m">{f.bitrate ? f.bitrate + " kbps" : (f.extension || "").toUpperCase()}{f.duration ? " · " + mmss(f.duration) : ""}</span>
|
|
</button>
|
|
{/each}
|
|
{#if g.files.length > 3 && !expanded.has(g.key)}
|
|
<button class="peer-more" onclick={() => expand(g.key)}>+ {g.files.length - 3} more</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
{:else}
|
|
<!-- signed out, the whole network section rendered nothing at all: no
|
|
row where "Search the network" lives, and no reason for its
|
|
absence. The catalog and the library above still answer. -->
|
|
<div class="sec-note offline-note">Not signed in, so nothing here can play from the network yet — sign in on Community.</div>
|
|
<div style="padding: 4px var(--pad) 8px"><button class="btn btn-ghost wide" onclick={() => go("community")}>Go to Community</button></div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<div class="endpad"></div>
|