Files
SoulStream/src/mobile/screens/Search.svelte
T
KeelrLeek 3c8989dfa6 android: the hub's filters on the network results, pasted lyrics
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.
2026-09-13 22:03:57 -05:00

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>