android: the repair sheet, browse, every long-press sheet, You's last sections, the lyrics tools — and the walk's audio check made honest
The playlist repair (Find a local match: the library searched by the row's name, a pick relinks it through playlist_relink_item; an unknown row takes the track's identity). A peer's sheet on the raw results (play or save the folder, browse their files, message, buddy) and the Browse screen — the hub's Browse tab: a user's whole share with sizes and totals, a folder played or saved whole; a share can hold thousands of folders, so it filters and renders 80 at a time. "Look up a user" on Community → Buddies. The sheets the phone lacked: a queue row's (Play now, Move to next, Remove from queue), a catalog song's, a catalog card's and the record page's own, a raw file's (in the results and in Browse), a room name's, a buddy's, the now-playing Save (the desktop's Save menu for a streamed track or record). The artwork viewer with pinch zoom, one layer the back gesture peels. Search's recent searches and its spelling suggestion from the names this phone knows. You → Streaming, Downloads, Sharing, Lyrics, Album artwork, and Clear play history; a record's Re-fetch the cover. The lyrics tools on the sheet's face: the offset nudged and kept per track, another LRCLIB candidate chosen, the lookup forgotten and done again (lyrics.svelte.js, shared by the face and the lyrics sheet). The harness: every CDP call times out and the reload step re-attaches (a WebView that stopped answering hung a walk for ten minutes); the audio check counts SoulStream's own tracks' underruns from the mixer dump instead of the thread-wide counters that any app's notification moves — the afternoon's "dropouts" were other apps' sounds, and the browse of a 49,544-file share measured at zero; __settle resets every tab to its root through the nav; an adb step kind; the walks cover all of the above on the mock and on the phone.
This commit is contained in:
1 parent
6e39dc43f3
commit
26928b706d
21 files changed
+1294
-92
No files matched your search
+226
-5
@@ -5,15 +5,21 @@
|
||||
are key/value rows, not buttons. */
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import Cover from "./Cover.svelte";
|
||||
import Identicon from "../components/Identicon.svelte";
|
||||
import { speedLabel, groupEntries } from "./net.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import { nav, closeHalf, push, top, patch, go, back, openHalf, openSheet } from "./nav.svelte.js";
|
||||
import { player, playNext, enqueue, setSleep, sleepLabel } from "./player.svelte.js";
|
||||
import { streamSong, saveSong } from "./resolve.svelte.js";
|
||||
import { player, playNext, enqueue, playEntries, playQueueAt, removeFromQueue, moveToNext, setSleep, sleepLabel } from "./player.svelte.js";
|
||||
import { streamSong, saveSong, streamCatalogAlbum, saveCatalogAlbum } from "./resolve.svelte.js";
|
||||
import { catalog, yearOf } from "./catalog.svelte.js";
|
||||
import { pls, bulk, bulkable, runBulk, coverFor, queueable, entryOf, playFrom, shufflePlay, togglePin, renamePlaylist, describePlaylist, duplicatePlaylist, deletePlaylist, removeItems, addEntries, createPlaylist } from "./playlists.svelte.js";
|
||||
import { DOORS, start as startImport } from "./importer.svelte.js";
|
||||
import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js";
|
||||
import { lib, albumById, albumsBy, tracksOf, loadLibrary } from "./lib.svelte.js";
|
||||
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur } from "./util.js";
|
||||
import { lib, albumById, albumsBy, tracksOf, loadLibrary, refreshPlaylists, refreshRecents } from "./lib.svelte.js";
|
||||
import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics } from "./lyrics.svelte.js";
|
||||
import { art, wantArt } from "./art.svelte.js";
|
||||
import { note } from "./notice.svelte.js";
|
||||
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur, mmss, titleFromName } from "./util.js";
|
||||
|
||||
const SORTS = [["added", "Recently added"], ["az", "A–Z"], ["artist", "By artist"], ["year", "By year"]];
|
||||
|
||||
@@ -29,6 +35,7 @@
|
||||
if (t.n === "album") return "album-more";
|
||||
if (t.n === "library") return "library-more";
|
||||
if (t.n === "playlist") return "playlist-more";
|
||||
if (t.n === "catalogAlbum") return "catalogAlbum-more";
|
||||
return "home-more";
|
||||
});
|
||||
|
||||
@@ -51,6 +58,15 @@
|
||||
: null,
|
||||
);
|
||||
const trackArg = $derived(kind === "track" ? shown?.arg : null);
|
||||
/* the catalog record page's sheet reads the page the store holds */
|
||||
const catPage = $derived.by(() => {
|
||||
if (kind === "catalogAlbum-more") return catalog.albums[top().c.id]?.page ?? null;
|
||||
if (kind === "catalogAlbum" && shown?.arg?.album) {
|
||||
const a = shown.arg.album;
|
||||
return { album: { ...a, artist: a.artist || a.artistName || "" }, tracks: catalog.albums[a.id]?.page?.tracks ?? [] };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const onThisAlbum = $derived((id) => top().n === "album" && top().c.id === id);
|
||||
const after = $derived(Math.max(0, player.queue.length - player.qi - 1));
|
||||
|
||||
@@ -123,6 +139,80 @@
|
||||
onsubmit: (v) => { push("import", {}); void startImport(door, v); },
|
||||
});
|
||||
}
|
||||
/* find a local match — the desktop's repair panel, on a sheet: the
|
||||
library searched by the row's name, a pick relinks the row (an
|
||||
unknown placeholder adopts the track's identity; the original line
|
||||
survives in source_line) */
|
||||
let matchQ = $state("");
|
||||
let matchRows = $state([]);
|
||||
let matchBusy = $state(false);
|
||||
let matchGen = 0;
|
||||
$effect(() => {
|
||||
const h = nav.half;
|
||||
if (h?.kind !== "plmatch" || !h.arg?.item) return;
|
||||
matchQ = [h.arg.item.artist, h.arg.item.title].filter(Boolean).join(" ");
|
||||
matchRows = [];
|
||||
queueMicrotask(() => void runMatch());
|
||||
});
|
||||
async function runMatch() {
|
||||
const g = ++matchGen;
|
||||
const q = matchQ.trim();
|
||||
if (!q) { matchRows = []; matchBusy = false; return; }
|
||||
matchBusy = true;
|
||||
try {
|
||||
const rows = await api.libraryTrackSearch(q);
|
||||
if (g === matchGen) matchRows = rows || [];
|
||||
} catch {
|
||||
if (g === matchGen) matchRows = [];
|
||||
} finally {
|
||||
if (g === matchGen) matchBusy = false;
|
||||
}
|
||||
}
|
||||
async function applyMatch(p, item, c) {
|
||||
const unknown = item.kind === "unresolved" && (!item.artist || item.title.trim().toLowerCase() === "id");
|
||||
closeHalf();
|
||||
try {
|
||||
await api.playlistRelinkItem(p.id, item.id, {
|
||||
path: c.path, artPath: c.art_path, durationSecs: c.duration_secs,
|
||||
title: unknown ? c.title : null, artist: unknown ? c.artist : null, album: unknown ? c.album : null,
|
||||
});
|
||||
await refreshPlaylists();
|
||||
note(`Linked to “${c.title}”`);
|
||||
} catch (e) { oops(e); }
|
||||
}
|
||||
/* the desktop's Save menu: a streamed track filed into the library —
|
||||
at once when its cache is byte-complete, else through the download
|
||||
pipeline — and the whole streamed record the queue holds */
|
||||
const streamMates = (track, album) => player.queue.filter((e) => e.t?.stream && e.t.path && e.album?.title === album?.title && e.album?.artist === album?.artist);
|
||||
async function saveStreams(entries) {
|
||||
const srcs = entries.filter((e) => e.t?.stream && e.t.path).map((e) => ({ username: e.t.stream.username, path: e.t.path, size: e.t.stream.size }));
|
||||
if (!srcs.length) return;
|
||||
try {
|
||||
const [organized, queued] = await api.saveToLibrary(srcs);
|
||||
note(organized && !queued ? (organized === 1 ? "Saved to the library" : organized + " tracks saved to the library")
|
||||
: queued ? queued + " downloading into the library" + (organized ? " · " + organized + " saved" : "")
|
||||
: "Nothing to save");
|
||||
} catch (e) { oops(e); }
|
||||
}
|
||||
/* a peer's sheet (a raw result's folder, or the browse screen's user) */
|
||||
function playGroup(g) {
|
||||
const es = groupEntries(g, 0);
|
||||
if (es.length) playEntries(es, 0, g.folderName).catch(oops);
|
||||
}
|
||||
async function saveGroup(g) {
|
||||
try {
|
||||
for (const f of g.files) await api.downloadFile(g.username, f.path, f.size);
|
||||
note(`Saving ${g.files.length} file${g.files.length === 1 ? "" : "s"} from ${g.username}`);
|
||||
} catch (e) { oops(e); }
|
||||
}
|
||||
/* the desktop's per-record cover refresh; the thumbnail is asked for again */
|
||||
async function refetchCover(album) {
|
||||
try {
|
||||
await api.refreshAlbumArt(album.id);
|
||||
if (album.art_path) { delete art[album.art_path]; wantArt(album.art_path); }
|
||||
note("Cover re-fetched for “" + album.title + "”");
|
||||
} catch (e) { oops(e); }
|
||||
}
|
||||
/* the prompt's field, seeded when the prompt opens */
|
||||
let promptVal = $state("");
|
||||
$effect(() => {
|
||||
@@ -188,6 +278,14 @@
|
||||
<span class="b"><span class="t">{track.title}</span><span class="m">{track.artist || album?.artist} · {album?.title}</span></span>
|
||||
</div>
|
||||
<div class="sheet-rows">
|
||||
{#if trackArg.queueIndex != null}
|
||||
{@render row("play", "Play now", "", act(() => playQueueAt(trackArg.queueIndex)))}
|
||||
{#if trackArg.queueIndex > player.qi + 1}
|
||||
{@render row("skip-forward", "Move to next", "right after this one", act(() => moveToNext(trackArg.queueIndex)))}
|
||||
{/if}
|
||||
{@render row("x", "Remove from queue", "", act(() => removeFromQueue(trackArg.queueIndex)))}
|
||||
<div class="sheet-hair"></div>
|
||||
{/if}
|
||||
{#if isCatalog(track)}
|
||||
{@render row("play", "Play", "streams from the best source", () => playCatalog(track, album))}
|
||||
{@render row(saveNote.startsWith("Saving") ? "check" : "download", saveNote || "Save to library", saveNote ? "" : "the best copy by your download rules", () => { if (!saveNote) saveTrack(track, album); })}
|
||||
@@ -200,6 +298,13 @@
|
||||
{@render row("users", "Go to artist", track.artist || album.artist, act(() => push("artist", { name: track.artist || album.artist })))}
|
||||
{/if}
|
||||
{:else}
|
||||
{#if track.stream}
|
||||
{@render row("download", "Save to library", "this track, from " + track.stream.username, act(() => saveStreams([{ t: track, album }])))}
|
||||
{#if streamMates(track, album).length > 1}
|
||||
{@render row("download", "Save this record", streamMates(track, album).length + " tracks streaming from the same record", act(() => saveStreams(streamMates(track, album))))}
|
||||
{/if}
|
||||
<div class="sheet-hair"></div>
|
||||
{/if}
|
||||
{@render row("play", "Play next", "", act(() => playNext([{ t: track, album }])))}
|
||||
{@render row("list-plus", "Add to queue", after ? after + " after this" : "", act(() => enqueue([{ t: track, album }])))}
|
||||
{@render row("playlist", "Add to playlist", "", () => openHalf("pickPlaylist", { entries: [{ t: track, album }] }))}
|
||||
@@ -235,6 +340,10 @@
|
||||
{/if}
|
||||
<div class="sheet-hair"></div>
|
||||
{@render row("users", "Go to artist", album.artist, act(() => push("artist", { name: album.artist })))}
|
||||
{#if album.id != null}
|
||||
<div class="sheet-hair"></div>
|
||||
{@render row("image", "Re-fetch the cover", "the canonical high-resolution cover, into the folder and the tags", act(() => refetchCover(album)))}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="sheet-info">
|
||||
<div class="kv"><span class="k">Format</span><span class="v">{albumFmt(tracks) || "—"}</span></div>
|
||||
@@ -293,6 +402,9 @@
|
||||
{:else if item.missing}
|
||||
<div class="sec-note" style="padding: 2px var(--pad) 10px">The file is no longer where the library last saw it.</div>
|
||||
{/if}
|
||||
{#if item.kind !== "local" || item.missing}
|
||||
{@render row("link", "Find a local match", "a track in your library takes this row's place", () => openHalf("plmatch", { playlist: p, items: rows, item }))}
|
||||
{/if}
|
||||
{#if item.artist}
|
||||
{@render row("users", "Go to artist", item.artist, act(() => push("artist", { name: item.artist })))}
|
||||
{/if}
|
||||
@@ -343,6 +455,115 @@
|
||||
{/each}
|
||||
</div>
|
||||
<div class="sec-note" style="padding-bottom: 14px">A playlist file or a folder of audio files imports on the desktop, where the files are.</div>
|
||||
{:else if kind === "plmatch" && shown?.arg?.item}
|
||||
{@const p = shown.arg.playlist}
|
||||
{@const item = shown.arg.item}
|
||||
<div class="sheet-title">Find a local match <span class="t-sub">· {item.title}</span></div>
|
||||
<div class="sfield plmatch">
|
||||
<Icon name="search" size={18} />
|
||||
<input value={matchQ} oninput={(e) => { matchQ = e.currentTarget.value; void runMatch(); }} type="search" placeholder="Search your library" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Search your library" />
|
||||
</div>
|
||||
<div class="sheet-rows">
|
||||
{#if matchBusy && !matchRows.length}
|
||||
<div class="sec-note" style="padding-bottom: 12px">Searching…</div>
|
||||
{:else if !matchRows.length}
|
||||
<div class="sec-note" style="padding-bottom: 12px">No library tracks match. A named row still streams from the network when you tap it.</div>
|
||||
{:else}
|
||||
{#each matchRows.slice(0, 30) as c (c.path)}
|
||||
<button class="row" onclick={() => applyMatch(p, item, c)}>
|
||||
<span class="art"><Cover path={c.art_path} size={16} /></span>
|
||||
<span class="b"><span class="t">{c.title}</span><span class="m">{c.artist} — {c.album}</span></span>
|
||||
<span class="r"><span class="n">{mmss(c.duration_secs)}</span></span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if kind === "peer" && shown?.arg?.username}
|
||||
{@const u = shown.arg.username}
|
||||
{@const g = shown.arg.group || null}
|
||||
{@const buddy = slsk.buddies.includes(u)}
|
||||
<div class="sheet-head">
|
||||
<span class="art idc"><Identicon seed={u} size={44} /></span>
|
||||
<span class="b"><span class="t">{u}</span><span class="m">{g ? (g.slot ? "slot free" : "queue " + g.queue) + " · " + speedLabel(g.speed) + " · " + g.folderName : "on Soulseek"}</span></span>
|
||||
</div>
|
||||
<div class="sheet-rows">
|
||||
{#if g}
|
||||
{@render row("play", "Play this folder", g.files.length + " file" + (g.files.length === 1 ? "" : "s") + ", streamed as a record", act(() => playGroup(g)))}
|
||||
{@render row("download", "Save this folder to library", "every file, as it is", act(() => saveGroup(g)))}
|
||||
<div class="sheet-hair"></div>
|
||||
{/if}
|
||||
{#if !(top().n === "browse" && top().c.username === u)}
|
||||
{@render row("folder-open", "Browse their files", "everything they share", act(() => push("browse", { username: u })))}
|
||||
{/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)))}
|
||||
</div>
|
||||
{:else if (kind === "catalogAlbum-more" || kind === "catalogAlbum") && catPage}
|
||||
{@const al = catPage.album}
|
||||
{@const tracks = catPage.tracks || []}
|
||||
<div class="sheet-head">
|
||||
<span class="art">{#if al.cover}<img src={al.cover} alt="" />{:else}<Icon name="disc" size={22} />{/if}</span>
|
||||
<span class="b"><span class="t">{al.title}</span><span class="m">{al.artist} · {yearOf(al.release_date) ?? "—"} · {tracks.length || al.track_count || "?"} tracks</span></span>
|
||||
</div>
|
||||
<div class="sheet-rows">
|
||||
{@render row("play", "Play", "streams the record from one folder", act(() => streamCatalogAlbum(al, 0).catch(oops)))}
|
||||
{@render row("download", "Save to library", "the best folder by your download rules", act(() => saveCatalogAlbum(al).then((r) => note(r ? `Saving ${r.files} from ${r.username}` : "No sources found")).catch(oops)))}
|
||||
{#if tracks.length}
|
||||
{@render row("playlist", "Add to playlist", tracks.length + " tracks, as streams", () => openHalf("pickPlaylist", { entries: tracks.map((t) => ({ t: { id: "cat:" + t.id, title: t.title, artist: t.artist || al.artist, path: null, duration_secs: t.duration_secs ?? null, catalog: { trackId: t.id, albumId: al.id, cover: al.cover ?? null } }, album: { id: null, catalogId: al.id, title: al.title, artist: al.artist, art_path: al.cover ?? null } })) }))}
|
||||
{/if}
|
||||
<div class="sheet-hair"></div>
|
||||
{#if !(top().n === "catalogAlbum" && top().c.id === al.id)}
|
||||
{@render row("disc", "Go to record", al.title, act(() => push("catalogAlbum", { id: al.id, artist: al.artist, title: al.title })))}
|
||||
{/if}
|
||||
{@render row("users", "Go to artist", al.artist, act(() => push("artist", { name: al.artist })))}
|
||||
</div>
|
||||
{:else if kind === "peerfile" && shown?.arg?.group}
|
||||
{@const g = shown.arg.group}
|
||||
{@const i = shown.arg.index || 0}
|
||||
{@const f = g.files[i]}
|
||||
<div class="sheet-head">
|
||||
<span class="art idc"><Identicon seed={g.username} size={44} /></span>
|
||||
<span class="b"><span class="t">{f ? titleFromName(f.name) : "—"}</span><span class="m">{g.username} · {g.folderName}{f ? " · " + (f.bitrate ? f.bitrate + " kbps" : (f.extension || f.ext || "").toUpperCase()) + " · " + fmtBytes(f.size) : ""}</span></span>
|
||||
</div>
|
||||
<div class="sheet-rows">
|
||||
{@render row("play", "Play from here", "the rest of the folder follows", act(() => { const es = groupEntries(g, i); if (es.length) playEntries(es, 0, g.folderName).catch(oops); }))}
|
||||
{#if f}
|
||||
{@render row("download", "Save this file to library", "as it is, from " + g.username, act(() => api.downloadFile(g.username, f.path, f.size).then(() => note(`Saving “${titleFromName(f.name)}” from ${g.username}`)).catch(oops)))}
|
||||
{/if}
|
||||
<div class="sheet-hair"></div>
|
||||
{@render row("folder-open", "Browse their files", "everything " + g.username + " shares", act(() => push("browse", { username: g.username })))}
|
||||
</div>
|
||||
{:else if kind === "lyrics"}
|
||||
<div class="sheet-title">Lyrics <span class="t-sub">· {lyr.status === "loading" ? "looking" : lyr.source || lyr.status}{lyr.offset ? ` · ${lyr.offset > 0 ? "+" : ""}${(lyr.offset / 1000).toFixed(1)} s` : ""}</span></div>
|
||||
{#if lyr.candidates}
|
||||
<div class="sheet-sec">Choose the right lyrics</div>
|
||||
<div class="sheet-rows">
|
||||
{#if lyr.searching}
|
||||
<div class="sec-note" style="padding-bottom: 12px">Searching LRCLIB…</div>
|
||||
{:else if !lyr.candidates.length}
|
||||
<div class="sec-note" style="padding-bottom: 12px">Nothing found for this track's name.</div>
|
||||
{:else}
|
||||
{#each lyr.candidates as c (c.id)}
|
||||
<button class="row" onclick={act(() => { void chooseCandidate(c.id); })}>
|
||||
<span class="lead"><Icon name={c.synced ? "lyrics" : "file-text"} size={20} /></span>
|
||||
<span class="b"><span class="t">{c.title}</span><span class="m">{c.artist}{c.album ? " — " + c.album : ""}{c.synced ? " · synced" : " · plain"}{c.instrumental ? " · instrumental" : ""}</span></span>
|
||||
<span class="r"><span class="n">{c.durationSecs ? mmss(c.durationSecs) : ""}</span></span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
<div style="padding: 8px var(--pad) 14px"><button class="btn btn-ghost wide" onclick={closeCandidates}>Keep what is showing</button></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="sheet-rows">
|
||||
{#if lyr.lines.length}
|
||||
{@render row("skip-back", "Lyrics earlier", "half a second, kept for this track", () => nudge(-500))}
|
||||
{@render row("skip-forward", "Lyrics later", "half a second", () => nudge(500))}
|
||||
<div class="sheet-hair"></div>
|
||||
{/if}
|
||||
{@render row("search", "Wrong lyrics? Search again", "LRCLIB, by this track's name", () => { void searchCandidates(); })}
|
||||
{@render row("refresh", "Forget and look again", "", act(() => { void forgetLyrics(); }))}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if kind === "librarysort"}
|
||||
<div class="sheet-title">Sort & view</div>
|
||||
<div class="sheet-sec">Order</div>
|
||||
@@ -388,7 +609,7 @@
|
||||
<div class="sheet-rows">
|
||||
{#each (room?.users ?? []).slice(0, 120) as u (u.username)}
|
||||
{@const buddy = slsk.buddies.includes(u.username)}
|
||||
<button class="row" onclick={act(() => { openThread(u.username); push("thread", { username: u.username }); })} oncontextmenu={(e) => { e.preventDefault(); (buddy ? removeBuddy : addBuddy)(u.username).catch(() => {}); }}>
|
||||
<button class="row" onclick={act(() => { openThread(u.username); push("thread", { username: u.username }); })} oncontextmenu={(e) => { e.preventDefault(); openHalf("peer", { username: u.username }); }}>
|
||||
<span class="lead"><span class="dot" class:off={u.status === 0} class:warn={u.status === 1}></span></span>
|
||||
<span class="b"><span class="t">{u.username}{#if buddy} <span class="tag hi">BUDDY</span>{/if}</span><span class="m">{u.file_count ? u.file_count.toLocaleString() + " files" : ""}</span></span>
|
||||
</button>
|
||||
|
||||
Reference in new issue
Block a user