The raw results take the Soulseek hub's filters — a free slot only, FLAC or MP3 320+ — as chips above the folders. The lyrics sheet has the desktop's paste door: plain text, or LRC with timestamps, kept for the track.
479 lines
27 KiB
Svelte
479 lines
27 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, 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 { mmss, fmtBytes, titleFromName, folderMeta } 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">{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" 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">{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} 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} 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}
|
|
{/if}
|
|
{/if}
|
|
|
|
<div class="endpad"></div>
|