playlists + player: every service's list through one door with its names matched for real, and shuffle that readies exactly what it promises

This commit is contained in:
KeelrLeek committed 2026-08-24 18:10:34 -05:00
1 parent 30eb6fe025
commit 50414dae5c
16 files changed
+4157 -154

No files matched your search

+50 -9
View File
@@ -7,6 +7,7 @@
getArt,
getCatalogArt,
jumpToQueueIndex,
peekNextIndex,
streamSong,
discovery,
loadDiscovery,
@@ -101,11 +102,6 @@
// nothing, then a partial list.
const albumMode = $derived(!!player.catalogRef.albumId);
// Artist name for the Save action: album items carry "artist — album".
const artistName = $derived.by(() => {
const sub = currentItem?.sub ?? "";
return sub.includes(" — ") ? sub.split(" — ")[0].trim() : sub;
});
// Artist / album split for the separate, clickable identity lines.
// Per field, like the player bar's: the item's own artist/album win when
// present (the continuation fill writes them beside a bare-artist `sub`),
@@ -121,6 +117,16 @@
currentItem?.album ?? split[1] ?? "",
];
});
// The artist everything else in this panel is ABOUT — discovery, the
// chip, "More from", the in-library count, the Save action.
//
// It used to split `sub` on its own, which is only "artist — album" for
// album playback. A playlist item's `sub` is the PLAYLIST's name, so
// playing out of one loaded the whole Artist and About panes for an
// artist called "Modern Electro Ghetto Tech Mix" while the identity
// lines two rows above correctly read Harka. The item's own `artist`
// field is the answer, and `metaParts` already applies that rule.
const artistName = $derived(metaParts[0]);
const LOSSLESS = new Set(["FLAC", "WAV", "ALAC", "AIFF", "APE"]);
@@ -170,7 +176,9 @@
// tier, drop the album that's playing now, cap at 6.
const typeRank = (t) => (t === "album" ? 0 : t === "ep" ? 1 : 2);
const moreAlbums = $derived.by(() => {
const here = norm(currentItem?.sub?.split(" — ")[1] ?? "");
// The record playing now, by the same per-field rule — `sub` is the
// playlist's name, not "artist — album", whenever one is playing.
const here = norm(metaParts[1]);
return [...discovery.albums]
.filter((a) => norm(a.title) !== here)
.sort(
@@ -906,7 +914,26 @@
}
}
const upNext = $derived(player.queue[player.queueIndex + 1] ?? null);
// What actually plays next — shuffle included: peekNextIndex reads the
// predetermined order, so this card, the preloader and the advance all
// name the same track.
const upNextIndex = $derived(peekNextIndex());
const upNext = $derived(upNextIndex >= 0 ? (player.queue[upNextIndex] ?? null) : null);
// The NEXT track's own artwork — the card wore the CURRENT track's cover
// for a while, which read as "The Medic" having the Jets to Brazil sleeve.
let upNextArt = $state(null);
$effect(() => {
const item = upNext;
upNextArt = null;
if (!item) return;
let active = true;
if (item.art) {
getArt(item.art, 96).then((u) => active && (upNextArt = u)).catch(() => {});
} else if (item.artUrl) {
getCatalogArt(playerArtSource(item)).then((u) => active && (upNextArt = u)).catch(() => {});
}
return () => (active = false);
});
const fmtListeners = (n) => (n == null ? null : n.toLocaleString());
@@ -1249,8 +1276,12 @@
<div class="pane" style="--dir: {paneDir}px">
{#if pane === "queue"}
{#if upNext}
<button class="upnext" onclick={() => jumpToQueueIndex(player.queueIndex + 1)}>
{#if artUrl}<img src={artUrl} alt="" draggable="false" />{/if}
<button class="upnext" onclick={() => jumpToQueueIndex(upNextIndex)}>
{#if upNextArt}
<img src={upNextArt} alt="" draggable="false" />
{:else}
<span class="un-fallback"><Icon name="music" size={15} /></span>
{/if}
<span class="un-body">
<span class="un-lab">Up next</span>
<span class="un-title" title={upNext.title}>{upNext.title}</span>
@@ -2242,6 +2273,16 @@
border-color: color-mix(in srgb, var(--accent-hover) 45%, var(--border));
}
.upnext img { width: 34px; height: 34px; border-radius: 4px; flex: none; object-fit: cover; }
.un-fallback {
width: 34px;
height: 34px;
flex: none;
display: grid;
place-items: center;
border-radius: 6px;
background: var(--bg-highlight);
color: var(--text-tertiary);
}
.un-body { min-width: 0; flex: 1; display: block; }
.un-lab {
display: block;
+217 -46
View File
@@ -15,18 +15,31 @@
startYoutubeListen,
startFileListen,
} from "../lib/setlistScan.svelte.js";
import { findCatalogTrack } from "../lib/catalogMatch.js";
import Icon from "./Icon.svelte";
let {
open = $bindable(false),
// "file": a probed draft arrives via `draft`; "paste": textarea
// first; "youtube": a link field first — the set's own tracklist
// evidence (chapters/description/comments) becomes the draft.
// evidence (chapters/description/comments) becomes the draft;
// "ytplaylist": a link field for an ordinary playlist link —
// YouTube (one row per video), Spotify, Apple Music, TIDAL, or
// Deezer (one row per track); metadata only, no tools involved.
mode = "file",
draft = null,
onimported = () => {},
} = $props();
// The door actually being shown. `mode` is what the caller opened; a
// capped Spotify import can hand itself over to the paste door from
// inside the review, and that switch is this component's own business.
let doorOverride = $state(null);
const door = $derived(doorOverride ?? mode);
// Set when the paste door was reached FROM a capped Spotify import, so it
// can say what to paste instead of leaving the reader to remember.
let pasteFromSpotify = $state(false);
let pasteText = $state("");
let youtubeUrl = $state("");
let localDraft = $state(null);
@@ -52,6 +65,14 @@
return r.startsWith("deno") ? "deno" : r;
});
let scanning = $state(null); // {stage, pct?, window?, total?, hit?}
// Pasted TRACK links are looked up a page each, so the paste door reports
// its own progress instead of freezing on "Reading…".
let linkProgress = $state(null); // {done, total, waiting}
// Mirrors the backend's detector closely enough to CHOOSE a door; the
// backend decides for real. A paste holding even one track link takes the
// link path, so a list copied out of Spotify never parses as track names.
const TRACK_LINK = /(?:spotify:track:[A-Za-z0-9]{22})|(?:\/\/(?:open\.spotify\.com\/(?:[\w-]+\/)*track\/|(?:www\.)?deezer\.com\/(?:[\w-]+\/)*track\/|(?:[\w-]+\.)?tidal\.com\/(?:[\w-]+\/)*track\/|music\.apple\.com\/[^\s]*(?:\/song\/|[?&]i=)))/i;
const pasteHasLinks = $derived(TRACK_LINK.test(pasteText));
// The working draft: probed (file mode) or built from the paste box.
const working = $derived(localDraft ?? draft);
@@ -68,12 +89,23 @@
fileTracklist = "";
includeUnresolved = true;
scanning = null;
linkProgress = null;
doorOverride = null;
pasteFromSpotify = false;
name = draft?.name ?? "";
// NOTE: `mode`, not `door`. This effect WRITES doorOverride, so
// reading the derived door here would make it re-trigger itself and
// wipe the hand-off the instant it was set.
if (mode === "youtube") {
void api.ytdlpStatus().then((s) => (tool = s)).catch(() => {});
}
if (draft?.entries?.length) startChecks(draft);
});
$effect(() => {
return api.onEvent("playlist:link-progress", (p) => {
if (parsing) linkProgress = p;
});
});
$effect(() => {
return api.onEvent("playlist:scan-progress", (p) => {
if (scanning || p?.stage === "tool" || p?.stage === "runtime")
@@ -170,8 +202,13 @@
if (!pasteText.trim() || parsing) return;
parsing = true;
error = "";
linkProgress = null;
try {
const parsed = await api.playlistImportText(pasteText);
// Track links get looked up one public page each — the path with
// no length ceiling. Plain text stays on the old parser.
const parsed = pasteHasLinks
? await api.playlistImportTrackLinks(pasteText, name.trim() || null)
: await api.playlistImportText(pasteText);
if (!parsed.entries.length) {
error = "No tracks recognized in that text.";
} else {
@@ -183,6 +220,7 @@
error = String(e);
} finally {
parsing = false;
linkProgress = null;
}
}
async function previewYoutube() {
@@ -205,6 +243,42 @@
}
}
async function previewYoutubePlaylist() {
if (!youtubeUrl.trim() || parsing) return;
parsing = true;
error = "";
try {
const parsed = await api.playlistImportLink(youtubeUrl.trim());
if (!parsed.entries.length) {
error = "The playlist yielded no importable tracks.";
} else {
localDraft = parsed;
name = parsed.name ?? name;
startChecks(parsed);
}
} catch (e) {
error = String(e);
} finally {
parsing = false;
}
}
// A Spotify import that came back at exactly the public ceiling. The
// real total is not exposed to anyone signed out, so this cannot say how
// many are missing — only that there are likely more.
const spotifyCapped = $derived(
working?.format === "spotify" && working.entries.length >= 100,
);
/// Hand the reader from the capped review to the door that has no
/// ceiling, with the paste box empty and waiting.
function bringTheRest() {
localDraft = null;
pasteText = "";
error = "";
doorOverride = "paste";
pasteFromSpotify = true;
}
const kept = $derived.by(() => {
if (!working) return [];
// Streams are first-class (identified, playable) — the checkbox
@@ -217,43 +291,9 @@
// ---- catalog verification of stream rows, live in the review -------
let checks = $state({}); // ordinal → "checking" | {album, cover} | "none"
let checkGen = 0;
const norm = (s) =>
(s ?? "")
.toLowerCase()
.normalize("NFKD")
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
/// Strict match: at least one exact side (title or artist), both at
/// least partial; duration within ±7 s strengthens; Deezer rank breaks
/// ties so a famous track beats a namesake.
function pickCatalogMatch(tracks, e) {
const wantT = norm(e.title);
const wantA = norm(e.artist);
let best = null;
let bestScore = 0;
for (const t of tracks ?? []) {
const tT = norm(t.title);
const tA = norm(t.artist);
let score = 0;
if (tT === wantT) score += 4;
else if (tT.includes(wantT) || wantT.includes(tT)) score += 2;
if (tA === wantA) score += 4;
else if (tA.includes(wantA) || wantA.includes(tA)) score += 2;
if (score < 6) continue;
if (
e.duration_secs &&
t.duration_secs &&
Math.abs(t.duration_secs - e.duration_secs) <= 7
)
score += 2;
score += Math.min((t.rank ?? 0) / 1_000_000, 1);
if (score > bestScore) {
bestScore = score;
best = t;
}
}
return best;
}
// The matcher moved to lib/catalogMatch.js when the player started
// needing the same bar to put artwork on a stream that arrived without
// any: strict is only meaningful if there is ONE definition of it.
/// Kick the walk OUTSIDE any effect's tracking zone: verifyStreams
/// reads and writes `checks`, and doing that inside an $effect made
/// every row verdict re-trigger the effect, which bumped the
@@ -282,9 +322,20 @@
anchored = !!hit;
}
if (!hit) {
const res = await api.catalogSearch(`${e.artist} ${e.title}`);
// The hardened lookup: Deezer's field syntax with the
// version wording set aside, then wider rungs. A plain
// "artist title" query returns NOTHING for a great many
// real records (measured: 41 of 258 rows on one import).
hit = await findCatalogTrack(
(query) => api.catalogSearchTracks(query),
{
title: e.title,
artist: e.artist,
duration_secs: e.duration_secs ?? null,
},
(artist, title) => api.catalogArtistTrackCandidates(artist, title),
);
if (gen !== checkGen) return;
hit = pickCatalogMatch(res?.tracks, e);
}
if (hit) {
// Attach catalog identity to the row commit will send.
@@ -364,6 +415,12 @@
text: "Pasted text",
folder: "Folder",
youtube: "YouTube",
"youtube-playlist": "YouTube playlist",
spotify: "Spotify",
"apple-music": "Apple Music",
tidal: "TIDAL",
deezer: "Deezer",
links: "Pasted links",
listened: "Recognized by ear",
};
</script>
@@ -388,8 +445,10 @@
Review import
{:else if pickedFile}
Listen to a set recording
{:else if mode === "youtube"}
{:else if door === "youtube"}
Import a DJ set from YouTube
{:else if door === "ytplaylist"}
Import a playlist from a link
{:else}
Paste a tracklist
{/if}
@@ -440,7 +499,7 @@
Listen
</button>
</footer>
{:else if mode === "youtube"}
{:else if door === "youtube"}
{#if scanning}
<div class="scanbox">
<span class="ring"></span>
@@ -537,16 +596,72 @@
</button>
</footer>
{/if}
{:else if door === "ytplaylist"}
<p class="lead">
Paste a playlist link from <b>YouTube</b>, <b>Spotify</b>,
<b>Apple Music</b>, <b>TIDAL</b>, or <b>Deezer</b> — every
track arrives named by the service's own listing. Read
straight off the playlist's public page; nothing is
downloaded and no account is involved.
</p>
<input
class="url"
type="text"
bind:value={youtubeUrl}
placeholder="https://open.spotify.com/playlist/… · youtube.com/playlist?list=…"
spellcheck="false"
onkeydown={(e) => e.key === "Enter" && previewYoutubePlaylist()}
/>
{#if error}<p class="error">{error}</p>{/if}
<footer>
<button class="btn ghost" onclick={close}>Cancel</button>
<button
class="btn primary"
onclick={previewYoutubePlaylist}
disabled={!youtubeUrl.trim() || parsing}
>
{parsing ? "Fetching…" : "Fetch tracks"}
</button>
</footer>
{:else}
{#if pasteFromSpotify}
<ol class="steps">
<li>Open the playlist in Spotify.</li>
<li>Click any track, then <b>Ctrl+A</b> to select them all.</li>
<li><b>Ctrl+C</b>, then paste below — every track comes over,
however many there are.</li>
</ol>
{/if}
<p class="lead">
One track per line. Timestamps make it a setlist —
<span class="mono">00:00 Artist - Title</span> keeps its place in the mix.
<b>Track links work too</b>: in Spotify (or Apple Music, TIDAL,
Deezer) select every track and copy, then paste here — that path
has no length limit, unlike a playlist link.
</p>
<textarea
bind:value={pasteText}
placeholder={"00:00 Bicep - Glue\n05:30 Overmono - So U Kno\nFour Tet - Baby"}
placeholder={"00:00 Bicep - Glue\n05:30 Overmono - So U Kno\nFour Tet - Baby\n\n…or pasted track links:\nhttps://open.spotify.com/track/…"}
spellcheck="false"
></textarea>
{#if pasteHasLinks && !parsing}
<div class="hint">
Track links detected — each is read from its own public page.
</div>
{/if}
{#if linkProgress}
<div class="scanbox">
<span class="ring"></span>
<div class="scanline">
{#if linkProgress.waiting}
The service is throttling — waiting a minute, then
carrying on from {linkProgress.done} of {linkProgress.total}…
{:else}
Reading track {linkProgress.done} of {linkProgress.total}…
{/if}
</div>
</div>
{/if}
{#if error}<p class="error">{error}</p>{/if}
<footer>
<button class="btn ghost" onclick={close}>Cancel</button>
@@ -555,7 +670,7 @@
onclick={previewPaste}
disabled={!pasteText.trim() || parsing}
>
{parsing ? "Reading…" : "Preview"}
{parsing ? (pasteHasLinks ? "Looking up…" : "Reading…") : "Preview"}
</button>
</footer>
{/if}
@@ -583,6 +698,19 @@
</div>
{/if}
{#if spotifyCapped}
<div class="capline">
<div class="capwords">
<b>This is Spotify's limit, not the playlist's.</b>
Pasted track links have none — and Spotify hands them all over
at once.
</div>
<button class="btn small" onclick={bringTheRest}>
Bring the whole playlist
</button>
</div>
{/if}
<div class="rows" tabindex="-1">
{#each working.entries as e (e.ordinal)}
<div
@@ -675,7 +803,7 @@
</div>
{#if error}<p class="error">{error}</p>{/if}
<footer>
{#if mode === "paste" || mode === "youtube"}
{#if door === "paste" || door === "youtube" || door === "ytplaylist"}
<button class="btn ghost" onclick={() => (localDraft = null)}>Back</button>
{:else}
<button class="btn ghost" onclick={close}>Cancel</button>
@@ -921,6 +1049,49 @@
color: #e8a33d;
}
/* The capped-import hand-off: a statement and the way out of it. */
.capline {
display: flex;
align-items: center;
gap: 14px;
margin: 0 0 12px;
padding: 11px 14px;
border-radius: 10px;
background: var(--bg-highlight);
border: 1px solid var(--border);
}
.capwords {
flex: 1;
font-size: 12.5px;
line-height: 1.45;
color: var(--text-secondary);
}
.capwords b {
color: var(--text-primary);
}
.btn.small {
flex: none;
padding: 7px 13px;
font-size: 12.5px;
border-radius: 8px;
background: var(--accent);
color: #04121d;
font-weight: 700;
white-space: nowrap;
}
.btn.small:hover {
background: var(--accent-hover);
}
.steps {
margin: 0 0 4px;
padding-left: 20px;
font-size: 12.5px;
line-height: 1.75;
color: var(--text-secondary);
}
.steps b {
color: var(--text-primary);
}
.warnings {
margin-bottom: 8px;
}
+26
View File
@@ -68,6 +68,10 @@ export const downloadFile = (username, path, size) =>
export const copyStreamToFolder = (files, folder) =>
invoke("copy_stream_to_folder", { files, folder });
export const saveToLibrary = (files) => invoke("save_to_library", { files });
// Byte length of the cached copy of one peer file (null when uncached) —
// compared against the peer-stated size to tell "finished" from "prefix".
export const streamCacheLen = (username, path) =>
invoke("stream_cache_len", { username, path });
export const cancelDownload = (id) => invoke("cancel_download", { id });
export const showInFolder = (path) => invoke("show_in_folder", { path });
export const refreshArtistArt = () => invoke("refresh_artist_art");
@@ -136,6 +140,11 @@ export const openExternal = (url) => invoke("open_external", { url });
// pixels on the long side. Omit it only where true pixels matter (the
// fullscreen viewer, the import picker's comparison and loupe).
export const albumArt = (path, maxEdge = null) => invoke("album_art", { path, maxEdge });
/** The cover embedded in a streamed track's own cache file, extracted to a
* local image path — null while the transfer is too young to read, or when
* the peer's file simply carries no picture. */
export const streamEmbeddedArt = (username, path) =>
invoke("stream_embedded_art", { username, path });
export const refreshAlbumArt = (albumId) => invoke("refresh_album_art", { albumId });
export const refreshAllAlbumArt = () => invoke("refresh_all_album_art");
export const getSettings = () => invoke("get_settings");
@@ -186,6 +195,14 @@ export const playlistImportProbe = () => invoke("playlist_import_probe");
export const playlistImportFolder = () => invoke("playlist_import_folder");
export const playlistImportText = (text) => invoke("playlist_import_text", { text });
export const playlistImportYoutube = (url) => invoke("playlist_import_youtube", { url });
export const playlistImportYoutubePlaylist = (url) =>
invoke("playlist_import_youtube_playlist", { url });
// One door for every service link: YouTube, Spotify, Apple Music, TIDAL, Deezer.
export const playlistImportLink = (url) => invoke("playlist_import_link", { url });
// A pasted block of TRACK links (any supported service), looked up one page
// each — the way past Spotify's 100-track public ceiling.
export const playlistImportTrackLinks = (text, name = null) =>
invoke("playlist_import_track_links", { text, name });
// The set LISTENER (src-tauri/src/setlist_scan.rs + ytdlp.rs): recognize a
// mix's tracks from its audio. Progress rides "playlist:scan-progress".
export const playlistScanYoutube = (url) => invoke("playlist_scan_youtube", { url });
@@ -257,6 +274,15 @@ export const lyricsVocalActivity = (path) => invoke("lyrics_vocal_activity", { p
export const lyricsSaveLines = (title, artist, durationSecs, lines) =>
invoke("lyrics_save_lines", { title, artist, durationSecs: durationSecs ?? null, lines });
export const catalogSearch = (query) => invoke("catalog_search", { query });
// Track rows only — one Deezer call, and the query may use Deezer's field
// syntax (artist:"…" track:"…"). The identity lookups use this; the search
// PAGE still uses catalogSearch, which also wants artists and albums.
export const catalogSearchTracks = (query, limit = 10) =>
invoke("catalog_search_tracks", { query, limit });
// The artist page's whole shelf — Deezer plus the MusicBrainz/Apple
// supplements — flattened to tracks, for lookups the search index failed.
export const catalogArtistTrackCandidates = (artist, title) =>
invoke("catalog_artist_track_candidates", { artist, title });
export const catalogTrackByIsrc = (isrc) => invoke("catalog_track_by_isrc", { isrc });
export const rememberFusedArtistPortrait = (artistId, portrait) =>
invoke("remember_fused_artist_portrait", { artistId, portrait });
+315
View File
@@ -0,0 +1,315 @@
// One strict name matcher against catalog search, shared by everything that
// has a name and wants a record.
//
// It lives here rather than inside the import review because a name is the
// weakest identity this app deals with, and the app's rule is that a name
// never identifies a record on its own (memory: the artist/album anchor
// rule). Where a name IS all there is, every caller must at least agree on
// how strict "matched" means — the loose version of this lookup once put one
// record's cover on seven unrelated songs, because a first-result fallback
// answers every query with something.
//
// The bar: the artist agrees, the title agrees ONCE ITS VERSION WORDING IS
// SET ASIDE, the version qualifiers are compatible, and a known duration on
// both sides confirms rather than contradicts.
//
// Why the wording has to be set aside (measured against a real 258-row
// import, where 41 rows read "not in catalog" while plainly being in it):
// services word the same master differently. Spotify writes "Bohemian
// Rhapsody - Remastered 2011"; Deezer writes "Bohemian Rhapsody (2011
// Remaster)". Neither string contains the other, so a containment test
// scores them zero and the most famous rock song ever recorded comes back
// unfound. But the qualifier cannot simply be DROPPED either — that would
// match a remix to the original it samples, which the DJ-set work proved is
// the wrong answer. So the base names must agree AND the qualifiers must be
// of compatible kinds.
/** Fold to comparable letters and digits: case, accents and punctuation all
* differ freely between a tag, a tracklist line and a catalog row. */
export const nameNorm = (s) =>
(s ?? "")
.toLowerCase()
.normalize("NFKD")
.replace(/&/g, " and ")
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
/** A different MASTER of one performance: same recording, repackaged. */
const EDITION_WORDS =
/\b(remaster|remastered|remasterd|stereo|mono|deluxe|anniversary|reissue|expanded|digital|album version|single version|original version|radio edit|explicit|clean|bonus|mix master|from the vault)\b/;
/** A different PERFORMANCE: another take, another artist's hands on it. */
const IDENTITY_WORDS =
/\b(remix|live|acoustic|instrumental|demo|cover|karaoke|unplugged|reprise|vip|bootleg|session|rework|flip|mashup|dub|edit|mix|version|take|alternate|orchestral|piano|8 bit|tribute|originally|made famous)\b/;
/** Credits, not recordings. */
const CREDIT_WORDS = /^(feat|featuring|ft|with|w)\b/;
/** Split "Title (2011 Remaster)" / "Title - Remastered 2011" /
* "Title -album version-" into its base name and its trailing qualifier.
* Only ONE trailing group is taken off per pass, repeatedly, so
* "Lola (Stereo Remastered 2020) [Bonus]" reduces fully. */
export function splitVersion(title) {
let base = (title ?? "").trim();
const parts = [];
for (let pass = 0; pass < 4; pass += 1) {
const before = base;
// A bracketed group at the end.
let m = base.match(/^(.*\S)\s*[([{]([^([{]*)[)\]}]\s*$/);
if (m) {
base = m[1].trim();
parts.push(m[2]);
continue;
}
// "Title -album version-" — dashes on both sides of the tail.
m = base.match(/^(.*\S)\s+-\s*([^-]+?)\s*-\s*$/);
if (m) {
base = m[1].trim();
parts.push(m[2]);
continue;
}
// "Title - Remastered 2011" — a dash-introduced tail. Only when it
// reads like a qualifier, so "Kiss - Detroit Rock City" keeps its
// shape and a hyphenated name like "A-Punk" is never split (no
// surrounding spaces).
m = base.match(/^(.*\S)\s+-\s+(.+)$/);
if (m) {
const tail = nameNorm(m[2]);
if (EDITION_WORDS.test(tail) || IDENTITY_WORDS.test(tail) || /^\d{4}$/.test(tail)) {
base = m[1].trim();
parts.push(m[2]);
continue;
}
}
if (base === before) break;
}
return { base, qualifier: parts.reverse().join(" ") };
}
/** What a qualifier means for identity: nothing, a different master, or a
* different performance. An unrecognized qualifier counts as a different
* performance — the strict reading, so an unknown word never silently
* merges two records. */
export function qualifierClass(qualifier) {
const n = nameNorm(qualifier);
if (!n) return "none";
if (CREDIT_WORDS.test(n)) return "edition";
if (/^\d{4}$/.test(n)) return "edition";
if (EDITION_WORDS.test(n)) return "edition";
if (IDENTITY_WORDS.test(n)) return "identity";
return "identity";
}
/** The comma-separated lead artist: services credit "Ennio Morricone, Joan
* Baez" where the catalog rows only the first. Never splits on "&" or "and"
* — "Joan Jett and the Blackhearts" is one act. */
export function primaryArtist(artist) {
const first = (artist ?? "").split(",")[0].trim();
return first || (artist ?? "").trim();
}
/** The act without its backing band: "Joan Jett and the Blackhearts" →
* "Joan Jett". Catalogs credit the same recording both ways — Deezer files
* the studio "Bad Reputation" under plain "Joan Jett" while giving the live
* takes the full name — so both spellings have to be comparable.
*
* Only a trailing "and the …" / "with …" comes off, never a bare word, so
* "Queen" and "Queen Latifah" stay the strangers they are. */
export function bandCore(artist) {
const n = nameNorm(artist);
const m = n.match(/^(.*?\S)\s+(?:and|with)\s+(?:the\s+)?\S.*$/);
return m ? m[1] : n;
}
/** Do these two artist strings name the same act? Exact after folding
* ("&" → "and"), or one side's lead artist equals the other whole, or they
* agree once a backing band is set aside. */
function artistAgrees(wantArtist, candArtist) {
const w = nameNorm(wantArtist);
const c = nameNorm(candArtist);
if (!w || !c) return false;
if (w === c) return true;
const wp = nameNorm(primaryArtist(wantArtist));
const cp = nameNorm(primaryArtist(candArtist));
if (wp === c || cp === w || (!!wp && wp === cp)) return true;
const wc = bandCore(wp);
const cc = bandCore(cp);
return (!!wc && (wc === c || wc === cc)) || (!!cc && cc === w);
}
/** Durations that both sides know and that disagree by more than this are
* different cuts (a single edit against an album version), not sloppy
* metadata. Rejecting on it is what keeps a 2:14 radio edit from answering
* for a 6:21 album track. */
const DURATION_TOLERANCE_SECS = 20;
/** Within this, the duration actively CONFIRMS the match. */
const DURATION_CONFIRM_SECS = 7;
/** The best catalog track for `want` ({ title, artist, duration_secs }), or
* null when nothing clears the bar. Null is a real answer: no artwork is
* better than another record's artwork. */
export function pickCatalogMatch(tracks, want) {
const wantSplit = splitVersion(want?.title);
const wantBase = nameNorm(wantSplit.base);
const wantClass = qualifierClass(wantSplit.qualifier);
const wantQual = nameNorm(wantSplit.qualifier);
if (!wantBase || !nameNorm(want?.artist)) return null;
let best = null;
let bestScore = -1;
for (const t of tracks ?? []) {
if (!artistAgrees(want.artist, t.artist)) continue;
const candSplit = splitVersion(t.title);
const candBase = nameNorm(candSplit.base);
const candClass = qualifierClass(candSplit.qualifier);
const candQual = nameNorm(candSplit.qualifier);
if (!candBase) continue;
// The names, with version wording set aside, must actually agree.
let titleScore;
if (candBase === wantBase) titleScore = 4;
else if (
// A containment still counts, but only when the shorter name is
// substantial — "Go" inside "Go Your Own Way" is a coincidence.
(candBase.includes(wantBase) || wantBase.includes(candBase)) &&
Math.min(candBase.length, wantBase.length) >= 6 &&
Math.min(candBase.length, wantBase.length) / Math.max(candBase.length, wantBase.length) >= 0.6
)
titleScore = 2;
else continue;
// Compatible versions: an edition may answer for a plain name (and
// vice versa) because it is the same performance; a REMIX, live take
// or cover may only answer a request that asked for one.
if (wantClass === "identity" || candClass === "identity") {
const shared =
wantQual &&
candQual &&
wantQual.split(" ").some((w) => w.length > 2 && candQual.includes(w));
if (!shared) continue;
}
// A duration both sides know, disagreeing badly, means a different cut.
const bothKnow = want.duration_secs && t.duration_secs;
const delta = bothKnow ? Math.abs(t.duration_secs - want.duration_secs) : null;
if (bothKnow && delta > DURATION_TOLERANCE_SECS) continue;
let score = titleScore;
if (bothKnow && delta <= DURATION_CONFIRM_SECS) score += 2;
// Same KIND of qualifier is a better answer than a merely allowed one.
if (wantClass === candClass) score += 1;
// Deezer's own rank breaks ties so a famous track beats a namesake.
score += Math.min((t.rank ?? 0) / 1_000_000, 1);
if (score > bestScore) {
bestScore = score;
best = t;
}
}
return best;
}
/** Letters outside the Latin script — the tell that a title cannot be
* name-compared against a catalog that romanizes ("孤独の発明" is filed on
* Deezer as "Kodokunohatsumei"). */
const nonLatinLetters = /(?![\p{Script=Latin}])\p{L}/u;
/** The one match a NAME cannot make: the wanted title is written in a
* non-Latin script and the catalog romanized it. No amount of folding
* compares those, so within an ARTIST-SCOPED candidate list the duration
* carries the identity instead — and only under the strictest reading:
* the artist agrees, both durations are known, they agree within 3
* seconds, and exactly ONE candidate qualifies. Two near-length songs on
* one record → null, the honest answer. */
export function pickCrossScript(tracks, want) {
if (!want?.duration_secs) return null;
if (!nonLatinLetters.test(want?.title ?? "")) return null;
const qualifying = (tracks ?? []).filter(
(t) =>
t.duration_secs &&
Math.abs(t.duration_secs - want.duration_secs) <= 3 &&
!nonLatinLetters.test(t.title ?? "") &&
nameNorm(t.artist) &&
nameNorm(want.artist) &&
(nameNorm(t.artist) === nameNorm(want.artist) ||
bandCore(t.artist) === bandCore(want.artist)),
);
return qualifying.length === 1 ? qualifying[0] : null;
}
/** Quote-safe value for Deezer's field search syntax. */
const q = (s) => (s ?? "").replace(/["\\]/g, " ").trim();
/** The queries to try for one wanted track, strongest first.
*
* Deezer's plain search is a blunt instrument: "Vashti Bunyan Diamond Day",
* "King Krule Flimsier" and "toe LONELINESS WILL SHINE" all return ZERO
* rows, while its field syntax — artist:"…" track:"…" — finds every one of
* them. Version wording poisons it too: searching the full "Take It Easy -
* 2013 Remaster" finds nothing, the base name finds the remaster. So the
* ladder asks the precise way first, with the base name, and only falls
* back to the blunt way. */
export function catalogQueries(want) {
const artist = q(primaryArtist(want?.artist));
const full = q(want?.title);
const base = q(splitVersion(want?.title).base);
const core = q(bandCore(artist));
const out = [];
if (artist && base) out.push(`artist:"${artist}" track:"${base}"`);
// Deezer may file the studio cut under the shorter credit while giving
// the live takes the full band name — ask both ways before giving up.
if (core && base && core !== nameNorm(artist)) out.push(`artist:"${core}" track:"${base}"`);
if (artist && full && full !== base) out.push(`artist:"${artist}" track:"${full}"`);
if (artist && base) out.push(`${artist} ${base}`);
return out;
}
// The artist-shelf walk is per ARTIST, and a missing artist usually misses
// with several rows at once (four King Krule rows, four Vashti Bunyan rows
// on one real import) — so its results are remembered for the session.
const shelfCache = new Map();
async function artistShelf(artistTracks, artist, title) {
const key = nameNorm(artist);
if (!key) return [];
if (!shelfCache.has(key)) {
if (shelfCache.size > 40) shelfCache.clear();
shelfCache.set(
key,
artistTracks(artist, title).catch(() => {
shelfCache.delete(key);
return [];
}),
);
}
return shelfCache.get(key);
}
/** Find one track in the catalog: walk the query ladder, stop at the first
* rung that produces a match. `search` takes a query string and resolves to
* a list of catalog tracks; `artistTracks` (optional) takes an artist name
* plus the wanted title and resolves to the artist PAGE's whole shelf as
* tracks — the last resort for records the search index omits (which
* includes every MusicBrainz/Apple-supplemented release: the page shows
* them, the index has never heard of them). Returns null when nothing
* clears the bar — which stays a real, common, correct answer. */
export async function findCatalogTrack(search, want, artistTracks = null) {
for (const query of catalogQueries(want)) {
let tracks;
try {
tracks = await search(query);
} catch {
return null; // offline or throttled — let the caller retry later
}
const hit = pickCatalogMatch(tracks, want) ?? pickCrossScript(tracks, want);
if (hit) return hit;
}
if (artistTracks) {
const names = [primaryArtist(want?.artist)];
const core = bandCore(names[0]);
if (core && core !== nameNorm(names[0])) names.push(core);
for (const name of names) {
const shelf = await artistShelf(artistTracks, name, want?.title ?? "");
const hit = pickCatalogMatch(shelf, want) ?? pickCrossScript(shelf, want);
if (hit) return hit;
}
}
return null;
}
+459 -44
View File
@@ -1,5 +1,6 @@
// Shared reactive app state (Svelte 5 runes module).
import * as api from "./api.js";
import { nameNorm, pickCatalogMatch } from "./catalogMatch.js";
import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.js";
import {
nav,
@@ -522,12 +523,39 @@ export const ART_EDGE_DEFAULT = 512;
* tile's own `object-fit` still decides the crop. Sources already within
* `edge` come back untouched rather than re-encoded. Pass `null` to opt out
* and get the master. */
// ---- art fetch gate ---------------------------------------------------
// A page mounting a wall of tiles fires its art requests as fast as the
// observer sees them; each answer is a base64 payload crossing IPC onto
// the main thread. Six in flight keeps the pipe busy without the burst —
// the backend bounds the DECODES separately (2 at a time); this bounds
// the traffic. Masters (the player-bar hero) bypass the gate: rare, and
// they should never wait behind a hundred tiles.
const ART_FETCH_LIMIT = 6;
let artFetchActive = 0;
const artFetchQueue = [];
function gatedArtFetch(fn) {
return new Promise((resolve, reject) => {
const run = () => {
artFetchActive += 1;
fn()
.then(resolve, reject)
.finally(() => {
artFetchActive -= 1;
const next = artFetchQueue.shift();
if (next) next();
});
};
if (artFetchActive < ART_FETCH_LIMIT) run();
else artFetchQueue.push(run);
});
}
export async function getArt(path, edge = ART_EDGE_DEFAULT) {
if (!path) return null;
if (edge == null) return getArtMaster(path);
const key = `${edge}|${path}`;
if (artThumbCache.has(key)) return touchCache(artThumbCache, key);
const pending = api.albumArt(path, edge).then(
const pending = gatedArtFetch(() => api.albumArt(path, edge)).then(
(url) => {
if (artThumbCache.get(key) === pending) {
setResolvedCache(artThumbCache, key, url, ART_THUMB_CACHE_MAX, ART_THUMB_CACHE_MAX_CHARS);
@@ -3386,6 +3414,129 @@ export async function saveStream(scope) {
}
}
// ---- bulk save of a playlist's stream rows ----------------------------------
// The playlist page's two collection actions, one job at a time app-wide:
// "cached" — file every row whose bytes are ALREADY fully in the stream
// cache into the library. Instant, free, touches no peer.
// "download" — find a source for every row (download prefs: quality floor,
// quality priority) and fetch it through save_to_library —
// cached rows organize free, the rest download the known file.
// Sequential on purpose: one Soulseek search at a time is the pending-
// resolver's own anti-flood posture, and a bulk job has no reason to be
// ruder than playback.
export const playlistBulk = $state({
active: false,
mode: null, // "cached" | "download"
playlistId: null,
done: 0,
total: 0,
organized: 0, // filed into the library instantly (was fully cached)
queued: 0, // handed to the download pipeline
missed: 0, // no complete cache ("cached") / no source found ("download")
summary: null, // last finished run's one-line result, until dismissed
});
let playlistBulkCancel = false;
export function cancelPlaylistBulk() {
playlistBulkCancel = true;
}
export function dismissPlaylistBulk() {
playlistBulk.summary = null;
}
export async function runPlaylistBulk(playlistId, rows, mode) {
if (playlistBulk.active) return false;
// Stream rows with full identity, deduped — the party-playlist case:
// the same track twice must not download twice.
const seen = new Set();
const want = rows.filter((r) => {
if (r.kind !== "stream" || !r.artist || !r.title) return false;
const key = `${normText(r.artist)}|${normText(r.title)}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
playlistBulkCancel = false;
Object.assign(playlistBulk, {
active: true,
mode,
playlistId,
done: 0,
total: want.length,
organized: 0,
queued: 0,
missed: 0,
summary: null,
});
try {
if (mode === "download") await autoConnect();
for (const row of want) {
if (playlistBulkCancel) break;
try {
const known = await cachedStreamSourceFor(
row.track_id ?? null,
row.artist,
row.title,
);
let src = known
? { username: known.username, path: known.path, size: known.size }
: null;
if (mode === "cached") {
// Only a byte-complete cache counts — a prefix is not
// the track, and this mode promised "no downloading".
const len = src ? await api.streamCacheLen(src.username, src.path) : null;
if (!src || !src.size || len !== src.size) {
playlistBulk.missed += 1;
continue;
}
} else if (!src) {
const found = await resolveStreamSourceFor(
{
trackId: row.track_id ?? null,
artist: row.artist,
title: row.title,
album: row.album ?? null,
duration: row.duration_secs ?? null,
},
9000,
false,
);
if (!found) {
playlistBulk.missed += 1;
continue;
}
src = {
username: found.stream.username,
path: found.path,
size: found.stream.size,
};
}
const [organized, queued] = await api.saveToLibrary([src]);
playlistBulk.organized += organized;
playlistBulk.queued += queued;
if (organized === 0 && queued === 0) playlistBulk.missed += 1;
} catch {
playlistBulk.missed += 1;
} finally {
playlistBulk.done += 1;
}
}
const parts = [];
if (playlistBulk.organized)
parts.push(`${playlistBulk.organized} saved to library`);
if (playlistBulk.queued) parts.push(`${playlistBulk.queued} downloading`);
if (playlistBulk.missed)
parts.push(
`${playlistBulk.missed} ${mode === "cached" ? "not fully streamed yet" : "without a source"}`,
);
if (playlistBulkCancel) parts.push("stopped early");
playlistBulk.summary = parts.length ? parts.join(" · ") : "Nothing to do";
if (playlistBulk.organized > 0) void refreshLibrary();
} finally {
playlistBulk.active = false;
}
return true;
}
/** Parse "Artist — Album" (or just "Artist") from the current track's sub. */
function currentArtistAlbum() {
const item = player.queue[player.queueIndex] ?? null;
@@ -3882,6 +4033,95 @@ async function cachedStreamSourceFor(trackId, artist, title) {
}
void refreshSourcePrefs();
// ---- artwork for a stream that arrived without any -----------------------
/** The cover embedded in a track's stream, but only if this app has already
* streamed it and still holds the whole file.
*
* Nothing is fetched and no peer is contacted: this is the playlist page
* asking what it can know for free about rows the catalog has never heard
* of. Null means "not from here" — never "this row has no artwork". */
export async function cachedStreamArt(trackId, artist, title) {
const src = await cachedStreamSourceFor(trackId ?? null, artist, title);
if (!src) return null;
try {
return await api.streamEmbeddedArt(src.username, src.path);
} catch {
return null;
}
}
/** Catalog covers already looked up this session, keyed artist|title.
* A stored `null` is a real answer — the catalog was asked and had nothing
* that matched strictly enough to be sure — and it stops every replay of
* that track asking again. */
const streamArtLookups = new Map();
/** A streamed track showing no artwork at all: find it some, in order of
* certainty.
*
* The bytes the peer is sending ARE the recording, so a cover embedded in
* them belongs to it — no matching, no doubt, no network. Only a file that
* carries none asks the catalog, and then through the same strict matcher
* the import review verifies rows with. A loose name lookup here is exactly
* what once put one record's cover on seven unrelated songs, so no match
* means no art: a blank tile is the honest answer, a wrong cover is not.
*
* Mutates the queue item in place, which is what every art surface already
* reads — `art` for a local image, `artUrl` for a catalog cover — so the
* bar, the panel and the lightbox all fill from one assignment. */
async function ensureStreamArt(item) {
if (!item?.stream || !item.path || item.art || item.artUrl) return;
// The world moves while a lookup is in flight: the queue can be replaced,
// and a later pass (or the stream-done retry) can land art first.
const stillBlank = () =>
player.queue.includes(item) && !item.art && !item.artUrl;
const showing = () => player.queue[player.queueIndex] === item;
try {
const embedded = await api.streamEmbeddedArt(item.stream.username, item.path);
if (embedded && stillBlank()) {
item.art = embedded;
if (showing()) setAccentFromArt(embedded);
return;
}
} catch {
// No tag, no file yet, or an unreadable one — the catalog is next.
}
if (!stillBlank()) return;
const artist = item.artist ?? "";
const title = item.title ?? "";
if (!artist || !title) return; // nothing to be strict ABOUT
const key = `${nameNorm(artist)}|${nameNorm(title)}`;
if (streamArtLookups.has(key)) {
const known = streamArtLookups.get(key);
if (known && stillBlank()) {
item.artUrl = known;
if (showing()) setAccentFromArtUrl(known);
}
return;
}
try {
const res = await api.catalogSearch(`${artist} ${title}`);
const hit = pickCatalogMatch(res?.tracks, {
title,
artist,
duration_secs: item.duration ?? null,
});
const url = hqCover(hit?.cover ?? null) ?? null;
streamArtLookups.set(key, url);
if (url && stillBlank()) {
item.artUrl = url;
if (showing()) setAccentFromArtUrl(url);
}
} catch {
// Offline or a provider hiccup: leave the key unset so a later play
// can try again rather than remembering a failure as an absence.
}
}
/** Whether a peer file satisfies the user's quality window for this mode.
* Lossless formats satisfy every kbps floor; a lossy file must carry a KNOWN
* bitrate at or above it — a peer that hides its bitrate does not get the
@@ -5228,6 +5468,18 @@ export const player = $state({
queue: [],
queueIndex: -1,
shuffle: false,
// The PREDETERMINED shuffle order: a permutation of queue indices for
// the current cycle. Play order is this array left to right; the
// position is wherever `queueIndex` sits in it. Predetermined so the
// up-next card, the gapless preload/append, and the actual advance all
// agree on the SAME future — a random pick at advance time made the
// card show the linear next track and the preloader cache it, while
// playback then went somewhere else entirely.
shuffleOrder: [],
// Under repeat "all", the NEXT cycle's order — generated the moment
// anything needs to see across the cycle boundary, so the last track's
// up-next and its preload name the reshuffle's real first track.
shuffleNextOrder: null,
repeat: "off", // off | all | one
// waveform peaks (0..1) for the current local track, [] while loading
waveform: [],
@@ -5650,7 +5902,12 @@ export function initEvents() {
if (s.done) {
// Fully cached → final waveform, and the peer's slot is
// definitely free, so make sure the next track is pre-buffering.
if (cur?.stream) loadStreamWaveform(cur, true);
if (cur?.stream) {
loadStreamWaveform(cur, true);
// The first attempt may have read a file too young to parse;
// the complete one carries whatever tag the peer sent.
void ensureStreamArt(cur);
}
prepareFollowing();
} else if (
ui.windowActive
@@ -5920,6 +6177,7 @@ function playQueueIndex(i) {
// correct: a restore must never resurrect a track the queue moved past).
bumpMainRevision("queueJumpOrNextPrev");
player.queueIndex = i;
ensureShuffleOrder();
player.track = {
title: item.title,
sub: item.sub,
@@ -5953,6 +6211,10 @@ function playQueueIndex(i) {
if ("trackId" in item) player.catalogRef.trackId = item.trackId;
appendedNextPath = null;
setAccentFromArtUrl(item.artUrl ?? null);
// A row that arrived with no cover of its own (a setlist row the
// catalog could not verify, a by-name stream) gets one looked up
// while it plays — the file's own embedded art first.
void ensureStreamArt(item);
loadStreamWaveform(item); // instant when already fully cached
api.playStream(item.stream.username, item.path, item.stream.size, item.duration ?? null)
.then(() => {
@@ -5998,10 +6260,10 @@ export function jumpToQueueIndex(i) {
}
function gaplessNextIndex() {
if (player.shuffle) return -1;
if (player.repeat === "one") return player.queueIndex;
const next = player.queueIndex + 1;
const i = next < player.queue.length ? next : player.repeat === "all" ? 0 : -1;
// Shuffle included: the order is predetermined, so the engine can arm
// the SAME track the panel promises.
const i = peekNextIndex();
// Set-recording slices never join the gapless machinery: a preload or
// append would come up at the FILE's start, not the slice's, and the
// slice's early cut is driven by the position handler, not the
@@ -6024,14 +6286,27 @@ function preloadFollowingTrack() {
// Quietly pre-buffer the next queued stream into the cache. The backend
// no-ops when it's already cached or being fetched; single-slot peers just
// queue the request until the current transfer finishes.
let lastPreloadedPath = null;
// Paths already asked for this session — the window re-arms on every
// advance, and a path once requested is cached or in flight; asking again
// would only duplicate peer traffic.
let preloadRequestedPaths = new Set();
function preloadNextStream() {
const next = gaplessNextIndex();
const item = next >= 0 ? player.queue[next] : null;
if (!item?.stream || item.path === lastPreloadedPath) return;
lastPreloadedPath = item.path;
api.preloadStream(item.stream.username, item.path, item.stream.size).catch(() => {});
/** Pull the next few stream tracks' bytes into the cache — the on-deck
* window. Nearest first, so the imminent boundary always has the head
* start; rows with no source yet are skipped here and picked up by the
* pending-row lookahead, whose resolve calls back into this. */
function preloadUpcomingStreams() {
for (const i of peekNextIndices(STREAM_PRELOAD_DEPTH)) {
const item = player.queue[i];
// Segments play a slice of a recording that is already on disk.
if (!item?.stream || !item.path || item.segment) continue;
if (preloadRequestedPaths.has(item.path)) continue;
preloadRequestedPaths.add(item.path);
api.preloadStream(item.stream.username, item.path, item.stream.size).catch(() => {
// Failed to even start — let a later window retry it.
preloadRequestedPaths.delete(item.path);
});
}
}
// Once the next queued stream is fully cached, append it to the active
@@ -6077,7 +6352,7 @@ async function tryAppendNext() {
* exactly one boundary and stalled on every one after it.) */
function prepareFollowing() {
preloadFollowingTrack();
preloadNextStream();
preloadUpcomingStreams();
tryAppendNext();
scheduleQueueLookahead();
}
@@ -6264,10 +6539,10 @@ async function maybeExtendAlbumQueue() {
// Teach the memory as the fill goes, so the album's LATER tracks
// are known even if playback never reaches them this session.
rememberStreamSource(item);
// Both are guarded against repeat work (lastPreloadedPath,
// Both are guarded against repeat work (preloadRequestedPaths,
// appendedNextPath), so calling them per append costs nothing once
// the immediate next track is already prepared.
preloadNextStream();
// the upcoming window is already prepared.
preloadUpcomingStreams();
tryAppendNext();
}
autoAlbum = null; // every remaining track queued or skipped
@@ -6287,8 +6562,10 @@ async function maybeExtendAlbumQueue() {
const pendingResolves = new WeakMap();
/** Resolve one pending descriptor to a playable source, or null. */
async function resolveStreamSourceFor(t, budgetMs = 7000) {
/** Resolve one pending descriptor to a playable source, or null.
* `streamOnly=false` ranks by the DOWNLOAD source prefs instead (quality
* floor, quality priority) — the bulk download uses it; playback never. */
async function resolveStreamSourceFor(t, budgetMs = 7000, streamOnly = true) {
const known = await cachedStreamSourceFor(t.trackId ?? null, t.artist, t.title);
if (known) {
return {
@@ -6300,7 +6577,7 @@ async function resolveStreamSourceFor(t, budgetMs = 7000) {
}
if (app.connection !== "online") return null;
const goodCount = (rs) =>
songCandidates(rs, t.title, true, t.album ?? null, t.duration ?? null, t.artist).length;
songCandidates(rs, t.title, streamOnly, t.album ?? null, t.duration ?? null, t.artist).length;
const primary = sanitizeSearchQuery(`${t.artist} ${simplifyTitle(t.title)}`);
const fallback = sanitizeSearchQuery(simplifyTitle(t.title));
let artistIncluded = true;
@@ -6312,7 +6589,7 @@ async function resolveStreamSourceFor(t, budgetMs = 7000) {
artistIncluded = normText(primary).includes(normText(t.artist ?? ""));
}
const best = songCandidates(
responses, t.title, true, t.album ?? null, t.duration ?? null, t.artist, !artistIncluded,
responses, t.title, streamOnly, t.album ?? null, t.duration ?? null, t.artist, !artistIncluded,
)[0];
if (!best) return null;
return {
@@ -6377,17 +6654,36 @@ function scheduleQueueLookahead() {
}, ALBUM_FILL_DELAY_MS);
}
// Rows the LOOKAHEAD has already searched once. Without this, a row whose
// search came up dry would re-enter the window every tick and re-search on
// a 4-second loop — the precise flood the pacing exists to prevent. One
// lookahead try per row; the play-on-reach resolver still retries it when
// its turn actually comes. Queue rebuilds mint new row objects, so the set
// forgets naturally.
const lookaheadTried = new WeakSet();
async function resolveNextPendingItem() {
if (player.shuffle) return; // the next index is unknowable
const next = player.queueIndex + 1;
// The whole on-deck window wants sources, but Soulseek searches stay
// ONE at a time on the album fill's own pacing — resolve the nearest
// untried pending row, then schedule the next tick if more remain.
const pendingIn = (indices) =>
indices.find((i) => {
const it = player.queue[i];
return it && !it.path && !it.stream && !it.unplayable && !lookaheadTried.has(it);
});
const next = pendingIn(peekNextIndices(STREAM_PRELOAD_DEPTH));
if (next == null) return;
const item = player.queue[next];
if (!item || item.path || item.stream || item.unplayable) return;
lookaheadTried.add(item);
const src = await resolveQueueItem(item);
if (!src || !player.queue.includes(item)) return;
// Now a normal upcoming stream: pre-buffer it and offer the gapless
// append, exactly as if it had been queued with its source.
preloadNextStream();
tryAppendNext();
if (!player.queue.includes(item)) return;
if (src) {
// Now a normal upcoming stream: pre-buffer it and offer the gapless
// append, exactly as if it had been queued with its source.
preloadUpcomingStreams();
tryAppendNext();
}
if (pendingIn(peekNextIndices(STREAM_PRELOAD_DEPTH)) != null) scheduleQueueLookahead();
}
// ---- waveform ----
@@ -6506,16 +6802,115 @@ async function loadStreamWaveform(item, final = false) {
// ---- queue navigation, shuffle, repeat ----
// Indices already played this shuffle cycle (module-private).
let shuffleSeen = new Set();
/** A Fisher-Yates permutation of 0..n, optionally anchored so `firstIndex`
* leads — the track already playing when an order is (re)built stays where
* it is and the shuffle happens to everything after it. */
function shuffledIndices(n, firstIndex = null) {
const idx = Array.from({ length: n }, (_, i) => i);
for (let i = n - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[idx[i], idx[j]] = [idx[j], idx[i]];
}
if (firstIndex != null) {
const at = idx.indexOf(firstIndex);
if (at > 0) {
idx.splice(at, 1);
idx.unshift(firstIndex);
}
}
return idx;
}
function pickShuffleIndex(reset = false) {
/** Keep the predetermined order valid. Called from the MUTATION points —
* every track start, the shuffle/repeat toggles, session restore — never
* from render-time reads: `peekNextIndex` runs inside $derived, where
* writing state is (rightly) an error. */
function ensureShuffleOrder() {
if (!player.shuffle) return;
const n = player.queue.length;
if (reset) shuffleSeen = new Set();
const remaining = [];
for (let i = 0; i < n; i++) if (!shuffleSeen.has(i)) remaining.push(i);
if (remaining.length === 0) return -1;
return remaining[Math.floor(Math.random() * remaining.length)];
if (n === 0) {
player.shuffleOrder = [];
player.shuffleNextOrder = null;
return;
}
if (player.shuffleOrder.length !== n || !player.shuffleOrder.includes(player.queueIndex)) {
player.shuffleOrder = shuffledIndices(n, player.queueIndex);
player.shuffleNextOrder = null;
}
// Within a preload-window of the cycle's end with repeat "all": the
// future crosses into a reshuffle, so make that reshuffle NOW and keep
// it — the up-next display, the 3-deep pre-buffer and the advance all
// need the SAME answer.
const pos = player.shuffleOrder.indexOf(player.queueIndex);
if (pos >= n - STREAM_PRELOAD_DEPTH && player.repeat === "all") {
if (!player.shuffleNextOrder || player.shuffleNextOrder.length !== n) {
const next = shuffledIndices(n);
// A cycle boundary should not replay the same track back to back.
if (n > 1 && next[0] === player.queueIndex) {
[next[0], next[1]] = [next[1], next[0]];
}
player.shuffleNextOrder = next;
}
} else {
player.shuffleNextOrder = null;
}
}
/** How many upcoming tracks keep their bytes ON DECK in the stream cache.
* One was the minimum for gapless boundaries; three gives a slow peer two
* whole songs of runway, and the window slides — every advance tops it
* back up. The SEARCHES that find sources stay one-at-a-time regardless
* (the album fill's own anti-flood posture); only the byte-pulling widens. */
const STREAM_PRELOAD_DEPTH = 3;
/** The next `count` queue indices in PLAY order — through the shuffle
* order when it's on, across the repeat-all boundary when the pre-made
* next cycle exists. Pure: reads state only. Duplicates collapse (a
* two-track queue on repeat has one upcoming track, not two entries). */
export function peekNextIndices(count = STREAM_PRELOAD_DEPTH) {
const n = player.queue.length;
if (n === 0) return [];
let seq;
let pos;
if (player.shuffle) {
seq = player.shuffleOrder;
if (player.repeat === "all" && player.shuffleNextOrder?.length === n) {
seq = seq.concat(player.shuffleNextOrder);
}
pos = player.shuffleOrder.indexOf(player.queueIndex);
if (pos < 0) return [];
} else {
seq = Array.from({ length: n }, (_, i) => i);
if (player.repeat === "all") seq = seq.concat(seq);
pos = player.queueIndex;
}
const out = [];
const seen = new Set([player.queueIndex]);
for (let at = pos + 1; at < seq.length && out.length < count; at += 1) {
const i = seq[at];
if (seen.has(i)) continue;
seen.add(i);
out.push(i);
}
return out;
}
/** The queue index that plays after the current track, or -1 when playback
* would stop there. THE one answer for what comes next — the up-next card,
* the gapless preload/append, the pending-row lookahead and the advance
* itself all read this, so they cannot disagree. Pure: reads state only. */
export function peekNextIndex() {
const n = player.queue.length;
if (n === 0) return -1;
if (player.shuffle) {
const pos = player.shuffleOrder.indexOf(player.queueIndex);
if (pos < 0) return -1; // order not built yet — a mutation point will
if (pos + 1 < player.shuffleOrder.length) return player.shuffleOrder[pos + 1];
if (player.repeat !== "all") return -1;
return player.shuffleNextOrder?.length === n ? player.shuffleNextOrder[0] : -1;
}
const next = player.queueIndex + 1;
return next < n ? next : player.repeat === "all" ? 0 : -1;
}
export function nextTrack(auto = false) {
@@ -6532,11 +6927,14 @@ export function nextTrack(auto = false) {
let next;
if (player.shuffle) {
shuffleSeen.add(player.queueIndex);
next = pickShuffleIndex();
if (next < 0) {
if (player.repeat === "all") next = pickShuffleIndex(true);
else return stopPlayback();
ensureShuffleOrder();
next = peekNextIndex();
if (next < 0) return stopPlayback();
// Crossing the cycle boundary consumes the pre-made reshuffle.
const pos = player.shuffleOrder.indexOf(player.queueIndex);
if (pos === player.shuffleOrder.length - 1 && player.shuffleNextOrder) {
player.shuffleOrder = player.shuffleNextOrder;
player.shuffleNextOrder = null;
}
} else {
next = player.queueIndex + 1;
@@ -6568,6 +6966,14 @@ export function prevTrack() {
seekPlayer(0);
return;
}
// Under shuffle, "previous" is the order's previous — the track that
// actually just played — not the neighbouring playlist row.
if (player.shuffle) {
const pos = player.shuffleOrder.indexOf(player.queueIndex);
if (pos > 0) playQueueIndex(player.shuffleOrder[pos - 1]);
else seekPlayer(0);
return;
}
const prev = player.queueIndex - 1;
if (prev >= 0) playQueueIndex(prev);
else seekPlayer(0);
@@ -6575,12 +6981,16 @@ export function prevTrack() {
export function toggleShuffle() {
player.shuffle = !player.shuffle;
shuffleSeen = new Set([player.queueIndex]);
player.shuffleOrder = [];
player.shuffleNextOrder = null;
ensureShuffleOrder();
}
export function cycleRepeat() {
player.repeat =
player.repeat === "off" ? "all" : player.repeat === "all" ? "one" : "off";
// Repeat decides whether a cycle boundary has a future — recheck it.
ensureShuffleOrder();
}
function stopPlayback() {
@@ -6602,7 +7012,7 @@ function stopPlayback() {
latestStreamTotal = 0;
autoAlbum = null;
appendedNextPath = null;
lastPreloadedPath = null;
preloadRequestedPaths = new Set();
setAccentFromArt(null);
}
@@ -6734,6 +7144,7 @@ function writeSession() {
duration: player.duration,
catalogRef: player.catalogRef,
shuffle: player.shuffle,
shuffleOrder: player.shuffleOrder,
repeat: player.repeat,
volume: player.volume,
muted: player.muted,
@@ -6829,6 +7240,9 @@ export async function restoreSession() {
player.muted = !!saveState.muted;
player.shuffle = !!saveState.shuffle;
player.repeat = saveState.repeat ?? "off";
// The predetermined order survives a restart with its queue; anything
// stale is rebuilt at the next mutation point.
player.shuffleOrder = Array.isArray(saveState.shuffleOrder) ? saveState.shuffleOrder : [];
const item = saveState.queue?.[saveState.queueIndex] ?? null;
if (item && saveState.track) {
@@ -6844,6 +7258,7 @@ export async function restoreSession() {
// Nothing is loaded in the audio thread, so the play button has to
// re-source rather than resume — see `resumePlayback`.
player.needsResource = !live;
ensureShuffleOrder();
// The bar's artwork and the app accent follow the track exactly as
// they would had it just been started.
if (item.stream) setAccentFromArtUrl(item.artUrl ?? null);
@@ -6901,6 +7316,6 @@ if (import.meta.env.DEV) {
playLocalFile, playAlbum, togglePlayPause, nextTrack, prevTrack,
catalogGo, refreshLibrary, refreshRecents,
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
playlistStore, refreshPlaylists, goToView,
playlistStore, refreshPlaylists, goToView, peekNextIndices,
};
}
+213 -24
View File
@@ -16,8 +16,15 @@
toggleShuffle,
goToView,
getArt,
cachedStreamArt,
playlistBulk,
runPlaylistBulk,
cancelPlaylistBulk,
dismissPlaylistBulk,
} from "../lib/state.svelte.js";
import { untrack } from "svelte";
import * as api from "../lib/api.js";
import { findCatalogTrack } from "../lib/catalogMatch.js";
import Icon from "../components/Icon.svelte";
const playlist = $derived(
@@ -29,6 +36,7 @@
let query = $state("");
let menuOpen = $state(false);
let confirmDelete = $state(false);
let exportOpen = $state(false);
let renaming = $state(false);
let nameInput = $state("");
let editingDesc = $state(false);
@@ -115,7 +123,6 @@
// the import ladder's own evidence class — supplies the cover and the
// track id, persisted on the row. Sequential, one search per row, each
// row attempted once per session; no match simply keeps the glyph.
const normId = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
const artAttempted = new Set();
let backfillBusy = false;
$effect(() => {
@@ -124,6 +131,7 @@
(i) =>
i.kind === "stream" &&
!i.art_url &&
!i.art_path &&
i.artist &&
i.title &&
!artAttempted.has(i.id),
@@ -138,17 +146,37 @@
if (ui.playlistId !== plId) return;
if (artAttempted.has(row.id)) continue;
artAttempted.add(row.id);
// The file BEFORE the catalog: a row already streamed once
// holds the peer's own copy, and underground rows — the ones
// with no catalog cover to find, which is why they are in
// this walk at all — are exactly the ones whose files carry
// artwork nothing else has.
const embedded = await cachedStreamArt(row.track_id, row.artist, row.title);
if (ui.playlistId !== plId) return;
if (embedded) {
try {
await api.playlistRelinkItem(plId, row.id, { artPath: embedded });
const mine = items.find((i) => i.id === row.id);
if (mine) mine.art_path = embedded;
} catch {
// The row vanished mid-walk — nothing to persist onto.
}
continue;
}
// The SAME lookup the import review uses — one definition of
// "this row is that record", so a row that matched on import
// matches here too.
let hit = null;
try {
const res = await api.catalogSearch(`${row.artist} ${row.title}`);
const same = (t) =>
normId(t.artist) === normId(row.artist) &&
normId(t.title) === normId(row.title);
const hits = (res?.tracks ?? []).filter(same);
hit =
hits.find((t) => row.album && normId(t.album) === normId(row.album)) ??
hits[0] ??
null;
hit = await findCatalogTrack(
(query) => api.catalogSearchTracks(query),
{
title: row.title,
artist: row.artist,
duration_secs: row.duration_secs ?? null,
},
(artist, title) => api.catalogArtistTrackCandidates(artist, title),
);
} catch {
// Offline or throttled — let the next page visit retry.
artAttempted.delete(row.id);
@@ -174,6 +202,28 @@
}
}
// A row the walk above could not help: its file was not cached when the
// page opened, so nothing local existed to read. The player extracts the
// cover the moment it streams one — if that row is one of ours, keep it,
// rather than making the row blank again on the next visit.
$effect(() => {
const q = player.queue[player.queueIndex];
const found = q?.art;
const rowId = q?.plItemId;
if (!found || rowId == null || !playlist) return;
const mine = items.find((i) => i.id === rowId);
if (!mine || mine.art_path === found) return;
const plId = playlist.id;
// Writing back into `items` is what this effect READS; untrack keeps
// the assignment from re-entering it, and the equality guard above
// makes a second pass a no-op either way.
untrack(() => {
mine.art_path = found;
artAttempted.add(rowId);
api.playlistRelinkItem(plId, rowId, { artPath: found }).catch(() => {});
});
});
const norm = (s) => (s ?? "").toLowerCase();
const visible = $derived.by(() => {
const q = norm(query.trim());
@@ -340,8 +390,23 @@
delete playlistStore.covers[playlist.id];
await refreshPlaylists();
}
// The two collection actions — stream rows with full identity are the
// material; the runner dedupes namesakes and narrates via playlistBulk.
const bulkableCount = $derived(
items.filter((i) => i.kind === "stream" && i.artist && i.title).length,
);
function addCachedToLibrary() {
menuOpen = false;
void runPlaylistBulk(playlist.id, items, "cached");
}
function downloadAllTracks() {
menuOpen = false;
void runPlaylistBulk(playlist.id, items, "download");
}
async function exportAs(format) {
menuOpen = false;
exportOpen = false;
try {
await api.playlistExport(playlist.id, format);
} catch (e) {
@@ -670,6 +735,7 @@
onclick={(e) => {
e.stopPropagation();
confirmDelete = false;
exportOpen = false;
menuOpen = !menuOpen;
}}
>
@@ -698,24 +764,51 @@
<button class="mi" onclick={removeUnresolved}>
<Icon name="x" size={15} /> Remove unresolved entries
</button>
<div class="rule"></div>
{/if}
<button class="mi" onclick={() => exportAs("m3u8")}>
<Icon name="export" size={15} /> Export M3U8
<div class="rule"></div>
<button
class="mi"
onclick={addCachedToLibrary}
disabled={playlistBulk.active || bulkableCount === 0}
title="File every already-streamed track into the library — nothing is downloaded"
>
<Icon name="library" size={15} /> Add to library
</button>
<button class="mi" onclick={() => exportAs("xspf")}>
<Icon name="export" size={15} /> Export XSPF
</button>
<button class="mi" onclick={() => exportAs("pls")}>
<Icon name="export" size={15} /> Export PLS
</button>
<button class="mi" onclick={() => exportAs("csv")}>
<Icon name="export" size={15} /> Export CSV
</button>
<button class="mi" onclick={() => exportAs("json")}>
<Icon name="export" size={15} /> Export backup (JSON)
<button
class="mi"
onclick={downloadAllTracks}
disabled={playlistBulk.active || bulkableCount === 0}
title="Find and download every track that isn't in the library yet"
>
<Icon name="download" size={15} /> Download all tracks
</button>
<div class="rule"></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="sub-wrap"
onmouseenter={() => (exportOpen = true)}
onmouseleave={() => (exportOpen = false)}
>
<button
class="mi"
onclick={() => (exportOpen = !exportOpen)}
aria-haspopup="menu"
aria-expanded={exportOpen}
>
<Icon name="export" size={15} /> Export
<span class="sub-arrow"><Icon name="chevron-right" size={13} /></span>
</button>
{#if exportOpen}
<div class="menu sub" role="menu">
<button class="mi" onclick={() => exportAs("m3u8")}>M3U8</button>
<button class="mi" onclick={() => exportAs("xspf")}>XSPF</button>
<button class="mi" onclick={() => exportAs("pls")}>PLS</button>
<button class="mi" onclick={() => exportAs("csv")}>CSV</button>
<button class="mi" onclick={() => exportAs("json")}>Backup (JSON)</button>
</div>
{/if}
</div>
<div class="rule"></div>
<button class="mi danger" onclick={del}>
<Icon name="trash" size={15} />
{confirmDelete ? "Really delete?" : "Delete playlist"}
@@ -737,6 +830,31 @@
{/if}
</div>
</div>
{#if playlistBulk.playlistId === playlist.id && (playlistBulk.active || playlistBulk.summary)}
<div class="bulkline">
{#if playlistBulk.active}
<span class="ring"></span>
<span>
{playlistBulk.mode === "cached"
? "Adding streamed tracks to the library"
: "Downloading tracks"}
— {playlistBulk.done}/{playlistBulk.total}
{#if playlistBulk.organized}&thinsp;·&thinsp;{playlistBulk.organized} saved{/if}
{#if playlistBulk.queued}&thinsp;·&thinsp;{playlistBulk.queued} downloading{/if}
{#if playlistBulk.missed}&thinsp;·&thinsp;{playlistBulk.missed} missed{/if}
</span>
<button class="bulkx" title="Stop" onclick={cancelPlaylistBulk}>
<Icon name="x" size={13} />
</button>
{:else}
<Icon name="check" size={13} />
<span>{playlistBulk.summary}</span>
<button class="bulkx" title="Dismiss" onclick={dismissPlaylistBulk}>
<Icon name="x" size={13} />
</button>
{/if}
</div>
{/if}
</div>
</div>
</header>
@@ -1236,6 +1354,77 @@
.mi.danger:hover {
color: #f15e6c;
}
.sub-wrap {
position: relative;
}
.sub-wrap > .mi {
width: 100%;
}
.sub-arrow {
margin-left: auto;
display: inline-flex;
color: var(--text-tertiary);
}
/* The flyout: the same glass card, berthed beside its row. The bridge
(::before) covers the 6px gap so the pointer can cross without the
mouseleave closing it. */
.menu.sub {
position: absolute;
left: calc(100% + 4px);
top: -6px;
min-width: 130px;
}
.menu.sub::before {
content: "";
position: absolute;
left: -8px;
top: 0;
bottom: 0;
width: 8px;
}
.mi:disabled {
opacity: 0.45;
pointer-events: none;
}
.bulkline {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding: 7px 12px;
border-radius: 999px;
width: fit-content;
max-width: 100%;
background: var(--bg-elevated);
border: 1px solid var(--border);
font-size: 12.5px;
font-weight: 600;
color: var(--text-secondary);
}
.bulkline .ring {
width: 12px;
height: 12px;
flex: none;
border-radius: 50%;
border: 2px solid var(--border);
border-top-color: var(--accent);
animation: bulkspin 0.9s linear infinite;
}
@keyframes bulkspin {
to {
transform: rotate(360deg);
}
}
.bulkline .bulkx {
display: inline-flex;
color: var(--text-tertiary);
padding: 2px;
border-radius: 50%;
}
.bulkline .bulkx:hover {
color: var(--text-primary);
background: var(--bg-highlight);
}
.menu .rule {
height: 1px;
background: var(--border);
+65 -15
View File
@@ -19,6 +19,7 @@
let importMode = $state("file"); // "file" | "paste"
let importDraft = $state(null);
let menuFor = $state(null); // playlist id whose ⋯ menu is open
let exportFor = $state(null); // playlist id whose Export flyout is open
let confirmDelete = $state(null); // playlist id armed for deletion
let creating = $state(false);
let newMenuOpen = $state(false);
@@ -130,6 +131,13 @@
importOpen = true;
}
function importFromYoutubePlaylist() {
newMenuOpen = false;
importDraft = null;
importMode = "ytplaylist";
importOpen = true;
}
function open(pl) {
goToView("playlist", { playlistId: pl.id, label: pl.name });
}
@@ -211,6 +219,7 @@
async function exportAs(pl, format) {
menuFor = null;
exportFor = null;
try {
await api.playlistExport(pl.id, format);
} catch (e) {
@@ -307,6 +316,11 @@
YouTube DJ set…
<span class="hint">tracks from chapters, description or comments</span>
</button>
<button class="mi" onclick={importFromYoutubePlaylist}>
<Icon name="playlist" size={15} />
Playlist from a link…
<span class="hint">YouTube · Spotify · Apple Music · TIDAL · Deezer</span>
</button>
</div>
{/if}
</div>
@@ -420,21 +434,31 @@
<Icon name="layers" size={15} /> Duplicate
</button>
<div class="rule"></div>
<button class="mi" onclick={() => exportAs(pl, "m3u8")}>
<Icon name="export" size={15} /> Export M3U8
</button>
<button class="mi" onclick={() => exportAs(pl, "xspf")}>
<Icon name="export" size={15} /> Export XSPF
</button>
<button class="mi" onclick={() => exportAs(pl, "pls")}>
<Icon name="export" size={15} /> Export PLS
</button>
<button class="mi" onclick={() => exportAs(pl, "csv")}>
<Icon name="export" size={15} /> Export CSV
</button>
<button class="mi" onclick={() => exportAs(pl, "json")}>
<Icon name="export" size={15} /> Export backup (JSON)
</button>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="sub-wrap"
onmouseenter={() => (exportFor = pl.id)}
onmouseleave={() => (exportFor = null)}
>
<button
class="mi"
onclick={() => (exportFor = exportFor === pl.id ? null : pl.id)}
aria-haspopup="menu"
aria-expanded={exportFor === pl.id}
>
<Icon name="export" size={15} /> Export
<span class="sub-arrow"><Icon name="chevron-right" size={13} /></span>
</button>
{#if exportFor === pl.id}
<div class="menu sub" role="menu">
<button class="mi" onclick={() => exportAs(pl, "m3u8")}>M3U8</button>
<button class="mi" onclick={() => exportAs(pl, "xspf")}>XSPF</button>
<button class="mi" onclick={() => exportAs(pl, "pls")}>PLS</button>
<button class="mi" onclick={() => exportAs(pl, "csv")}>CSV</button>
<button class="mi" onclick={() => exportAs(pl, "json")}>Backup (JSON)</button>
</div>
{/if}
</div>
<div class="rule"></div>
<button class="mi danger" onclick={() => remove(pl)}>
<Icon name="trash" size={15} />
@@ -641,6 +665,32 @@
color: var(--text-muted);
padding-left: 14px;
}
.sub-wrap {
position: relative;
}
.sub-arrow {
margin-left: auto;
display: inline-flex;
color: var(--text-tertiary);
}
/* Same glass card, berthed beside its row; the ::before bridge covers
the gap so the pointer can cross without closing it. Card menus sit
at the grid's right edge as often as not, so the flyout opens LEFT. */
.menu.sub {
position: absolute;
right: calc(100% + 4px);
left: auto;
top: -6px;
min-width: 130px;
}
.menu.sub::after {
content: "";
position: absolute;
right: -8px;
top: 0;
bottom: 0;
width: 8px;
}
.menu .rule {
height: 1px;
background: var(--border);