• Import a shared playlist link and build it in Navidrome
    Paste a Spotify, Apple Music or Deezer playlist URL; muse resolves it, matches
    every track through the same Deezer path a pasted line takes, and writes an
    .m3u the library scanner picks up.
    
    Written per import rather than at the end, so the playlist appears in Navidrome
    with the first track that lands and grows as the rest arrive -- twenty minutes
    of nothing, then everything, is the worse shape. A track rejected in review
    drops out on the next rewrite, and one that never resolves is reported instead
    of silently vanishing.
    
    Resolve is split from queue on purpose: a shared link is opaque, you cannot
    tell twelve tracks from four hundred by looking at it, and four hundred is a
    quota decision.
    
    Two of the three sources are scrapes and the code says so plainly. Deezer has a
    real API. Spotify and Apple Music have no free way to read a public playlist --
    Apple's MusicKit needs a paid developer account -- so those read the public page
    and parse the JSON it already embeds for its own player. No key, no OAuth, no
    account, and no guarantee: they break whenever either company reshuffles their
    frontend. So the resolvers fail loudly and name themselves, because an empty
    playlist and a changed page must not produce the same message.
    
    The .m3u uses MUSE_PLAYLIST_PREFIX (/music), not muse's own library path. Both
    containers mount the same directory at different points and a playlist written
    with muse's view resolves to nothing on Navidrome's side.
    
    Also reorders the hooks in search-bar so the URL-seeding effect is declared
    after the state it sets, rather than closing over a binding declared below it.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Check the audio's length, since the filename can agree and still be wrong
    The file that got filed as Michael Jackson's "Chicago" had every textual signal
    in its favour: the title matched exactly, and once approved it landed under the
    right artist, album and track number. The only thing that disagreed was the
    running time -- 359 seconds against Deezer's 245 -- because it was The Fall.
    
    Duration is the one signal a stranger's filename cannot fake, and muse already
    had it: Deezer supplies it at request time and it has been sitting unused in the
    jobs table. On import the real length is read off the downloaded audio and
    compared; a drift past MUSE_LENGTH_TOLERANCE multiplies confidence down and
    explains itself in the review queue rather than leaving a bare number.
    
    For this case that is 0.75 -> 0.26, which falls below REVIEW_CONFIDENCE and so
    reads as "almost certainly wrong" instead of "worth a look". It runs on import
    rather than in confidence() because the file does not exist when the candidate
    is chosen.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Make the artist a gate, not a quarter of the score
    Requesting "Michael Jackson - Chicago" filed The Fall's "Chicago, Now!" from
    Extricate. The chain: the artist name is filtered by the Soulseek server, so
    the ladder correctly fell back to the bare title -- at which point the result
    set is every song anyone has ever called Chicago, and the artist is the only
    thing telling them apart.
    
    match_ratio weights the artist at 0.25, so a perfect title with no artist
    anywhere scores 0.75. That clears MIN_MATCH, and the ~22 point gap it leaves
    against a genuine match is nothing next to the 340 points score() awards for a
    free upload slot and a short queue. An idle peer holding the wrong song
    therefore beat a busy peer holding the right one, every time.
    
    That is the failure the confidence score was explicitly designed to prevent --
    "a fast peer must not be able to raise the confidence of a wrong match" -- and
    rewriting score() to rank availability first reintroduced it on the other side
    of the wall. Availability is a tiebreak among plausible files; it is never a
    reason to take a different song.
    
    Candidates whose path names the artist are now kept outright and the rest
    discarded, falling back to title-only matches only when there are none. The
    downstream confidence gate still ran correctly here: it scored 0.75, said
    "artist not found in path", and held the file for review.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Correct the reasoning behind the attempt backoff
    baa936e claimed muse's enqueue burst stalled slskd and took other users'
    searches down with it. That is not what happened, and the log says so plainly:
    "Failed to resolve search response" timeouts run at 10-178 per hour, every
    hour, for as long as the log is retained -- they are constant background from
    slskd answering other people's searches over a network full of unreachable
    peers. Seeing three of them in the same minute as five of muse's own enqueue
    timeouts was a coincidence I read as causation.
    
    What is actually true is narrower: an unreachable peer is common, slskd reports
    it as a 500, and the ladder exists to walk past it. The backoff stands as
    ordinary politeness rather than a fix, and the README now says that instead of
    claiming a harm that was never measured.
    
    No behaviour change.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Try the whole ladder, rather than five eighths of it
    MAX_ATTEMPTS was 5 while CANDIDATES was 8, so three ranked candidates were
    gathered and then never tried. The cap was arbitrary and the live numbers show
    what it cost.
    
    slskd answers 500 whenever it cannot open a connection to a peer, and that is
    common: retrying "Michael Jackson - Billie Jean" walked four unreachable peers
    before the fifth served it -- succeeding on precisely the last attempt the cap
    allowed. "The Weeknd - Die For You" ran the same gauntlet and lost, giving up
    with three untried candidates still on the row.
    
    Defaults to CANDIDATES, so the ladder is the ladder. Anyone wanting the old
    behaviour can still set MUSE_MAX_ATTEMPTS.
    
    The cap test lowers the value explicitly now; with the two equal by default it
    was only ever proving the list ran out, which is the other branch.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Stop the candidate ladder from hammering slskd through a stall
    A live request for "Michael Jackson - Chicago" exercised both new paths
    correctly -- the blocked artist name fell back to the bare title and found real
    Xscape rips, and the ladder tried five peers instead of dying on the first --
    and then failed anyway, with all five reporting a 500 from slskd.
    
    slskd's own log shows why, and it is not the peers. Every timeout in the
    preceding hour landed inside the single minute muse was enqueueing, and in the
    same window slskd also failed to resolve three OTHER users' searches. Each
    enqueue blocks about five seconds waiting on a peer message connection, so five
    back-to-back attempts is twenty-five seconds of sustained pressure on one
    subsystem. The ladder turned one bad moment into five spent candidates and a
    dead request, and made slskd worse for everyone else while doing it.
    
    Attempts are now spaced by MUSE_ATTEMPT_BACKOFF. Failures are classified: "File
    not shared" is about a file and still advances at once, while a timeout is about
    slskd and, after MUSE_SYSTEMIC_LIMIT of them in a row, stops the walk and leaves
    the untried candidates on the row. A retry resumes that ladder instead of
    re-running a search whose answer has not changed.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Count the peers actually tried, not the attempt counter
    A job carried over from before the candidate ladder has attempts=0 while
    carrying a real failure in `tried`, so the summary rendered as "tried 0 peers:
    1x no bytes in 20m" -- a count of zero next to the evidence contradicting it.
    The live instance produced exactly that when the stall check finally cleared
    its long-stuck job.
    
    Counts distinct usernames in `tried` instead. The two agree for anything this
    version enqueued; the list is the one that is right in both cases.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Age a stalled transfer from created_at when started_at is missing
    The stall clock read started_at and returned zero when it was absent, which
    exempted every row written before that column existed -- precisely the jobs
    that have been stuck the longest. The live instance had one: a `downloading`
    job from before the upgrade, its transfer parked in Queued, Remotely, which
    the new check would have skipped forever.
    
    Falls back to created_at, which for a job still in `downloading` is the same
    question asked a slightly different way.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Recover blocked searches, fall through to the next peer, rebuild the UI
    Measured against a live instance: 47 failed jobs against 33 imported. Three
    independent causes, all of which ended the request.
    
    The Soulseek server filters some artist names. "Michael Jackson Billie Jean"
    and bare "Michael Jackson" both return zero responses; "Billie Jean" returns
    1083 files. The Weeknd, Kanye West and Frank Ocean behave the same way. Since
    pick_and_enqueue sent exactly one query of `artist title`, every request for a
    filtered artist failed reporting "searched 0 files" -- 28 of 29 occurrences.
    search_queries() now walks a ladder, loosest last, ending at the bare title,
    and escalates only when a search underdelivers so the common case still costs
    one search. Dropping the artist from the query does not mean ignoring it:
    match_ratio() looks for it in the folder path, where an organised share puts it.
    
    A rejected transfer ended the request. pick_and_enqueue kept a running maximum
    and discarded every runner-up, so "Transfer rejected: File not shared." -- a
    per-file condition where candidate two almost always works -- was terminal, as
    were timeouts and slskd's 500 on an unreachable peer. The ranked list is now
    persisted on the job row, ordered to put a different peer next, and
    try_next_candidate() advances through it. A job fails only once the list is
    exhausted, and then reports every peer it tried and what each one said.
    
    norm() destroyed every non-ASCII character, so "Beyonce" never matched
    Beyonce, nor Bjork Bjork, nor Dont Don't. It now folds accents and drops
    apostrophes while leaving & as a separator.
    
    Also: availability outranks format in score(), because a FLAC behind 5000
    queued users used to beat an immediately available MP3; transfers that stall or
    that slskd forgets are abandoned instead of sitting in `downloading` forever;
    searches are deleted only once finished, since deleting one mid-flight makes
    slskd throw an optimistic-concurrency error and lose the record; a failed
    search POST is retried rather than failing the job on one blip.
    
    The UI moves to a sidebar shell -- rail at tablet, bottom bar on phones -- with
    neutral chrome and the sampled artwork colour confined to hero areas rather
    than repainting the whole interface. Adds URL routing, a primitive set under
    components/ui, and a request sheet showing the attempt ladder. Fixes
    hover:bg-glass-hi, which was never a valid class, so track rows and the search
    field had no hover or focus state at all; a reduced-motion path that could
    strand elements at opacity 0; the login page's hardcoded palette; and the
    missing 404 and error boundaries.
    
    tests/test_acquisition.py covers this path against a faked slskd. It had none.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
49 changed files with 4070 additions and 810 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "muse",
"runtimeExecutable": "bash",
"runtimeArgs": ["dev.sh"],
"port": 5041
}
]
}
+5
View File
@@ -5,3 +5,8 @@ config/
.venv/
.DS_Store
dev/
# Local diagnostics: contain the live instance's Soulseek username,
# server paths and peer names. Not for a public repo.
FINDINGS.md
SERVER_AUDIT.md
+166 -7
View File
@@ -148,8 +148,40 @@ automatically. Every job shows what it *resolved to*, so a bad auto-pick is
visible in the list rather than quietly landing the wrong song. Lines that
match nothing are reported back as skipped instead of failing the batch.
Failed jobs get a **Retry** button. Most Soulseek failures are the peer going
offline, not a bad request, so retrying later usually just works.
Failed jobs get a **Retry** button, but far fewer jobs reach that state now:
a request that loses its peer falls through to the next candidate on its own.
See "When a peer says no" below.
## Shared playlist links
Paste a Spotify, Apple Music or Deezer playlist link and muse resolves it,
matches every track the same way a pasted line is matched, and writes an `.m3u`
into the library that Navidrome picks up on its next scan — within seconds, in
practice. The playlist is written **per import**, not at the end, so it appears
with the first track that lands and grows as the rest arrive. Tracks that never
resolve are reported rather than silently dropped, and a track rejected in
review disappears from the playlist on the next rewrite.
Resolving is deliberately separate from queueing. A shared link is opaque — you
cannot tell twelve tracks from four hundred by looking at it — so the track list
and count are shown before anything is committed against a quota.
**Two of the three sources are scrapes, and this is worth understanding before
you rely on it.** Deezer has a real public API. Spotify and Apple Music have no
free way to read a public playlist — Apple's MusicKit requires a paid developer
account — so muse reads the public web page and parses the JSON blob the page
already embeds for its own player. No key, no OAuth, no account. It also means
those two will break the day either company reshuffles their frontend. The
resolvers therefore fail loudly and specifically ("Spotify's embed page no
longer carries `__NEXT_DATA__` — the resolver in app.py needs updating") rather
than quietly returning nothing, because an empty playlist and a changed page
must not look the same to whoever has to fix it.
One detail that is easy to get wrong: the `.m3u` has to use **Navidrome's**
mount path, not muse's. Both containers see the same directory, muse at
`/data/media/music` and Navidrome at `/music`, and a playlist written with the
former resolves to nothing on the other side. `MUSE_PLAYLIST_PREFIX` is that
path.
### Everything goes through a bounded queue
@@ -179,10 +211,115 @@ is needed to catch `Come As You Are (Live)`, but substring matching would also
penalise `Deliver Me` and `Olive`. And a term only counts against a result when
you didn't type it, so `oasis live forever` and `nirvana unplugged` still work.
**Picking the file (Soulseek).** Format preference first, then whether the peer
can actually serve you (free upload slot, short queue), then filename
plausibility, then bitrate. A marginally better file behind 200 queued users
loses to a good-enough one available now.
**Picking the file (Soulseek).** Whether the peer can actually serve you comes
first — a free upload slot and a short queue — then format preference, then
filename plausibility, then bitrate. A marginally better file behind 200 queued
users loses to a good-enough one available now.
That order is deliberate and was once the other way round, which was a bug
worth naming: format outranked availability, so a FLAC behind a five-thousand
deep queue always beat an MP3 that was free to download immediately. Whether
the peer can serve you is the single most predictive signal for whether the
job ever finishes, so nothing outranks it.
## Searching for a name the file doesn't use
Two things go wrong between "what Deezer calls this track" and "what a stranger
named the file", and both used to end the request.
**The Soulseek server filters some artist names.** This is measurable: on a
healthy, logged-in client, `Michael Jackson Billie Jean` and bare
`Michael Jackson` both return **zero** responses, while `Billie Jean` returns
over a thousand files. The same is true of The Weeknd, Kanye West and Frank
Ocean. Sending one query of `artist title` — which is all muse used to do —
means every request for a filtered artist fails reporting "searched 0 files",
indistinguishable from a track nobody shares.
So muse tries a **ladder** of queries, loosest last, and stops at the first
rung that yields enough candidates:
```
Michael Jackson Billie Jean 0 responses
Billie Jean 251 responses, 1083 files
```
The rungs drop, in order: a parenthetical suffix (`Novacane (Explicit Version)`),
a `feat.` credit, a leading article, everything after a `/` in a split title,
and finally the artist itself. Since most requests are answered by the first
query, the extra searches cost nothing in the common case — `MUSE_MAX_QUERIES`
caps how far it will go.
Dropping the artist from the *query* does not mean ignoring it — it makes the
artist matter more. A bare-title search stops returning "this song from various
peers" and starts returning *every song anyone ever called Chicago*: The Fall,
Groove Armada, Sufjan Stevens. The artist is then the only thing separating the
right recording from a different song entirely, so it is a **gate**: candidates
whose path names the artist are preferred outright, and ones that do not are
used only when nothing else is left.
That has to be a gate rather than a weighting, and the reason is worth stating.
A perfect title with no artist scores 0.75, comfortably above `MUSE_MIN_MATCH`,
so as a mere contribution it loses to availability — and an idle peer holding
the wrong song beats a busy one holding the right song. That is not
hypothetical either: it filed The Fall's "Chicago, Now!" as Michael Jackson.
The same principle the confidence score is built on applies here: a fast peer
must not be able to promote a wrong match.
The artist is matched against the whole **folder path**, which is where a
well-organised share puts it anyway:
```
anjya music/Michael Jackson/1982 - Thriller [flac]/06 - Billie Jean.flac
^^^^^^^^^^^^^^^^ artist found here
```
**Names differ in punctuation and accents.** Normalisation folds accents away
before comparing, so `Beyoncé` matches a file called `Beyonce`, `Björk` matches
`Bjork`, and `Don't Stop` matches `Dont Stop`. Apostrophes are removed rather
than turned into a space; `&` still separates, so `R&B` stays two tokens
instead of collapsing into the unrelated word `rb`. Track numbers and release
tags (`02 - `, `[320kbps]`) are stripped before the comparison, and a
reordered filename (`Creep - Radiohead`) scores the same as `Radiohead - Creep`.
The title has to stand on its own: a matching artist cannot carry a filename
whose title is wrong. Without that floor, a strong artist match let a
*different song by the right artist* through.
## When a peer says no
`Transfer rejected: File not shared.` is the commonest failure on Soulseek —
the peer's share index is stale and the file it advertised is gone. It is a
condition of one *file*, not of the request, and the right response is to take
the next candidate.
muse keeps the whole ranked list (`MUSE_CANDIDATES`, default 8) on the job row
rather than only the winner, so when a transfer is rejected, times out, errors,
or slskd cannot open a connection to the peer at all, the next candidate is
enqueued instead of the request failing. The list is ordered to put a different
peer next, so one offline user cannot burn three attempts in a row. Only when
the list is exhausted — or `MUSE_MAX_ATTEMPTS` is reached — does the job fail,
and it then reports every peer it tried and what each one said.
A download that sits with no bytes for `MUSE_STALL_MINUTES` is abandoned the
same way, as is one slskd has forgotten about. Both used to leave the job in
`downloading` forever, with a cheerful pulsing dot and no log line.
**Unreachable peers are the norm, not the exception.** slskd answers 500 when it
cannot open a connection to a peer within its five-second window, and on a live
instance that is common: one request walked four such peers before the fifth
served it. This is why the ladder is worth having and why `MUSE_MAX_ATTEMPTS`
defaults to the full `MUSE_CANDIDATES` rather than a smaller number — a cap
below the ladder size throws away candidates that would have worked.
Attempts are spaced by `MUSE_ATTEMPT_BACKOFF`. That is politeness rather than a
fix for anything measured: each enqueue holds a peer connection slot for up to
five seconds, and there is no reason to ask for eight of them in immediate
succession. Separately, `MUSE_SYSTEMIC_LIMIT` consecutive *connection* errors —
meaning slskd itself is unreachable, not a peer — stop the walk and **keep the
untried candidates on the row**, so retrying resumes the ladder rather than
paying for a search whose answer has not changed. A 500 is deliberately not
counted as systemic: it describes one peer, and treating it as systemic would
have abandoned the request that succeeded on its fifth attempt.
## Review: when muse isn't sure
@@ -202,6 +339,16 @@ filename alone:
Then:
**Length is checked against the audio, not the name.** Every textual signal can
agree and the file still be the wrong song: right title, right folder, right
album — and six minutes of a track that runs four. So once the download
finishes, the file's real duration is compared against what Deezer said, and a
drift past `MUSE_LENGTH_TOLERANCE` multiplies confidence down and says so in
words: *"runs 5:59 but the track is 4:05 (47% out), so this is probably a
different recording"*. This is applied on import rather than inside
`confidence()` for the obvious reason that the file does not exist yet when the
candidate is picked.
- **≥ 0.95** — filed automatically. Nothing to do.
- **0.70 – 0.95** — held in **Review** with the reasons shown.
- **< 0.70** — also held, but it will almost certainly be rejected.
@@ -470,9 +617,21 @@ stat -c "%h" "/data/media/music/<Artist>/<Album> (<Year>)/<NN> - <Title>.flac"
| `MUSE_LIBRARY_DIR` | `/data/media/music` | where the library lives |
| `MUSE_DB` | `/config/muse.db` | job history |
| `MUSE_FORMATS` | `flac,mp3` | ordered preference; anything else is rejected |
| `MUSE_MIN_MATCH` | `0.45` | filename-to-title similarity floor |
| `MUSE_MIN_MATCH` | `0.45` | filename-to-track similarity floor |
| `MUSE_TITLE_FLOOR` | `0.5` | the title alone must clear this before the artist counts |
| `MUSE_CANDIDATES` | `8` | ranked candidates kept per request, for fallback |
| `MUSE_MAX_ATTEMPTS` | `MUSE_CANDIDATES` | peers tried before a request is given up on |
| `MUSE_ATTEMPT_BACKOFF` | `4` | seconds between candidate attempts |
| `MUSE_SYSTEMIC_LIMIT` | `2` | consecutive connection failures before stopping |
| `MUSE_STALL_MINUTES` | `20` | a transfer moving no bytes for this long is abandoned |
| `MUSE_MAX_QUERIES` | `4` | rungs of the search ladder to try |
| `MUSE_MIN_CANDIDATES` | `3` | below this, escalate to the next query |
| `MUSE_SEARCH_RETRIES` | `3` | attempts at issuing a search before failing |
| `MUSE_AUTO_CONFIDENCE` | `0.95` | at or above this, import without asking |
| `MUSE_REVIEW_CONFIDENCE` | `0.70` | below this, held but probably wrong |
| `MUSE_LENGTH_TOLERANCE` | `0.2` | duration drift before a file is called a different recording |
| `MUSE_MAX_PLAYLIST_TRACKS` | `500` | cap on one playlist link |
| `MUSE_PLAYLIST_PREFIX` | `/music` | the library path *Navidrome* sees, for `.m3u` lines |
| `MUSE_WORKERS` | `3` | concurrent Soulseek searches |
| `MUSE_SMTP_HOST` | — | set to enable email; see **Email** above |
| `MUSE_SMTP_PORT` | `587` | |
+1198 -126
View File
File diff suppressed because it is too large. Load diff
+369
View File
@@ -0,0 +1,369 @@
"""The acquisition ladder: query variants, candidate fallback, stall handling.
This is the path that had no coverage at all, and it is where every production
failure lived. A fake slskd stands in for the network so the branches that only
happen on a bad day -- a peer that rejects, a transfer slskd forgets, a search
the server filters -- can be exercised deterministically.
Run standalone: python tests/test_acquisition.py
"""
import asyncio, json, os, sys
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("MUSE_AUTH", "off")
os.environ.setdefault("MUSE_DB", "dev/acquisition.db")
os.environ.setdefault("MUSE_SEARCH_POLLS", "2")
for f in ("dev/acquisition.db", "dev/acquisition.db-wal", "dev/acquisition.db-shm"):
if os.path.exists(f):
os.remove(f)
import app # noqa: E402
ok = fail = 0
def check(name, cond, detail=""):
global ok, fail
if cond:
ok += 1
print(f" PASS {name}")
else:
fail += 1
print(f" FAIL {name} {detail}")
# Searches poll on a 2s cadence; nothing here is actually waiting on a network.
async def _instant(_):
return None
app.asyncio.sleep = _instant
app.init_db() # normally done in lifespan; there is no server here
# ── a slskd that behaves however the test needs ──────────────────────────
class Resp:
def __init__(self, data=None, status=200):
self._data, self.status = data, status
def raise_for_status(self):
if self.status >= 400:
raise RuntimeError(f"Server error '{self.status} Internal Server Error'")
def json(self):
return self._data
class FakeSlskd:
"""Records what was asked of it and answers from a script."""
def __init__(self, per_query=None, enqueue_status=None, transfers=None):
self.per_query = per_query or {} # query text -> list of responses
self.enqueue_status = enqueue_status or {} # username -> http status
self.transfers = transfers or []
self.searched = []
self.enqueued = []
self.deleted = []
self._last = None
def __call__(self, timeout=30.0):
return self
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def post(self, url, json=None):
if url == "/api/v0/searches":
self._last = json["searchText"]
self.searched.append(self._last)
return Resp({})
user = url.rsplit("/", 1)[-1]
st = self.enqueue_status.get(user, 200)
self.enqueued.append(user)
return Resp({}, st)
async def get(self, url, params=None):
if url.startswith("/api/v0/searches/"):
responses = self.per_query.get(self._last, [])
return Resp({"state": "Completed, ResponseLimitReached",
"responses": responses,
"fileCount": sum(len(r["files"]) for r in responses)})
return Resp(self.transfers)
async def delete(self, url, params=None):
self.deleted.append(url)
return Resp({})
def peer(username, filename, *, slot=True, queue=0, size=5_000_000, bitrate=320):
return {"username": username, "hasFreeUploadSlot": slot,
"queueLength": queue, "uploadSpeed": 1_000_000,
"files": [{"filename": filename, "size": size, "bitRate": bitrate}]}
def make_job(job_id, artist="Michael Jackson", title="Billie Jean"):
with app.db() as c:
c.execute("INSERT OR REPLACE INTO jobs (id, state, artist, title, album)"
" VALUES (?,?,?,?,?)", (job_id, "searching", artist, title, None))
def job_row(job_id):
with app.db() as c:
return c.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
def main() -> int:
print("\nnormalisation")
check("accents fold to ASCII", app.norm("Beyoncé") == "beyonce",
repr(app.norm("Beyoncé")))
check("umlauts fold", app.norm("Björk") == "bjork", repr(app.norm("Björk")))
check("slashed o folds", app.norm("margø") == "margo", repr(app.norm("margø")))
check("apostrophes vanish, not split",
app.norm("Don't Stop") == "dont stop", repr(app.norm("Don't Stop")))
check("ampersand still separates", app.norm("R&B") == "r b", repr(app.norm("R&B")))
print("\nquery ladder")
qs = app.search_queries("Michael Jackson", "Billie Jean")
check("falls back to the bare title", "Billie Jean" in qs, qs)
check("full query is still tried first", qs[0] == "Michael Jackson Billie Jean", qs)
qs = app.search_queries("Frank Ocean", "Novacane (Explicit Version)")
check("strips a parenthetical suffix", "Frank Ocean Novacane" in qs, qs)
qs = app.search_queries("Post Sex Nachos", "Pop Tarts (feat. MARIS)")
check("strips a featuring credit", "Post Sex Nachos Pop Tarts" in qs, qs)
qs = app.search_queries("The Weeknd", "House Of Balloons / Glass Table Girls")
check("splits a slashed title", "House Of Balloons" in qs, qs)
check("ladder is capped", len(app.search_queries("A", "B")) <= app.MAX_QUERIES)
print("\nscoring")
avail_mp3 = app.score({"filename": r"x\Radiohead - Creep.mp3", "size": 1,
"bitRate": 320},
{"username": "u", "hasFreeUploadSlot": True,
"queueLength": 0, "uploadSpeed": 0},
"Radiohead", "Creep")
queued_flac = app.score({"filename": r"x\Radiohead - Creep.flac", "size": 1,
"bitRate": 1000},
{"username": "v", "hasFreeUploadSlot": False,
"queueLength": 400, "uploadSpeed": 0},
"Radiohead", "Creep")
check("an available MP3 beats a FLAC behind 400 people",
avail_mp3 > queued_flac, f"{avail_mp3:.0f} vs {queued_flac:.0f}")
check("artist found in the folder, not the filename",
app.match_ratio(r"Radiohead\Pablo Honey\02 Creep.flac",
"Radiohead", "Creep") == 1.0)
check("a matching artist cannot rescue the wrong song",
app.match_ratio(r"Radiohead\Pablo Honey\04 Karma Police.flac",
"Radiohead", "Creep") == 0.0)
check("reordered filename still matches",
app.match_ratio(r"x\Creep - Radiohead.mp3", "Radiohead", "Creep") == 1.0)
print("\nthe artist gate")
check("artist named in the folder is confirmed",
app.artist_confirmed(r"m\Michael Jackson\Xscape\02 Chicago.flac",
"Michael Jackson"))
check("a different band's song with the same title is not",
not app.artist_confirmed(
r"@@jwbvv\Music\The Fall\1990-Extricate\08-Chicago, Now!.flac",
"Michael Jackson"))
check("out-of-order naming still counts",
app.artist_confirmed(r"x\Jackson, Michael - Chicago.flac",
"Michael Jackson"))
# The real regression: bare-title search returns every song called
# "Chicago", and an available stranger used to outrank a queued match.
gate = FakeSlskd(per_query={"Chicago": [
peer("stranger", r"@@x\Music\The Fall\1990-Extricate\08-Chicago, Now!.flac",
slot=True, queue=0),
peer("correct", r"m\Michael Jackson\XSCAPE\02 - Chicago.flac",
slot=False, queue=300),
]})
app.slskd_client = gate
make_job("j6", "Michael Jackson", "Chicago")
asyncio.run(app.pick_and_enqueue("j6", "Michael Jackson", "Chicago"))
r = job_row("j6")
check("the right artist wins even from a peer with 300 queued",
r["username"] == "correct", f"picked {r['username']}")
check("and the wrong-artist file is dropped, not merely ranked lower",
len(json.loads(r["candidates"] or "[]")) == 0, r["candidates"])
print("\ncandidate ladder")
fake = FakeSlskd(per_query={
"Billie Jean": [peer("alice", r"m\Michael Jackson\Billie Jean.flac"),
peer("bob", r"n\Michael Jackson\Billie Jean.mp3"),
peer("carol", r"o\Michael Jackson\Billie Jean.flac")],
})
app.slskd_client = fake
make_job("j1")
asyncio.run(app.pick_and_enqueue("j1", "Michael Jackson", "Billie Jean"))
r = job_row("j1")
check("blocklisted artist recovered by the title rung",
"Billie Jean" in fake.searched, fake.searched)
check("job is downloading", r["state"] == "downloading", r["detail"])
check("runners-up were kept", len(json.loads(r["candidates"] or "[]")) == 2,
r["candidates"])
check("consecutive candidates are different peers",
len({c["username"] for c in json.loads(r["candidates"])}) == 2)
print("\nfallback on failure")
fake.enqueue_status = {}
asyncio.run(app.abandon(job_row("j1"), "Transfer rejected: File not shared."))
r = job_row("j1")
check("a rejection advances instead of failing",
r["state"] == "downloading", f"{r['state']} / {r['detail']}")
check("the failed peer is recorded",
"File not shared" in (r["tried"] or ""), r["tried"])
check("attempt counter advanced", r["attempts"] == 2, r["attempts"])
asyncio.run(app.abandon(job_row("j1"), "timed out"))
asyncio.run(app.abandon(job_row("j1"), "timed out"))
r = job_row("j1")
check("only fails once the ladder is exhausted", r["state"] == "failed", r["detail"])
check("failure names every attempt, not just the last",
"tried 3 peers" in (r["detail"] or ""), r["detail"])
# A row carried over from before the ladder has attempts=0 but a real
# failure recorded, which must not render as "tried 0 peers: 1x ...".
check("a migrated row counts its evidence, not its counter",
app.exhausted_detail(
[{"username": "u1", "reason": "no bytes in 20m"}], 0)
== "tried 1 peer: 1x no bytes in 20m",
app.exhausted_detail([{"username": "u1", "reason": "no bytes in 20m"}], 0))
print("\nattempt cap")
fake2 = FakeSlskd(per_query={"Creep": [
peer(f"u{i}", rf"x\Radiohead\Creep.flac") for i in range(8)]})
# every peer refuses to accept the transfer
fake2.enqueue_status = {f"u{i}": 500 for i in range(8)}
app.slskd_client = fake2
# The cap defaults to the ladder size, so lower it here or this only ever
# proves the list ran out -- which is a different branch.
cap, app.MAX_ATTEMPTS = app.MAX_ATTEMPTS, 3
make_job("j2", "Radiohead", "Creep")
asyncio.run(app.pick_and_enqueue("j2", "Radiohead", "Creep"))
r = job_row("j2")
check("gives up at MAX_ATTEMPTS, not at the end of the list",
r["state"] == "failed" and r["attempts"] == 3,
f"state={r['state']} attempts={r['attempts']}")
check("and leaves the untried remainder alone",
len(json.loads(r["candidates"] or "[]")) > 0, r["candidates"])
app.MAX_ATTEMPTS = cap
check("unreachable peers are reported as such",
"could not reach peer" in (r["tried"] or ""), r["detail"])
print("\nsystemic failures do not burn the ladder")
# slskd timing out says nothing about any particular file, so walking the
# whole list to learn it once is waste -- and it is what took a real
# request down: five enqueues in twenty-five seconds during one slskd stall.
fake4 = FakeSlskd(per_query={"Chicago": [
peer(f"p{i}", r"x\Michael Jackson\Xscape\Chicago.flac") for i in range(8)]})
class Timeouts(FakeSlskd):
async def post(self, url, json=None):
if url != "/api/v0/searches":
raise RuntimeError("The wait timed out after 5000 milliseconds")
return await FakeSlskd.post(self, url, json=json)
t = Timeouts(per_query=fake4.per_query)
app.slskd_client = t
make_job("j5", "Michael Jackson", "Chicago")
asyncio.run(app.pick_and_enqueue("j5", "Michael Jackson", "Chicago"))
r = job_row("j5")
check("stops after SYSTEMIC_LIMIT instead of walking the whole list",
r["attempts"] == app.SYSTEMIC_LIMIT, r["attempts"])
left = json.loads(r["candidates"] or "[]")
check("untried candidates are preserved for a retry", len(left) > 0, len(left))
check("the message says a retry will resume",
"retry resumes" in (r["detail"] or ""), r["detail"])
print("\nempty results")
fake3 = FakeSlskd(per_query={})
app.slskd_client = fake3
make_job("j3", "Nobody", "Nothing")
asyncio.run(app.pick_and_enqueue("j3", "Nobody", "Nothing"))
r = job_row("j3")
check("says the search was empty, not that matching failed",
"Soulseek returned nothing" in (r["detail"] or ""), r["detail"])
check("names the queries it tried", "'Nothing'" in (r["detail"] or ""), r["detail"])
print("\ntransient slskd")
class Flaky(FakeSlskd):
def __init__(self):
super().__init__(per_query={"Creep": [peer("z", r"x\Creep.flac")]})
self.n = 0
async def post(self, url, json=None):
if url == "/api/v0/searches":
self.n += 1
if self.n < 3:
raise RuntimeError("") # httpx timeouts stringify to nothing
return await FakeSlskd.post(self, url, json=json)
flaky = Flaky()
app.slskd_client = flaky
make_job("j4", "Radiohead", "Creep")
asyncio.run(app.pick_and_enqueue("j4", "Radiohead", "Creep"))
check("a blip is retried rather than failing the job",
job_row("j4")["state"] == "downloading", job_row("j4")["detail"])
print("\nsearch hygiene")
check("finished searches are cleaned up", len(flaky.deleted) >= 1)
print("\nlength cross-check")
# The signal a filename cannot fake. Every textual cue agreed on the file
# that got filed as Michael Jackson; only the running time disagreed.
class FakeInfo:
def __init__(self, n): self.length = n
class FakeAudio:
def __init__(self, n): self.info = FakeInfo(n)
real_file = mutagen_file = app.mutagen.File
try:
app.mutagen.File = lambda p: FakeAudio(359.0) # The Fall
got = app.length_check(Path("x.flac"), 245 * 1000) # Michael Jackson
check("a 47% length disagreement is caught", got is not None, got)
check("and it drops the file below the review floor",
got and round(0.75 * got[0], 3) < app.REVIEW_CONFIDENCE,
got and round(0.75 * got[0], 3))
check("the reason names both lengths",
got and "5:59" in got[1] and "4:05" in got[1], got and got[1])
app.mutagen.File = lambda p: FakeAudio(247.0)
check("a two-second drift is not flagged",
app.length_check(Path("x.flac"), 245 * 1000) is None)
app.mutagen.File = lambda p: FakeAudio(359.0)
check("nothing to compare against is not a failure",
app.length_check(Path("x.flac"), None) is None)
finally:
app.mutagen.File = mutagen_file
print("\nstall clock")
from datetime import datetime, timedelta, timezone
with app.db() as c:
c.execute("UPDATE jobs SET started_at=? WHERE id=?",
((datetime.now(timezone.utc) - timedelta(minutes=90)).isoformat(),
"j4"))
check("a 90-minute-old transfer reads as stalled",
app.stalled_for(job_row("j4")) > app.STALL_MINUTES,
app.stalled_for(job_row("j4")))
# A row written before started_at existed still has to age out, or the
# jobs that have been stuck longest are the only ones exempt from the check.
with app.db() as c:
c.execute("UPDATE jobs SET started_at=NULL, created_at=? WHERE id=?",
((datetime.now(timezone.utc) - timedelta(days=2))
.isoformat(sep=" ")[:19], "j4"))
check("a pre-upgrade row falls back to created_at",
app.stalled_for(job_row("j4")) > app.STALL_MINUTES,
app.stalled_for(job_row("j4")))
print(f"\n{ok} passed, {fail} failed")
return 1 if fail else 0
if __name__ == "__main__":
sys.exit(main())
+176
View File
@@ -0,0 +1,176 @@
"""Playlist import: link resolution, ordering, and the m3u Navidrome reads.
The resolvers are exercised against synthetic payloads rather than the live
sites. Two reasons: a test that fails when Spotify has a bad afternoon is not
testing this code, and the failure that actually matters here is the *shape*
changing -- which is precisely what a fixture can assert and a live fetch
cannot.
Run standalone: python tests/test_playlist.py
"""
import asyncio, json, os, sys
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("MUSE_AUTH", "off")
os.environ.setdefault("MUSE_DB", "dev/playlist.db")
os.environ.setdefault("MUSE_LIBRARY_DIR", "dev/pl-library")
for f in ("dev/playlist.db", "dev/playlist.db-wal", "dev/playlist.db-shm"):
if os.path.exists(f):
os.remove(f)
import app # noqa: E402
app.init_db()
app.LIBRARY_DIR.mkdir(parents=True, exist_ok=True)
ok = fail = 0
def check(name, cond, detail=""):
global ok, fail
if cond:
ok += 1
print(f" PASS {name}")
else:
fail += 1
print(f" FAIL {name} {detail}")
SPOTIFY_PAGE = (
'<html><script id="__NEXT_DATA__" type="application/json">'
+ json.dumps({"props": {"pageProps": {"state": {"data": {"entity": {
"name": "Late Night",
"trackList": [
{"title": "The Color Violet", "subtitle": "Tory Lanez"},
{"title": "No Pole", "subtitle": "Don Toliver"},
]}}}}}})
+ "</script></html>")
APPLE_PAGE = (
'<html><script id="serialized-server-data" type="application/json">'
+ json.dumps([{"data": {"sections": [
{"headerTitle": "Heavy Rotation"},
{"items": [
{"title": "YUKON", "artistName": "Justin Bieber"},
{"title": "LET THE WORLD BURN", "artistName": "Chris Grey"},
{"title": "YUKON", "artistName": "Justin Bieber"}, # dupe
]}]}}])
+ "</script></html>")
def fake_fetch(body):
async def _f(url):
return body
return _f
def make_playlist(pid, name, rows):
"""rows: (seq, artist, title, state, imported_path)"""
with app.db() as c:
c.execute("INSERT OR REPLACE INTO playlists (id,name,source,"
"source_url,tracks_total) VALUES (?,?,?,?,?)",
(pid, name, "spotify", "https://x", len(rows)))
for seq, artist, title, state, path in rows:
c.execute(
"INSERT INTO jobs (id,state,detail,artist,title,"
"imported_path,playlist_id,playlist_seq) "
"VALUES (?,?,?,?,?,?,?,?)",
(f"{pid}-{seq}", state, "", artist, title, path, pid, seq))
def main() -> int:
print("\nlink routing")
for url, want in [
("https://open.spotify.com/playlist/abc123", "spotify"),
("https://open.spotify.com/intl-pt/playlist/abc123", "spotify"),
("https://music.apple.com/us/playlist/hits/pl.abc-123", "apple"),
("https://www.deezer.com/en/playlist/1313621735", "deezer"),
]:
matched = ("spotify" if app.SPOTIFY_URL.search(url)
else "apple" if app.APPLE_URL.search(url)
else "deezer" if app.DEEZER_URL.search(url) else "none")
check(f"{want} link routes to {want}", matched == want, f"got {matched}")
print("\nresolvers, against a fixed payload shape")
real = app._fetch
try:
app._fetch = fake_fetch(SPOTIFY_PAGE)
name, tracks = asyncio.run(app._spotify_playlist("playlist", "x"))
check("spotify name", name == "Late Night", name)
check("spotify pulls artist from subtitle",
tracks[0] == ("Tory Lanez", "The Color Violet"), tracks[:1])
app._fetch = fake_fetch(APPLE_PAGE)
name, tracks = asyncio.run(app._apple_playlist("playlist", "pl.x", "u"))
check("apple name", name == "Heavy Rotation", name)
check("apple pulls artist from artistName",
("Justin Bieber", "YUKON") in tracks, tracks)
check("apple de-duplicates repeated entries", len(tracks) == 2, tracks)
# The failure that actually matters: they changed the page.
app._fetch = fake_fetch("<html>nothing useful here</html>")
for fn, label in ((app._spotify_playlist, "spotify"),
(app._apple_playlist, "apple")):
try:
asyncio.run(fn("playlist", "x", "u") if label == "apple"
else fn("playlist", "x"))
check(f"{label} shape change is reported", False, "no raise")
except app.PlaylistUnavailable as e:
check(f"{label} shape change names the resolver",
"needs updating" in str(e), str(e))
finally:
app._fetch = real
print("\nthe m3u Navidrome reads")
lib = app.LIBRARY_DIR
(lib / "Tory Lanez").mkdir(parents=True, exist_ok=True)
(lib / "Don Toliver").mkdir(parents=True, exist_ok=True)
a = lib / "Tory Lanez" / "The Color Violet.flac"; a.write_text("x")
b = lib / "Don Toliver" / "No Pole.flac"; b.write_text("x")
make_playlist("p1", "Late Night", [
(1, "Don Toliver", "No Pole", "imported", str(b)),
(0, "Tory Lanez", "The Color Violet", "imported", str(a)),
(2, "Nobody", "Missing", "failed", None),
])
dest = app.write_playlist("p1")
text = dest.read_text()
check("m3u is written", dest and dest.exists(), dest)
check("declares the playlist name for Navidrome",
"#PLAYLIST:Late Night" in text, text)
check("uses Navidrome's mount, not muse's",
"/music/Tory Lanez/The Color Violet.flac" in text
and str(lib) not in text, text)
check("keeps the playlist's own order, not insertion order",
text.index("The Color Violet") < text.index("No Pole"), text)
check("omits tracks that never imported", "Missing" not in text, text)
print("\nedge cases")
make_playlist("p2", "Nothing Landed Yet", [
(0, "A", "B", "searching", None)])
check("no m3u while the playlist is empty",
app.write_playlist("p2") is None)
check("and no stray file left behind",
not (lib / "Nothing Landed Yet.m3u").exists())
make_playlist("p3", "../../escape", [
(0, "Tory Lanez", "The Color Violet", "imported", str(a))])
out = app.write_playlist("p3")
check("a traversing playlist name cannot escape the library",
out is None or out.resolve().is_relative_to(lib.resolve()), out)
# A track approved and then rejected has to leave the file.
with app.db() as c:
c.execute("UPDATE jobs SET state='failed' WHERE id=?", ("p1-0",))
app.write_playlist("p1")
check("rejecting a track drops it from the m3u",
"The Color Violet" not in dest.read_text(), dest.read_text())
print(f"\n{ok} passed, {fail} failed")
return 1 if fail else 0
if __name__ == "__main__":
sys.exit(main())
+32
View File
@@ -0,0 +1,32 @@
"use client";
import Link from "next/link";
export default function Error({ reset }: { error: Error; reset: () => void }) {
return (
<main className="grid min-h-dvh place-items-center px-6 text-center">
<div>
<h1 className="text-display">Something broke</h1>
<p className="mx-auto mt-2 max-w-[46ch] text-body text-dim">
The view failed to render. Your requests are unaffected — they run on the server.
</p>
<div className="mt-6 flex justify-center gap-2">
<button
onClick={reset}
className="focus-ring rounded-control bg-accent px-4 py-2 text-callout
font-semibold text-[var(--accent-ink)]"
>
Try again
</button>
<Link
href="/"
className="focus-ring rounded-control border border-edge-hi px-4 py-2
text-callout font-semibold text-ink"
>
Back to search
</Link>
</div>
</div>
</main>
);
}
+179 -115
View File
@@ -3,40 +3,67 @@
/* ───────────────────────────────────────────────────────────────
muse design system
The accent is the ARTWORK. --a-r/g/b hold the dominant colour sampled
from whatever album is on screen; every tinted surface derives from them,
so the page takes on the palette of the music instead of a fixed brand
colour. JS writes those three numbers and nothing else.
Governing rule: NEUTRAL CHROME, COLOUR IN CONTENT.
The previous system sampled the album artwork and repainted the whole
interface from it. That is the inverse of how a native application behaves
-- Music, Mail and Finder keep their chrome grey and let the artwork be the
only saturated thing on screen -- and it is most of why this read as a
themed web page rather than an app.
So there are two colours now:
--accent fixed. Controls, selection, focus rings. Never sampled.
--art-r/g/b sampled from the cover. Hero washes and now-playing only.
That split also closes a real bug: the focus ring used to be drawn in the
sampled colour, so its contrast against the page depended on which album
you happened to be looking at.
─────────────────────────────────────────────────────────────── */
@theme inline {
--color-accent: rgb(var(--a-r) var(--a-g) var(--a-b));
--color-accent-soft: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.16);
--color-accent-line: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.4);
--color-accent: var(--accent);
--color-accent-soft: var(--accent-soft);
--color-accent-line: var(--accent-line);
--color-accent-ink: var(--accent-ink);
--color-art: rgb(var(--a-r) var(--a-g) var(--a-b));
--color-art-soft: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.16);
--color-ink: var(--fg);
--color-dim: var(--dim);
--color-mute: var(--mute);
--color-edge: var(--edge);
--color-edge-hi: var(--edge-hi);
--color-faint: var(--faint);
/* Opaque surfaces. Content sits on a real surface and vibrancy is reserved
for the sidebar and toolbar; previously `glass` was on everything, which
is why nothing read as layered. */
--color-surface: var(--surface);
--color-surface-2: var(--surface-2);
--color-surface-3: var(--surface-3);
--color-ok: var(--ok);
--color-err: var(--err);
--color-warn: var(--warn);
--radius-glass: 14px;
--radius-panel: 28px;
--radius-control: 11px;
--radius-control: 8px;
--radius-card: 12px;
--radius-panel: 18px;
--radius-sheet: 22px;
--radius-pill: 999px;
--ease-glass: cubic-bezier(0.22, 0.9, 0.28, 1);
/* Apple's motion reads as weight, not duration: things overshoot slightly
and settle. A single shared curve keeps unrelated components feeling like
one physical system. */
/* One shared curve, so unrelated components feel like one physical system. */
--ease-out: cubic-bezier(0.22, 0.9, 0.28, 1);
--ease-spring: linear(
0, 0.006, 0.025, 0.101, 0.539, 0.721, 0.849, 0.937, 0.968, 0.991, 1
);
/* System faces on purpose. next/font self-hosts a webfont, which would make
the Docker build reach out to Google, and `font-src 'self'` in the CSP
blocks a CDN outright. -apple-system resolves to SF Pro on the devices
this is used from, which is the target anyway. */
--font-sans:
-apple-system, BlinkMacSystemFont, "SF Pro Text", ui-sans-serif, system-ui,
"Segoe UI", Roboto, sans-serif;
@@ -45,9 +72,13 @@
system-ui, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
/* Type scale. The defining trait of Apple's typography is that tracking
tightens as size grows — display text at default tracking looks loose and
amateurish, and small text at display tracking looks cramped. */
/* Type scale. Tracking tightens as size grows -- display text at default
tracking looks loose, small text at display tracking looks cramped. */
--text-hero: 2.75rem;
--text-hero--line-height: 1.05;
--text-hero--letter-spacing: -0.038em;
--text-hero--font-weight: 700;
--text-display: 2.125rem;
--text-display--line-height: 1.08;
--text-display--letter-spacing: -0.033em;
@@ -74,88 +105,113 @@
--text-caption: 0.6875rem;
--text-caption--line-height: 1.35;
--text-caption--letter-spacing: 0.005em;
/* Named layout constants. These were arbitrary literals repeated across
files: max-w-[940px] twice, gap-[7px] five times. */
--spacing-row: 7px;
--container-3xl: 1280px;
--sidebar-w: 240px;
--rail-w: 68px;
--toolbar-h: 52px;
}
:root {
color-scheme: light dark;
/* Default artwork tint, used until a cover is sampled. */
--a-r: 120;
--a-g: 140;
--a-b: 255;
--bg: #08090d;
--fg: #f2f4f9;
--dim: #9aa3b6;
--faint: rgb(255 255 255 / 0.07);
--glass: rgb(255 255 255 / 0.055);
--glass-hi: rgb(255 255 255 / 0.085);
--edge: rgb(255 255 255 / 0.1);
--edge-hi: rgb(255 255 255 / 0.17);
--sheet: rgb(16 18 26 / 0.86);
--header: rgb(8 9 13 / 0.62);
--scrim: rgb(8 9 13 / 0.74);
--scrim-2: rgb(8 9 13 / 0.4);
--blur: saturate(180%) blur(24px);
--shadow: 0 10px 40px rgb(0 0 0 / 0.45);
--bg: #0a0b0f;
--surface: #121319;
--surface-2: #181a22;
--surface-3: #20232c;
/* Semantic colours are per-theme, not fixed. These are tuned to sit on the
dark background; the same amber on white fails contrast outright, which
is exactly the text a reviewer most needs to read. */
--ok: #3ddc84;
--err: #ff6b6b;
--warn: #ffc95c;
--fg: #f4f6fb;
--dim: #a0a8bb;
--mute: #6d7488;
--faint: rgb(255 255 255 / 0.06);
--edge: rgb(255 255 255 / 0.09);
--edge-hi: rgb(255 255 255 / 0.16);
/* Vibrancy, for sidebar and toolbar only. */
--material: rgb(18 19 25 / 0.72);
--material-hi: rgb(24 26 34 / 0.86);
--blur: saturate(180%) blur(20px);
--scrim: rgb(10 11 15 / 0.76);
--scrim-2: rgb(10 11 15 / 0.35);
/* Fixed accent. Apple's system blue, which stays legible on both grounds
and is independent of whatever is playing. */
--accent: #0a84ff;
--accent-soft: rgb(10 132 255 / 0.16);
--accent-line: rgb(10 132 255 / 0.45);
--accent-ink: #ffffff;
--ok: #30d158;
--err: #ff453a;
--warn: #ff9f0a;
/* The specular edge. A real frosted panel catches light along its top rim;
without this, blur alone reads as a flat grey rectangle. This one line is
most of the difference between "translucent" and "material". */
--spec: inset 0 1px 0 rgb(255 255 255 / 0.09);
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 0.14);
without this, blur alone reads as a flat grey rectangle. */
--spec: inset 0 1px 0 rgb(255 255 255 / 0.07);
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 0.12);
/* Elevation. Two shadows each: a tight contact shadow that anchors the
element, and a wide soft one for the light. One alone always looks fake. */
--e1: 0 1px 2px rgb(0 0 0 / 0.28), 0 4px 12px rgb(0 0 0 / 0.18);
--e2: 0 2px 6px rgb(0 0 0 / 0.32), 0 12px 32px rgb(0 0 0 / 0.28);
--e3: 0 6px 16px rgb(0 0 0 / 0.36), 0 28px 68px rgb(0 0 0 / 0.42);
element and a wide soft one for the light. One alone always looks fake. */
--e1: 0 1px 2px rgb(0 0 0 / 0.3), 0 4px 12px rgb(0 0 0 / 0.16);
--e2: 0 2px 6px rgb(0 0 0 / 0.34), 0 12px 32px rgb(0 0 0 / 0.26);
--e3: 0 6px 16px rgb(0 0 0 / 0.38), 0 28px 68px rgb(0 0 0 / 0.4);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f4f5f8;
--fg: #0e1116;
--dim: #5a6274;
--faint: rgb(10 14 25 / 0.07);
--glass: rgb(255 255 255 / 0.7);
--glass-hi: rgb(255 255 255 / 0.88);
--edge: rgb(10 14 25 / 0.09);
--edge-hi: rgb(10 14 25 / 0.16);
--sheet: rgb(255 255 255 / 0.95);
--header: rgb(244 245 248 / 0.72);
--scrim: rgb(255 255 255 / 0.78);
--scrim-2: rgb(255 255 255 / 0.5);
--shadow: 0 10px 34px rgb(20 30 60 / 0.13);
/* On light material the specular rim is near-white and the shadows have
to lose most of their weight, or every card looks like it is hovering. */
--spec: inset 0 1px 0 rgb(255 255 255 / 0.9);
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 1);
--e1: 0 1px 2px rgb(20 30 60 / 0.07), 0 4px 12px rgb(20 30 60 / 0.06);
--e2: 0 2px 6px rgb(20 30 60 / 0.08), 0 12px 32px rgb(20 30 60 / 0.09);
--e3: 0 6px 16px rgb(20 30 60 / 0.1), 0 28px 68px rgb(20 30 60 / 0.14);
/* Darkened to clear 4.5:1 on the light background. Same hues, so a status
--bg: #f6f7f9;
--surface: #ffffff;
--surface-2: #f2f3f6;
--surface-3: #e9ebf0;
--fg: #0d1117;
--dim: #596273;
--mute: #858da0;
--faint: rgb(10 14 25 / 0.05);
--edge: rgb(10 14 25 / 0.1);
--edge-hi: rgb(10 14 25 / 0.18);
--material: rgb(246 247 249 / 0.72);
--material-hi: rgb(255 255 255 / 0.88);
--scrim: rgb(255 255 255 / 0.8);
--scrim-2: rgb(255 255 255 / 0.45);
--accent: #0071e3;
--accent-soft: rgb(0 113 227 / 0.12);
--accent-line: rgb(0 113 227 / 0.4);
--accent-ink: #ffffff;
/* Darkened to clear 4.5:1 on the light ground. Same hues, so a status
still reads as the same status when the theme flips. */
--ok: #0a7d43;
--err: #c62828;
--warn: #8a5a00;
/* On light material the specular rim is near-white and the shadows have to
lose most of their weight, or every card looks like it is hovering. */
--spec: inset 0 1px 0 rgb(255 255 255 / 0.9);
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 1);
--e1: 0 1px 2px rgb(20 30 60 / 0.06), 0 4px 12px rgb(20 30 60 / 0.05);
--e2: 0 2px 6px rgb(20 30 60 / 0.07), 0 12px 32px rgb(20 30 60 / 0.08);
--e3: 0 6px 16px rgb(20 30 60 / 0.09), 0 28px 68px rgb(20 30 60 / 0.12);
}
}
@layer base {
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
@@ -167,8 +223,8 @@
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
/* Headings take the display face and its tighter tracking automatically,
so a component never has to remember to ask for it. */
/* Headings take the display face and its tighter tracking automatically, so
a component never has to remember to ask for it. */
h1,
h2,
h3 {
@@ -189,43 +245,48 @@
color: inherit;
}
::selection {
background: var(--color-accent-soft);
background: var(--accent-soft);
}
input[type="search"]::-webkit-search-cancel-button {
display: none;
}
}
@utility glass {
background: var(--glass);
border: 1px solid var(--edge);
/* Vibrancy, for chrome that sits over scrolling content. */
@utility material {
background: var(--material);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
box-shadow: var(--spec);
}
@utility glass-hi {
background: var(--glass-hi);
border: 1px solid var(--edge-hi);
@utility material-hi {
background: var(--material-hi);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
box-shadow: var(--spec-hi);
}
/* An opaque content surface. The default for cards, rows and panels. */
@utility card {
background: var(--surface);
border: 1px solid var(--edge);
box-shadow: var(--spec), var(--e1);
}
@utility sheet {
background: var(--sheet);
background: var(--surface);
border: 1px solid var(--edge-hi);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
box-shadow: var(--spec-hi), var(--e3);
}
/* A card that lifts on hover. The translate is tiny on purpose: Apple's
hover feedback is barely perceptible frame to frame and obvious in
aggregate. Anything larger reads as a web page, not an interface. */
/* A card that lifts on hover. The translate is tiny on purpose: Apple's hover
feedback is barely perceptible frame to frame and obvious in aggregate.
Anything larger reads as a web page, not an interface. */
@utility lift {
transition:
transform 0.28s var(--ease-glass),
box-shadow 0.28s var(--ease-glass),
border-color 0.28s var(--ease-glass),
background 0.28s var(--ease-glass);
transform 0.28s var(--ease-out),
box-shadow 0.28s var(--ease-out),
border-color 0.28s var(--ease-out),
background 0.28s var(--ease-out);
&:hover {
transform: translateY(-2px);
box-shadow: var(--spec-hi), var(--e2);
@@ -237,11 +298,14 @@
}
}
/* Keyboard focus only — a mouse click should never draw a ring. */
@utility ring {
/* ONE focus treatment for the whole app. There used to be two that looked
nothing alike -- an outline utility on ~12 elements and a box-shadow ring on
buttons -- so keyboard focus changed shape depending on what you tabbed to. */
@utility focus-ring {
&:focus-visible {
outline: 2px solid var(--color-accent);
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: inherit;
}
}
@@ -265,12 +329,6 @@
overflow: hidden;
}
/* Very light noise over the gradients — large flat blurs band visibly on
8-bit panels without it. */
@utility grain {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E");
}
@utility no-scrollbar {
scrollbar-width: none;
&::-webkit-scrollbar {
@@ -278,14 +336,10 @@
}
}
input[type="search"]::-webkit-search-cancel-button {
display: none;
}
/* Keyframes live here rather than in a <style> tag inside a component: the
CSP is style-src 'self' with no 'unsafe-inline', so React-rendered <style>
elements are blocked outright. Setting element.style from JS is fine —
CSP governs markup, not the CSSOM — which is why Framer Motion works. */
/* Keyframes live here rather than in a <style> tag inside a component: the CSP
is style-src 'self' with no 'unsafe-inline', so React-rendered <style>
elements are blocked outright. Setting element.style from JS is fine -- CSP
governs markup, not the CSSOM -- which is why Framer Motion works. */
@keyframes shimmer {
to {
background-position: -220% 0;
@@ -295,19 +349,29 @@ input[type="search"]::-webkit-search-cancel-button {
.shimmer {
background-image: linear-gradient(
100deg,
var(--glass) 30%,
var(--glass-hi) 50%,
var(--glass) 70%
var(--surface-2) 30%,
var(--surface-3) 50%,
var(--surface-2) 70%
);
background-size: 220% 100%;
animation: shimmer 1.4s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Framer's animations are JS-driven and ignore this block entirely, which is
why every component also consults useReducedMotion(). This covers the CSS
half: shimmer, spinners, and every utility transition above. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+14 -7
View File
@@ -6,19 +6,26 @@ export const metadata: Metadata = {
description: "Request a song, get it in your library, correctly tagged.",
};
export const viewport: Viewport = {
themeColor: "#08090d",
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
/**
* System fonts on purpose. next/font self-hosts a webfont — fine for the CSP,
* but it makes the Docker build reach out to Google at build time, and
* -apple-system resolves to SF Pro on the devices this is actually used from,
* which is the look being aimed at anyway.
*
* themeColor is a pair rather than a single hex: it paints the browser's own
* chrome on mobile, and a hardcoded dark value left a black status bar sitting
* above a white page for anyone using the light theme.
*/
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#f6f7f9" },
{ media: "(prefers-color-scheme: dark)", color: "#0a0b0f" },
],
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="h-full antialiased">
+26
View File
@@ -0,0 +1,26 @@
import Link from "next/link";
/**
* Without this, a bad path drops the user on Next's unstyled white error page,
* which does not look like it belongs to anything.
*/
export default function NotFound() {
return (
<main className="grid min-h-dvh place-items-center px-6 text-center">
<div>
<p className="text-caption font-semibold uppercase tracking-[0.08em] text-mute">404</p>
<h1 className="mt-2 text-display">That page isn&rsquo;t here</h1>
<p className="mx-auto mt-2 max-w-[42ch] text-body text-dim">
The link may be stale, or the view may have been renamed.
</p>
<Link
href="/"
className="focus-ring mt-6 inline-flex rounded-control bg-accent px-4 py-2
text-callout font-semibold text-[var(--accent-ink)]"
>
Back to search
</Link>
</div>
</main>
);
}
+105 -277
View File
@@ -2,48 +2,35 @@
import * as React from "react";
import { motion } from "framer-motion";
import { ArrowLeft, Disc3, Music2 } from "lucide-react";
import { ArrowLeft, SearchX } from "lucide-react";
import { api, type Me, type Track } from "@/lib/api";
import { tint } from "@/lib/colour";
import { cn } from "@/lib/utils";
import { useNav, type Tab } from "@/lib/nav";
import { panel, useMotionOK } from "@/lib/motion";
import { Aurora } from "@/components/aurora";
import { SearchBar } from "@/components/search-bar";
import { TrackRow } from "@/components/track-row";
import { AlbumView } from "@/components/album-view";
import { ArtistView } from "@/components/artist-view";
import { BulkImport } from "@/components/bulk-import";
import { PlaylistImport } from "@/components/playlist-import";
import { Requests, type JobsSnapshot } from "@/components/requests";
import { Review } from "@/components/review";
import { DiscoverPanel } from "@/components/discover";
import { Settings } from "@/components/settings";
import { Skeletons } from "@/components/skeletons";
import { Welcome } from "@/components/welcome";
import { PlayerProvider } from "@/components/player";
import { ToastProvider } from "@/components/toast";
import { AdminPanel } from "@/components/admin/admin-panel";
type Tab =
| "search"
| "discover"
| "jobs"
| "review"
| "bulk"
| "settings"
| "admin";
type View =
| { k: "welcome" }
| { k: "results"; q: string }
| { k: "album"; id: number }
| { k: "artist"; id: number };
const EXAMPLES = [
"Radiohead Creep",
"Kendrick Lamar Money Trees",
"Fleetwood Mac Dreams",
"Tame Impala The Less I Know The Better",
"Frank Ocean Ivy",
];
import { Sidebar } from "@/components/shell/sidebar";
import { TabBar } from "@/components/shell/tabbar";
import { Toolbar } from "@/components/shell/toolbar";
import { StatusBanner } from "@/components/shell/status-banner";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { SkeletonRows } from "@/components/ui/skeleton";
export default function Page() {
return (
@@ -55,36 +42,32 @@ export default function Page() {
);
}
/** Tailwind's `md`. Decides sidebar-vs-rail, and rail-vs-bottom-bar. */
function useBreakpoint() {
const [w, setW] = React.useState(1280);
React.useEffect(() => {
const on = () => setW(window.innerWidth);
on();
window.addEventListener("resize", on);
return () => window.removeEventListener("resize", on);
}, []);
return { rail: w >= 768 && w < 1024 };
}
function Muse() {
const [tab, setTab] = React.useState<Tab>("search");
const [view, setView] = React.useState<View>({ k: "welcome" });
const [history, setHistory] = React.useState<View[]>([]);
const [nav, go] = useNav();
const { rail } = useBreakpoint();
const ok = useMotionOK();
const [results, setResults] = React.useState<Track[] | null>(null);
const [busy, setBusy] = React.useState(false);
const [me, setMe] = React.useState<Me | null>(null);
const [snap, setSnap] = React.useState<JobsSnapshot | null>(null);
const go = React.useCallback((next: View) => {
setView((cur) => {
setHistory((h) => (cur.k === "welcome" ? h : [...h, cur]));
return next;
});
setTab("search");
window.scrollTo({ top: 0, behavior: "smooth" });
}, []);
const back = () => {
setHistory((h) => {
const prev = h[h.length - 1];
if (prev) setView(prev);
return h.slice(0, -1);
});
};
const doSearch = React.useCallback(
async (q: string) => {
if (!q.trim()) return;
go({ k: "results", q });
go({ tab: "search", q });
setResults(null);
setBusy(true);
try {
@@ -99,8 +82,24 @@ function Muse() {
[go],
);
const openAlbum = React.useCallback((id: number) => go({ k: "album", id }), [go]);
const openArtist = React.useCallback((id: number) => go({ k: "artist", id }), [go]);
// Re-runs the query when the URL carries one, so a shared link and a browser
// Back both restore an actual result set rather than an empty screen.
const lastQ = React.useRef<string | null>(null);
React.useEffect(() => {
if (nav.tab !== "search" || nav.view || !nav.q) return;
if (lastQ.current === nav.q) return;
lastQ.current = nav.q;
void doSearch(nav.q);
}, [nav.tab, nav.view, nav.q, doSearch]);
const openAlbum = React.useCallback(
(id: number) => go({ tab: "search", view: "album", id: String(id), q: nav.q }),
[go, nav.q],
);
const openArtist = React.useCallback(
(id: number) => go({ tab: "search", view: "artist", id: String(id), q: nav.q }),
[go, nav.q],
);
const refresh = React.useCallback(async () => {
try {
@@ -129,191 +128,64 @@ function Muse() {
(snap?.counts.searching ?? 0) +
(snap?.counts.downloading ?? 0);
const held = snap?.counts.review ?? 0;
const isAdmin = me?.role === "admin";
// Only worth showing when a limit actually exists — a permanent "0 / 0" chip
// is noise on a single-user install, which is most of them.
const quotaLeft =
me?.quota && me.usage ? Math.max(0, me.quota - me.usage.requests) : null;
const badges: Partial<Record<Tab, number>> = { jobs: active, review: held };
// Review appears exactly when something is waiting on you; an always-present
// tab that is empty nine days in ten is chrome.
const visible = (t: Tab) =>
t === "admin" ? !!isAdmin : t === "review" ? held > 0 || nav.tab === "review" : true;
const onBack = nav.view
? () => go({ tab: "search", q: nav.q })
: null;
return (
<>
<Aurora />
<div className="flex min-h-dvh">
<Sidebar nav={nav} go={go} badges={badges} rail={rail} visible={visible} />
<header
className="sticky top-0 z-40 border-b border-edge bg-[var(--header)] pt-3.5
backdrop-blur-[22px] backdrop-saturate-[1.7]"
>
<div className="mx-auto w-full max-w-[940px] px-4">
<div className="mb-3.5 flex items-center gap-3">
<motion.div
whileHover={{ rotate: 12, scale: 1.06 }}
transition={{ type: "spring", stiffness: 400, damping: 15 }}
className="grid h-[38px] w-[38px] shrink-0 place-items-center rounded-xl
border border-accent-line bg-accent-soft text-accent"
>
<Music2 className="h-5 w-5" />
</motion.div>
<div className="min-w-0 flex-1">
<h1 className="text-title leading-none">muse</h1>
<p className="mt-0.5 text-caption text-dim">
search · request · it appears in Navidrome
</p>
</div>
{me?.user && (
<div className="flex items-center gap-2 text-callout text-dim">
{quotaLeft !== null && (
<span
title={`${me.usage!.requests} of ${me.quota} requests used this month`}
className={cn(
"hidden rounded-pill border px-2 py-0.5 text-caption nums sm:inline",
quotaLeft === 0
? "border-err/50 text-err"
: quotaLeft <= 5
? "border-warn/50 text-warn"
: "border-edge text-dim",
)}
>
{quotaLeft} left
</span>
)}
{me.avatar && (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={me.avatar}
alt=""
className="h-6 w-6 rounded-full border border-edge"
/>
)}
<span className="hidden font-medium text-ink sm:inline">
{me.display || me.user}
</span>
{me.role && me.role !== "user" && (
<span
className="rounded-pill border border-accent-line bg-accent-soft px-1.5
py-0.5 text-caption uppercase tracking-[0.6px] text-accent"
>
{me.role}
</span>
)}
<a
href="/auth/logout"
className="ring rounded-md px-1 transition-colors hover:text-ink"
>
Log out
</a>
</div>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col">
<Toolbar me={me} quotaLeft={quotaLeft}>
<SearchBar initial={nav.q} busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} />
</Toolbar>
<StatusBanner />
<SearchBar busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} />
{/* A sliding pill rather than an underline: the moving surface is
the selection itself, so the eye tracks one object across the
row instead of watching two things fade. layoutId does the
interpolation, so it stays correct when tabs appear or vanish
with a role change. */}
{/* The mask is the scroll affordance: on a narrow screen the last
tab fades out at the edge instead of being clipped flat, which
is the only cue that there is more to the right. */}
<nav
className="flex gap-0.5 overflow-x-auto pb-3 pt-2.5 no-scrollbar
[mask-image:linear-gradient(to_right,#000_calc(100%-28px),transparent)]
sm:[mask-image:none]"
>
{(
[
["search", "Search", 0],
["discover", "Discover", 0],
["jobs", "Requests", active],
// Only exists when it is actionable. An always-present tab
// that is empty nine days in ten is chrome; this one appears
// exactly when something is waiting on you.
...(held > 0 || tab === "review"
? ([["review", "Review", held]] as [Tab, string, number][])
: []),
["bulk", "Import", 0],
["settings", "Settings", 0],
...(isAdmin ? ([["admin", "Admin", 0]] as [Tab, string, number][]) : []),
] as [Tab, string, number][]
).map(([k, label, badge]) => (
<button
key={k}
onClick={() => setTab(k)}
aria-current={tab === k ? "page" : undefined}
className={cn(
"ring relative shrink-0 cursor-pointer rounded-pill px-3 py-1.5",
"text-callout font-medium transition-colors duration-200",
tab === k ? "text-ink" : "text-dim hover:text-ink",
)}
>
{tab === k && (
<motion.span
layoutId="tab-pill"
className="absolute inset-0 rounded-pill border border-edge-hi bg-[var(--glass-hi)]"
transition={{ type: "spring", stiffness: 460, damping: 38 }}
/>
)}
<span className="relative flex items-center gap-1.5">
{label}
{badge > 0 && (
<span
className={cn(
"min-w-[17px] rounded-pill px-1 text-caption font-bold nums",
k === "review"
? "bg-warn/20 text-warn"
: "bg-accent-soft text-accent",
)}
>
{badge}
</span>
)}
</span>
</button>
))}
</nav>
</div>
</header>
<main className="mx-auto w-full max-w-[940px] px-4 pb-32 pt-5">
{/* Keyed on `tab`, so React remounts and Framer replays the entrance.
Deliberately NOT wrapped in AnimatePresence mode="wait": that holds
the incoming panel until the outgoing one's exit animation reports
finished, which makes navigation depend on an animation completing.
Anything that stalls the frame loop — a backgrounded tab, a browser
that throttles rAF — then leaves the UI stuck on the old panel with
no way forward. A cosmetic transition should never gate navigation. */}
<motion.div
key={tab}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22 }}
>
{tab === "search" && (
<main className="mx-auto w-full max-w-[var(--container-3xl)] flex-1 px-4 pb-28 pt-5 md:pb-16">
<motion.div key={nav.tab + (nav.view ?? "") + (nav.id ?? "")} {...panel(ok)}>
{nav.tab === "search" && (
<>
{history.length > 0 && (
<motion.button
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
onClick={back}
className="glass mb-3.5 flex cursor-pointer items-center gap-1.5 rounded-full
px-3 py-1.5 text-[13px] text-dim transition hover:border-edge-hi hover:text-ink"
>
{onBack && (
<Button variant="quiet" size="sm" onClick={onBack} className="mb-3 -ml-2">
<ArrowLeft className="h-3.5 w-3.5" /> Back
</motion.button>
</Button>
)}
{view.k === "welcome" && <Welcome onPick={doSearch} />}
{nav.view === "album" && nav.id && (
<AlbumView id={Number(nav.id)} onArtist={openArtist} onQueued={refresh} />
)}
{nav.view === "artist" && nav.id && (
<ArtistView id={Number(nav.id)} onAlbum={openAlbum} onQueued={refresh} />
)}
{view.k === "results" &&
{!nav.view && !nav.q && <Welcome onPick={doSearch} recent={snap?.jobs ?? []} />}
{!nav.view &&
nav.q &&
(results === null ? (
<Skeletons />
<SkeletonRows n={6} />
) : results.length === 0 ? (
<div className="py-12 text-center text-dim">
No matches. Try adding the artist name.
</div>
<EmptyState
icon={SearchX}
title="No matches"
body="Try adding the artist name, or fewer words."
/>
) : (
<motion.div layout className="flex flex-col gap-[7px]">
<div className="flex flex-col gap-row">
{results.map((t, i) => (
<TrackRow
key={t.deezer_id}
@@ -324,71 +196,27 @@ function Muse() {
onQueued={refresh}
/>
))}
</motion.div>
</div>
))}
{view.k === "album" && (
<AlbumView id={view.id} onArtist={openArtist} onQueued={refresh} />
)}
{view.k === "artist" && (
<ArtistView id={view.id} onAlbum={openAlbum} onQueued={refresh} />
)}
</>
)}
{tab === "discover" && <DiscoverPanel onArtist={doSearch} />}
{tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
{tab === "review" && <Review onDecided={refresh} />}
{tab === "bulk" && <BulkImport onQueued={refresh} />}
{tab === "settings" && me && <Settings me={me} refresh={refresh} />}
{tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
</motion.div>
</main>
</>
);
}
function Welcome({ onPick }: { onPick: (q: string) => void }) {
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 260, damping: 28 }}
className="px-5 py-14 text-center"
>
<motion.div
animate={{ y: [0, -7, 0] }}
transition={{ duration: 4.5, repeat: Infinity, ease: "easeInOut" }}
className="mx-auto mb-4 grid h-16 w-16 place-items-center rounded-[20px]
border border-accent-line bg-accent-soft text-accent"
>
<Disc3 className="h-7 w-7" />
</motion.div>
<h2 className="mb-2 text-display">Find anything</h2>
<p className="mx-auto max-w-[46ch] text-body text-dim">
Search a song and press{" "}
<kbd className="rounded-md border border-edge-hi bg-[var(--glass-hi)] px-1.5 py-0.5 text-caption text-ink">
Enter
</kbd>
. Hit play to hear a 30-second preview before you commit, then <b>Get</b> to pull it into
your library, tagged.
</p>
<div className="mt-5 flex flex-wrap justify-center gap-2">
{EXAMPLES.map((e, i) => (
<motion.button
key={e}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + i * 0.05 }}
whileHover={{ y: -2 }}
onClick={() => onPick(e)}
className="glass ring cursor-pointer rounded-pill px-3.5 py-1.5 text-callout text-dim
transition hover:border-accent-line hover:bg-accent-soft hover:text-ink"
>
{e}
</motion.button>
))}
{nav.tab === "discover" && <DiscoverPanel onArtist={doSearch} />}
{nav.tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
{nav.tab === "review" && <Review onDecided={refresh} />}
{nav.tab === "bulk" && (
<div className="flex flex-col gap-4">
<PlaylistImport onQueued={refresh} />
<BulkImport onQueued={refresh} />
</div>
)}
{nav.tab === "settings" && me && <Settings me={me} refresh={refresh} />}
{nav.tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
</motion.div>
</main>
</div>
</motion.div>
<TabBar nav={nav} go={go} badges={badges} visible={visible} />
</div>
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export function ActivityChart({ data }: { data: Activity }) {
);
return (
<div className="glass rounded-panel p-5">
<div className="card rounded-panel p-5">
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
<div>
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
+4 -4
View File
@@ -7,7 +7,7 @@ import { api, bytes, type Activity, type AdminUser, type SystemStats } from "@/l
import { StatCard } from "@/components/admin/stat-card";
import { ActivityChart } from "@/components/admin/activity-chart";
import { UsersTable } from "@/components/admin/users-table";
import { Skeletons } from "@/components/skeletons";
import { SkeletonCards } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
export function AdminPanel({ me }: { me: string | null }) {
@@ -32,7 +32,7 @@ export function AdminPanel({ me }: { me: string | null }) {
}, [loadUsers]);
if (err) return <div className="py-12 text-center text-dim">{err}</div>;
if (!sys || !users) return <Skeletons n={6} />;
if (!sys || !users) return <SkeletonCards n={4} />;
const s = sys.system;
const lib = s.disks.library;
@@ -112,7 +112,7 @@ function SlskdBanner({ slskd }: { slskd: SystemStats["slskd"] }) {
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
className={cn(
"glass flex items-center gap-2.5 rounded-glass px-4 py-2.5 text-callout",
"card flex items-center gap-2.5 rounded-card px-4 py-2.5 text-callout",
healthy ? "border-ok/30" : "border-err/40",
)}
>
@@ -141,7 +141,7 @@ function Breakdown({
}) {
const max = Math.max(1, ...rows.map((r) => r.n));
return (
<div className="glass rounded-panel p-5">
<div className="card rounded-panel p-5">
<h3 className="mb-3 text-callout font-bold uppercase tracking-[0.9px] text-dim">{title}</h3>
<div className="flex flex-col gap-2">
{rows.slice(0, 8).map((r, i) => (
+1 -1
View File
@@ -26,7 +26,7 @@ export function StatCard({
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(index * 0.04, 0.4), type: "spring", stiffness: 320, damping: 30 }}
className="glass rounded-glass p-3.5"
className="card rounded-card p-3.5"
>
<div className="text-caption uppercase tracking-[0.6px] text-dim">{label}</div>
<div className="mt-0.5 text-[22px] font-bold leading-tight tracking-[-0.6px]">
+5 -5
View File
@@ -22,7 +22,7 @@ export function UsersTable({
const [adding, setAdding] = React.useState(false);
return (
<div className="glass rounded-panel p-5">
<div className="card rounded-panel p-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
@@ -35,7 +35,7 @@ export function UsersTable({
anyone in — the row is.
</p>
</div>
<Button size="pill" variant={adding ? "ghost" : "primary"} onClick={() => setAdding(!adding)}>
<Button size="pill" variant={adding ? "secondary" : "primary"} onClick={() => setAdding(!adding)}>
{adding ? <X className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{adding ? "Cancel" : "Invite"}
</Button>
@@ -101,7 +101,7 @@ function UserRow({
exit={{ opacity: 0, scale: 0.98 }}
transition={{ type: "spring", stiffness: 360, damping: 32 }}
className={cn(
"glass-hi rounded-glass p-3",
"card rounded-card p-3",
u.disabled && "opacity-60",
)}
>
@@ -324,7 +324,7 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
};
const input =
"rounded-glass border border-edge-hi bg-transparent px-3 py-2 text-callout " +
"rounded-card border border-edge-hi bg-transparent px-3 py-2 text-callout " +
"text-ink outline-none focus:border-accent-line";
return (
@@ -334,7 +334,7 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<div className="glass-hi mt-1 flex flex-wrap items-end gap-2.5 rounded-glass p-3">
<div className="card mt-1 flex flex-wrap items-end gap-2.5 rounded-card p-3">
<label className="flex min-w-[210px] flex-1 flex-col gap-1">
<span className="text-caption text-dim">
{provider === "github" ? "GitHub username" : "Google email"}
+46 -24
View File
@@ -1,14 +1,15 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { api, mmss, type AlbumHeader, type Track } from "@/lib/api";
import { tint } from "@/lib/colour";
import { Hero } from "@/components/hero";
import { TrackRow } from "@/components/track-row";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/toast";
import { Skeletons } from "@/components/skeletons";
import { SkeletonRows } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { AlertCircle, Disc } from "lucide-react";
export function AlbumView({
id,
@@ -43,8 +44,16 @@ export function AlbumView({
};
}, [id]);
if (err) return <div className="py-12 text-center text-dim">Could not load album: {err}</div>;
if (!data) return <Skeletons n={8} />;
if (err)
return (
<EmptyState
icon={AlertCircle}
tone="err"
title="Could not load album"
body={err}
/>
);
if (!data) return <SkeletonRows n={8} />;
const { album, tracks } = data;
@@ -88,29 +97,42 @@ export function AlbumView({
album.duration ? mmss(album.duration * 1000) : null,
]}
actions={
<Button onClick={getAll} disabled={batch !== "idle"}>
{batch === "idle"
? `Get all ${tracks.length} tracks`
: batch === "busy"
? "Queueing…"
: `Queued ${queued}`}
</Button>
// Hidden rather than rendered as "Get all 0 tracks": Deezer serves
// metadata for albums whose track list it has no data for, and an
// enabled button that queues nothing is worse than no button.
tracks.length > 0 ? (
<Button onClick={getAll} disabled={batch !== "idle"}>
{batch === "idle"
? `Get all ${tracks.length} tracks`
: batch === "busy"
? "Queueing…"
: `Queued ${queued}`}
</Button>
) : null
}
/>
<motion.div layout className="flex flex-col gap-[7px]">
{tracks.map((t, i) => (
<TrackRow
key={t.deezer_id}
track={t}
index={i}
showNumber
showArtist={false}
showAlbum={false}
onQueued={onQueued}
/>
))}
</motion.div>
{tracks.length === 0 ? (
<EmptyState
icon={Disc}
title="Deezer has no track list for this release"
body="Its catalogue has the album but not its songs. Search for a track by name instead."
/>
) : (
<div className="flex flex-col gap-row">
{tracks.map((t, i) => (
<TrackRow
key={t.deezer_id}
track={t}
index={i}
showNumber
showArtist={false}
showAlbum={false}
onQueued={onQueued}
/>
))}
</div>
)}
</>
);
}
+3 -3
View File
@@ -6,7 +6,7 @@ import { api, type AlbumCard, type ArtistHeader, type Track } from "@/lib/api";
import { tint } from "@/lib/colour";
import { Hero } from "@/components/hero";
import { TrackRow } from "@/components/track-row";
import { Skeletons } from "@/components/skeletons";
import { SkeletonRows } from "@/components/ui/skeleton";
export function ArtistView({
id,
@@ -42,7 +42,7 @@ export function ArtistView({
}, [id]);
if (err) return <div className="py-12 text-center text-dim">Could not load artist: {err}</div>;
if (!data) return <Skeletons n={8} />;
if (!data) return <SkeletonRows n={8} />;
const { artist, tracks, albums } = data;
@@ -60,7 +60,7 @@ export function ArtistView({
/>
<SectionHead>Top tracks</SectionHead>
<motion.div layout className="flex flex-col gap-[7px]">
<motion.div layout className="flex flex-col gap-row">
{tracks.map((t, i) => (
<TrackRow
key={t.deezer_id}
-42
View File
@@ -1,42 +0,0 @@
"use client";
import { motion } from "framer-motion";
/**
* Colour-reactive backdrop. Three blurred blobs tinted from --a-r/g/b, which
* the artwork sets, so the page takes on the palette of whatever is on screen.
*
* Kept faint on purpose: at full strength the gradient competes with the album
* art it is derived from, and the glass rows lose contrast against it.
*/
export function Aurora() {
return (
<>
<div className="fixed inset-0 -z-20 overflow-hidden pointer-events-none">
<motion.i
className="absolute block rounded-full bg-accent opacity-30 blur-[110px]
w-[60vw] h-[60vw] -top-[22vw] -left-[12vw]"
animate={{ x: ["0vw", "8vw", "0vw"], y: ["0vh", "6vh", "0vh"], scale: [1, 1.15, 1] }}
transition={{ duration: 26, repeat: Infinity, ease: "easeInOut" }}
/>
<motion.i
className="absolute block rounded-full opacity-30 blur-[110px]
w-[46vw] h-[46vw] top-[18vh] -right-[14vw]
bg-[rgb(var(--a-r)_var(--a-g)_var(--a-b)/0.75)]"
animate={{ x: ["0vw", "-7vw", "0vw"], y: ["0vh", "10vh", "0vh"], scale: [1, 0.88, 1] }}
transition={{ duration: 32, repeat: Infinity, ease: "easeInOut" }}
/>
{/* Channel-rotated so the palette has range rather than one flat wash,
but faint — a full-strength complement fights the artwork. */}
<motion.i
className="absolute block rounded-full opacity-[0.14] blur-[110px]
w-[52vw] h-[52vw] -bottom-[24vw] left-[22vw]
bg-[rgb(var(--a-b)_var(--a-r)_var(--a-g)/0.7)]"
animate={{ x: ["0vw", "5vw", "0vw"], y: ["0vh", "-8vh", "0vh"], scale: [1, 1.1, 1] }}
transition={{ duration: 38, repeat: Infinity, ease: "easeInOut" }}
/>
</div>
<div className="grain fixed inset-0 -z-10 pointer-events-none opacity-[0.16]" />
</>
);
}
+2 -2
View File
@@ -47,7 +47,7 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) {
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="glass rounded-panel p-5"
className="card rounded-panel p-5"
>
<h2 className="mb-1 text-headline font-bold tracking-[-0.3px]">Import a list</h2>
<p className="mb-3.5 max-w-[62ch] text-callout text-dim">
@@ -61,7 +61,7 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) {
onChange={(e) => setText(e.target.value)}
spellCheck={false}
placeholder={PLACEHOLDER}
className="min-h-[170px] w-full resize-y rounded-glass border border-edge
className="min-h-[170px] w-full resize-y rounded-card border border-edge
bg-black/20 dark:bg-black/20 p-3.5 font-mono text-[13px] leading-[1.7]
text-ink outline-none transition
placeholder:text-dim/65
+4 -4
View File
@@ -5,7 +5,7 @@ import { motion } from "framer-motion";
import { CalendarDays, Compass, Sparkles, User } from "lucide-react";
import { api, bytes, type Discover as DiscoverData, type Recap } from "@/lib/api";
import { Skeletons } from "@/components/skeletons";
import { SkeletonGrid } from "@/components/ui/skeleton";
/**
* Recommendations and a personal recap, both derived from muse's OWN request
@@ -24,7 +24,7 @@ export function DiscoverPanel({ onArtist }: { onArtist: (name: string) => void }
api.recap().then(setR).catch(() => undefined);
}, []);
if (!d) return <Skeletons n={4} />;
if (!d) return <SkeletonGrid n={8} />;
const empty = d.artists.length === 0 && (!r || r.tracks === 0);
if (empty)
@@ -67,7 +67,7 @@ export function DiscoverPanel({ onArtist }: { onArtist: (name: string) => void }
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(i * 0.03, 0.3) }}
onClick={() => onArtist(a.name)}
className="glass lift ring group cursor-pointer rounded-panel p-3 text-center"
className="card lift focus-ring group cursor-pointer rounded-panel p-3 text-center"
>
{a.picture ? (
/* eslint-disable-next-line @next/next/no-img-element */
@@ -175,7 +175,7 @@ function Card({ title, children }: { title: string; children: React.ReactNode })
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="glass rounded-panel p-4"
className="card rounded-panel p-4"
>
<h3 className="mb-2.5 text-caption font-bold uppercase tracking-[0.9px] text-dim">
{title}
+2 -2
View File
@@ -31,7 +31,7 @@ export function Hero({
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className="glass relative mb-5 flex flex-col sm:flex-row items-center sm:items-stretch
className="card relative mb-5 flex flex-col sm:flex-row items-center sm:items-stretch
gap-4 sm:gap-5 overflow-hidden rounded-panel p-5 text-center sm:text-left"
>
{art && (
@@ -72,7 +72,7 @@ export function Hero({
{facts.filter(Boolean).map((f, i) => (
<span
key={i}
className="glass-hi rounded-full px-2.5 py-0.5 text-caption text-dim"
className="card rounded-full px-2.5 py-0.5 text-caption text-dim"
>
{f}
</span>
+164
View File
@@ -0,0 +1,164 @@
"use client";
import * as React from "react";
import { CheckCircle2, CircleSlash, Loader2, XCircle } from "lucide-react";
import type { Job } from "@/lib/api";
import { api } from "@/lib/api";
import { Sheet } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { useToast } from "@/components/toast";
/**
* Why a request is where it is.
*
* Previously a failure was a red word in a list plus a three-second toast, and
* the reason was raw text — "transfer Completed, Errored" — joined with a dot
* after the album name in 13px dim type. Since the backend now walks a ladder
* of candidates instead of giving up on the first peer, there is an actual
* story to tell: who was tried, what each one said, and what is left.
*/
export function JobSheet({
job,
onClose,
onChanged,
}: {
job: Job | null;
onClose: () => void;
onChanged: () => void;
}) {
const toast = useToast();
const [busy, setBusy] = React.useState(false);
const retry = async () => {
if (!job) return;
setBusy(true);
try {
await api.retry(job.id);
toast("Requeued");
onChanged();
onClose();
} catch (e) {
toast((e as Error).message, true);
}
setBusy(false);
};
const conf = job?.confidence;
return (
<Sheet
open={!!job}
onOpenChange={(v) => !v && onClose()}
title={job ? job.title : ""}
description={job ? [job.artist, job.album].filter(Boolean).join(" · ") : undefined}
footer={
job?.state === "failed" ? (
<Button onClick={retry} disabled={busy}>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null} Try again
</Button>
) : null
}
>
{job && (
<div className="flex flex-col gap-5">
<div className="flex items-center gap-2">
<StatePill state={job.state} />
{job.attempts > 0 && (
<span className="text-caption text-mute nums">
{job.attempts} peer{job.attempts === 1 ? "" : "s"} tried
{job.state !== "failed" && job.remaining > 0
? ` · ${job.remaining} left to try`
: ""}
</span>
)}
</div>
{job.detail && (
<p className="text-callout text-dim">{job.detail}</p>
)}
{conf != null && (
<section>
<div className="mb-1.5 flex items-baseline justify-between">
<h3 className="text-callout font-medium">Match confidence</h3>
<span className="text-callout text-dim nums">{Math.round(conf * 100)}%</span>
</div>
<Progress
value={conf * 100}
tone={conf >= 0.95 ? "ok" : conf >= 0.7 ? "warn" : "err"}
/>
{job.match_why && (
<p className="mt-1.5 text-caption text-mute">{job.match_why}</p>
)}
</section>
)}
{job.remote_file && (
<section>
<h3 className="mb-1.5 text-callout font-medium">File</h3>
<p className="break-all rounded-control bg-[var(--surface-2)] p-2.5 font-mono text-caption text-dim">
{job.remote_file.replace(/\\/g, "/")}
</p>
</section>
)}
{job.tried.length > 0 && (
<section>
<h3 className="mb-2 text-callout font-medium">
Peers tried
</h3>
<ol className="flex flex-col gap-1.5">
{job.tried.map((t, i) => (
<li
key={`${t.username}-${i}`}
className="flex items-start gap-2 rounded-control border border-edge
bg-[var(--surface-2)] px-2.5 py-2"
>
<CircleSlash className="mt-0.5 h-3.5 w-3.5 shrink-0 text-err" />
<div className="min-w-0">
<div className="text-callout font-medium">{t.username}</div>
<div className="text-caption text-dim">{t.reason}</div>
</div>
</li>
))}
{job.state === "downloading" && job.username && (
<li
className="flex items-start gap-2 rounded-control border border-accent-line
bg-accent-soft px-2.5 py-2"
>
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
<div className="min-w-0">
<div className="text-callout font-medium">{job.username}</div>
<div className="text-caption text-dim">downloading now</div>
</div>
</li>
)}
</ol>
</section>
)}
</div>
)}
</Sheet>
);
}
function StatePill({ state }: { state: Job["state"] }) {
const map = {
imported: { tone: "ok", icon: CheckCircle2, label: "In your library" },
failed: { tone: "err", icon: XCircle, label: "Failed" },
review: { tone: "accent", icon: CheckCircle2, label: "Waiting on you" },
downloading: { tone: "warn", icon: Loader2, label: "Downloading" },
searching: { tone: "warn", icon: Loader2, label: "Searching Soulseek" },
queued: { tone: "neutral", icon: Loader2, label: "Queued" },
} as const;
const m = map[state];
const Icon = m.icon;
return (
<Badge tone={m.tone}>
<Icon className={`h-3 w-3 ${state === "imported" || state === "failed" ? "" : "animate-spin"}`} />
{m.label}
</Badge>
);
}
+1 -1
View File
@@ -134,7 +134,7 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
<button
onClick={stop}
aria-label="Stop preview"
className="w-8 h-8 shrink-0 grid place-items-center rounded-full glass-hi
className="w-8 h-8 shrink-0 grid place-items-center rounded-full card
text-ink cursor-pointer transition hover:border-accent-line
hover:text-accent"
>
+204
View File
@@ -0,0 +1,204 @@
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Link2, ListMusic, Loader2, CheckCircle2 } from "lucide-react";
import { api, type PlaylistPreview, type PlaylistRow } from "@/lib/api";
import { useToast } from "@/components/toast";
import { Button } from "@/components/ui/button";
import { Card, CardTitle, CardNote } from "@/components/ui/card";
import { Input } from "@/components/ui/field";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { useMotionOK, item } from "@/lib/motion";
const SOURCE: Record<string, string> = {
spotify: "Spotify",
apple: "Apple Music",
deezer: "Deezer",
};
/**
* Paste a shared link, get the playlist.
*
* Resolve is deliberately split from queue. A shared link is opaque — you
* cannot tell twelve tracks from four hundred by looking at it — and four
* hundred is a quota decision, so the tracks are shown before anything is
* committed.
*/
export function PlaylistImport({ onQueued }: { onQueued?: () => void }) {
const toast = useToast();
const ok = useMotionOK();
const [url, setUrl] = React.useState("");
const [name, setName] = React.useState("");
const [preview, setPreview] = React.useState<PlaylistPreview | null>(null);
const [busy, setBusy] = React.useState<"idle" | "resolving" | "queueing">("idle");
const [rows, setRows] = React.useState<PlaylistRow[] | null>(null);
// Not async, and resolving through .then: setState then happens in a promise
// callback rather than synchronously inside the effect, which is both what
// React wants and what the rest of the polling components here already do.
const refresh = React.useCallback(() => {
api
.playlists()
.then((d) => setRows(d.playlists))
.catch(() => {
/* a 401 here just means the poll outlived the session */
});
}, []);
React.useEffect(() => {
refresh();
const t = setInterval(refresh, 5000);
return () => clearInterval(t);
}, [refresh]);
const resolve = async () => {
if (!url.trim()) return toast("Paste a playlist link first", true);
setBusy("resolving");
setPreview(null);
try {
const d = await api.playlistPreview(url.trim());
setPreview(d);
setName(d.name);
} catch (e) {
toast((e as Error).message, true);
}
setBusy("idle");
};
const queue = async () => {
setBusy("queueing");
try {
const d = await api.playlistImport(url.trim(), name.trim() || undefined);
toast(
`Queued ${d.queued.length} of ${d.queued.length + d.skipped.length} from ${d.name}`,
);
setUrl("");
setPreview(null);
onQueued?.();
refresh();
} catch (e) {
toast((e as Error).message, true);
}
setBusy("idle");
};
return (
<div className="flex flex-col gap-4">
<Card pad="lg">
<CardTitle>Import a shared playlist</CardTitle>
<CardNote className="mt-1 mb-3.5 max-w-[64ch]">
Paste a link from Spotify, Apple Music or Deezer. Every track is matched on
Deezer and queued like any other request, and the playlist itself appears in
Navidrome — growing as tracks arrive rather than all at once.
</CardNote>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && busy === "idle" && resolve()}
placeholder="https://open.spotify.com/playlist/…"
aria-label="Playlist link"
inputMode="url"
/>
<Button onClick={resolve} disabled={busy !== "idle"} className="shrink-0">
{busy === "resolving" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Link2 className="h-3.5 w-3.5" />}
Resolve
</Button>
</div>
<AnimatePresence>
{preview && (
<motion.div
initial={ok ? { opacity: 0, height: 0 } : false}
animate={{ opacity: 1, height: "auto" }}
exit={ok ? { opacity: 0, height: 0 } : { opacity: 0 }}
className="overflow-hidden"
>
<div className="mt-4 rounded-card border border-edge bg-[var(--surface-2)] p-3.5">
<div className="mb-2.5 flex flex-wrap items-center gap-2">
<Badge tone="accent">{SOURCE[preview.source] ?? preview.source}</Badge>
<span className="text-callout text-dim nums">
{preview.count} track{preview.count === 1 ? "" : "s"}
</span>
</div>
<label className="mb-1 block text-caption font-medium text-dim">
Name in Navidrome
</label>
<Input value={name} onChange={(e) => setName(e.target.value)} className="mb-3" />
<ol className="mb-3 max-h-52 overflow-y-auto text-callout">
{preview.tracks.map((t, i) => (
<li key={i} className="flex gap-2 border-b border-edge/60 py-1 last:border-0">
<span className="w-6 shrink-0 text-right text-mute nums">{i + 1}</span>
<span className="clamp1">
{t.title} <span className="text-dim">· {t.artist}</span>
</span>
</li>
))}
{preview.count > preview.tracks.length && (
<li className="py-1 text-caption text-mute">
…and {preview.count - preview.tracks.length} more
</li>
)}
</ol>
<Button onClick={queue} disabled={busy !== "idle"}>
{busy === "queueing" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
Queue all {preview.count}
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</Card>
{rows && rows.length > 0 && (
<Card pad="lg">
<CardTitle className="mb-3">Playlists</CardTitle>
<div className="flex flex-col gap-2.5">
{rows.map((p, i) => {
const total = p.tracks_total || 1;
const settled = p.done + p.failed;
return (
<motion.div
key={p.id}
{...item(i, ok)}
className="rounded-card border border-edge bg-[var(--surface-2)] p-3"
>
<div className="mb-1.5 flex items-center gap-2">
<ListMusic className="h-3.5 w-3.5 shrink-0 text-mute" />
<span className="min-w-0 flex-1 text-body font-semibold clamp1">{p.name}</span>
<Badge tone="neutral">{SOURCE[p.source] ?? p.source}</Badge>
</div>
<Progress
value={(p.done / total) * 100}
tone={p.failed && settled >= total ? "warn" : "accent"}
size="sm"
label={`${p.name} progress`}
/>
<div className="mt-1.5 flex flex-wrap gap-x-3 text-caption text-dim nums">
<span className="text-ok">{p.done} in library</span>
{p.pending > 0 && <span>{p.pending} in flight</span>}
{p.review > 0 && <span className="text-warn">{p.review} to review</span>}
{p.failed > 0 && <span className="text-err">{p.failed} failed</span>}
<span className="text-mute">of {p.tracks_total}</span>
</div>
{p.m3u_path && (
<div className="mt-1 flex items-center gap-1 text-caption text-mute">
<CheckCircle2 className="h-3 w-3 text-ok" />
in Navidrome with {p.tracks_written} track
{p.tracks_written === 1 ? "" : "s"}
</div>
)}
</motion.div>
);
})}
</div>
</Card>
)}
</div>
);
}
+103 -110
View File
@@ -2,9 +2,14 @@
import * as React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { api, type Job } from "@/lib/api";
import { useToast } from "@/components/toast";
import { Button } from "@/components/ui/button";
import { Inbox, ChevronRight } from "lucide-react";
import { type Job } from "@/lib/api";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { EmptyState } from "@/components/ui/empty-state";
import { JobSheet } from "@/components/job-sheet";
import { useMotionOK, spring } from "@/lib/motion";
import { cn } from "@/lib/utils";
export type JobsSnapshot = {
@@ -13,142 +18,130 @@ export type JobsSnapshot = {
pending: number;
};
const PILL: Record<Job["state"], string> = {
imported: "text-ok border-ok/40 bg-ok/10",
failed: "text-err border-err/40 bg-err/10",
// Held for review, not in flight and not finished — it needs a person, so
// it reads as the accent rather than as another in-progress amber.
review: "text-accent border-accent-line bg-accent-soft",
downloading: "text-warn border-warn/40 bg-warn/10",
searching: "text-warn border-warn/40 bg-warn/10",
queued: "text-warn border-warn/40 bg-warn/10",
/**
* `queued`, `searching` and `downloading` used to be the same amber pill and
* differed only by the word inside, so the three stages of a request looked
* identical at a glance. They are distinct stages with distinct waits, so they
* now read as distinct.
*/
const STATE: Record<Job["state"], { tone: "neutral" | "accent" | "ok" | "warn" | "err"; label: string; live?: boolean }> = {
queued: { tone: "neutral", label: "Queued" },
searching: { tone: "accent", label: "Searching", live: true },
downloading: { tone: "warn", label: "Downloading", live: true },
review: { tone: "accent", label: "Needs review" },
imported: { tone: "ok", label: "In library" },
failed: { tone: "err", label: "Failed" },
};
export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh: () => void }) {
const toast = useToast();
const ok = useMotionOK();
const [open, setOpen] = React.useState<string | null>(null);
if (!snap) return null;
const { jobs, counts, pending } = snap;
const { jobs, counts } = snap;
const active = (counts.queued ?? 0) + (counts.searching ?? 0) + (counts.downloading ?? 0);
const done = counts.imported ?? 0;
const failed = counts.failed ?? 0;
const held = counts.review ?? 0;
const total = active + done;
const held = counts.review ?? 0;
const stats: [string, number, string][] = [
["In flight", active, "text-accent"],
["Imported", done, "text-ok"],
["Held", held, held > 0 ? "text-warn" : ""],
["Failed", failed, "text-err"],
["In library", done, "text-ok"],
["Needs review", held, held > 0 ? "text-warn" : "text-dim"],
["Failed", failed, failed > 0 ? "text-err" : "text-dim"],
];
// Kept in sync with the poll so the sheet updates live while it is open.
const current = jobs.find((j) => j.id === open) ?? null;
return (
<>
<div className="mb-4 grid grid-cols-2 sm:grid-cols-4 gap-2.5">
<div className="mb-4 grid grid-cols-2 gap-2.5 sm:grid-cols-4">
{stats.map(([label, n, colour]) => (
<motion.div key={label} layout className="glass rounded-glass p-3.5">
<motion.b
key={n}
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
className={cn("block text-title leading-none nums", colour)}
>
{n}
</motion.b>
<span className="mt-1 block text-caption uppercase tracking-[0.6px] text-dim">
<Card key={label} pad="sm">
<b className={cn("block text-title leading-none nums", colour)}>{n}</b>
<span className="mt-1 block text-caption uppercase tracking-[0.06em] text-mute">
{label}
</span>
</motion.div>
</Card>
))}
</div>
<AnimatePresence>
{active > 0 && total > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 4 }}
exit={{ opacity: 0, height: 0 }}
className="mb-4 overflow-hidden rounded-full bg-faint"
>
<motion.i
className="block h-1 rounded-full bg-accent"
animate={{ width: `${(done / total) * 100}%` }}
transition={{ ease: "easeOut", duration: 0.5 }}
/>
</motion.div>
)}
</AnimatePresence>
{active > 0 && total > 0 && (
<Progress
value={(done / total) * 100}
className="mb-4"
label="Requests completed"
/>
)}
{jobs.length === 0 ? (
<div className="py-12 text-center text-dim">Nothing requested yet.</div>
<EmptyState
icon={Inbox}
title="Nothing requested yet"
body="Search for a song and press Get. Anything you ask for shows up here with its progress."
/>
) : (
<motion.div layout className="flex flex-col gap-[7px]">
<div className="flex flex-col gap-row">
<AnimatePresence initial={false}>
{jobs.map((j) => (
<motion.div
key={j.id}
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97 }}
transition={{ type: "spring", stiffness: 380, damping: 34 }}
className="glass flex items-center gap-3 rounded-glass py-2.5 pl-2.5 pr-3"
>
<div className="h-[50px] w-[50px] shrink-0 overflow-hidden rounded-[9px] bg-faint">
{j.cover && (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={j.cover}
alt=""
loading="lazy"
className="h-full w-full object-cover"
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
/>
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-body font-semibold clamp1">{j.title}</div>
<div className="mt-px text-callout text-dim clamp1">
{[j.artist, j.album, j.detail].filter(Boolean).join(" · ")}
</div>
</div>
{j.state === "failed" && (
<Button
variant="quiet"
size="sm"
onClick={async () => {
await api.retry(j.id);
toast("Requeued");
refresh();
}}
>
Retry
</Button>
)}
<span
className={cn(
"shrink-0 whitespace-nowrap rounded-full border px-2.5 py-1",
"text-caption font-semibold capitalize",
PILL[j.state] ?? "text-dim border-edge",
)}
{jobs.map((j) => {
const s = STATE[j.state] ?? STATE.queued;
return (
<motion.button
key={j.id}
layout={ok}
initial={ok ? { opacity: 0, y: 10 } : false}
animate={{ opacity: 1, y: 0 }}
exit={ok ? { opacity: 0, scale: 0.97 } : { opacity: 0 }}
transition={spring}
onClick={() => setOpen(j.id)}
aria-label={`${j.title} by ${j.artist} — ${s.label}. Show details`}
className="card focus-ring flex items-center gap-3 rounded-card py-2.5 pl-2.5 pr-2
text-left transition-colors hover:bg-[var(--surface-2)]"
>
{(j.state === "searching" || j.state === "downloading") && (
<motion.span
className="mr-1.5 inline-block h-[5px] w-[5px] rounded-full bg-current align-[1px]"
animate={{ opacity: [1, 0.25, 1] }}
transition={{ duration: 1.3, repeat: Infinity, ease: "easeInOut" }}
/>
)}
{j.state}
</span>
</motion.div>
))}
<div className="h-[48px] w-[48px] shrink-0 overflow-hidden rounded-control bg-[var(--surface-3)]">
{j.cover && (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={j.cover}
alt=""
loading="lazy"
className="h-full w-full object-cover"
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
/>
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-body font-semibold clamp1">{j.title}</div>
<div className="mt-px text-callout text-dim clamp1">
{[j.artist, j.album].filter(Boolean).join(" · ")}
</div>
{j.detail && j.state === "failed" && (
<div className="mt-0.5 text-caption text-err clamp1">{j.detail}</div>
)}
</div>
<Badge tone={s.tone} className="shrink-0">
{s.live && (
<motion.span
className="h-[5px] w-[5px] rounded-full bg-current"
animate={ok ? { opacity: [1, 0.25, 1] } : {}}
transition={{ duration: 1.3, repeat: Infinity, ease: "easeInOut" }}
/>
)}
{s.label}
</Badge>
<ChevronRight className="h-4 w-4 shrink-0 text-mute" />
</motion.button>
);
})}
</AnimatePresence>
</motion.div>
</div>
)}
<JobSheet job={current} onClose={() => setOpen(null)} onChanged={refresh} />
</>
);
}
+7 -7
View File
@@ -7,7 +7,7 @@ import { Check, FileAudio, ShieldQuestion, X } from "lucide-react";
import { api, bytes, type ReviewItem } from "@/lib/api";
import { useToast } from "@/components/toast";
import { Button } from "@/components/ui/button";
import { Skeletons } from "@/components/skeletons";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
/**
@@ -36,7 +36,7 @@ export function Review({ onDecided }: { onDecided: () => void }) {
}, [load]);
if (err) return <div className="py-12 text-center text-dim">{err}</div>;
if (!items) return <Skeletons n={3} />;
if (!items) return <div className="flex flex-col gap-3">{[0,1,2].map((i)=>(<Skeleton key={i} className="h-[196px] rounded-panel" />))}</div>;
if (items.length === 0)
return (
@@ -47,7 +47,7 @@ export function Review({ onDecided }: { onDecided: () => void }) {
>
<div
className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-[18px]
border border-edge bg-[var(--glass)] text-dim"
border border-edge bg-[var(--surface)] text-dim"
>
<Check className="h-6 w-6" />
</div>
@@ -115,7 +115,7 @@ function ReviewCard({
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.18 } }}
transition={{ type: "spring", stiffness: 340, damping: 30 }}
className="glass rounded-panel p-4 sm:p-5"
className="card rounded-panel p-4 sm:p-5"
>
<div className="flex gap-4">
{item.cover ? (
@@ -123,11 +123,11 @@ function ReviewCard({
<img
src={item.cover}
alt=""
className="h-16 w-16 shrink-0 rounded-glass border border-edge object-cover"
className="h-16 w-16 shrink-0 rounded-card border border-edge object-cover"
/>
) : (
<div
className="grid h-16 w-16 shrink-0 place-items-center rounded-glass
className="grid h-16 w-16 shrink-0 place-items-center rounded-card
border border-edge bg-faint text-dim"
>
<FileAudio className="h-6 w-6" />
@@ -145,7 +145,7 @@ function ReviewCard({
</div>
</div>
<div className="mt-3.5 rounded-glass border border-edge bg-[var(--glass)] p-3">
<div className="mt-3.5 rounded-card border border-edge bg-[var(--surface)] p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-caption uppercase tracking-[0.7px] text-dim">
<ShieldQuestion className="h-3 w-3" /> what was found
</div>
+29 -7
View File
@@ -9,21 +9,40 @@ import { cn } from "@/lib/utils";
type Item = { kind: "track" | "album" | "artist"; label: string; sub: string; cover: string | null; meta?: string; run: () => void };
export function SearchBar({
initial,
busy,
onSearch,
onAlbum,
onArtist,
}: {
/** Seeds the field from the URL, so a shared link shows what it searched. */
initial?: string;
busy?: boolean;
onSearch: (q: string) => void;
onAlbum: (id: number) => void;
onArtist: (id: number) => void;
}) {
const [q, setQ] = React.useState("");
const [q, setQ] = React.useState(initial ?? "");
const [items, setItems] = React.useState<Item[]>([]);
const [open, setOpen] = React.useState(false);
const [sel, setSel] = React.useState(-1);
const inputRef = React.useRef<HTMLInputElement>(null);
// A value seeded from the URL must not pop the suggestion list open over the
// results that link was for. Only typing counts as asking for suggestions.
const typed = React.useRef(false);
const seeded = React.useRef(initial);
// Follows the URL on Back/Forward, but never fights what is being typed:
// only a genuinely different incoming query replaces the field. Declared
// after the state it sets, or the effect closes over a binding that is not
// initialised yet.
React.useEffect(() => {
if (initial === undefined || initial === seeded.current) return;
seeded.current = initial;
typed.current = false;
setQ(initial);
setOpen(false);
}, [initial]);
// Debounced so a fast typist makes one upstream call, not eight. The
// AbortController matters more than the delay: without it a slow response
@@ -76,7 +95,7 @@ export function SearchBar({
];
setItems(next);
setSel(-1);
setOpen(next.length > 0);
setOpen(typed.current && next.length > 0);
} catch {
/* aborted or offline — leave the previous list alone */
}
@@ -129,9 +148,9 @@ export function SearchBar({
<div className="relative z-50">
<div
className={cn(
"glass flex items-center gap-3 h-[50px] px-3.5 rounded-[20px]",
"transition-all duration-250 ease-glass",
"focus-within:border-accent-line focus-within:bg-glass-hi",
"card flex items-center gap-3 h-[50px] px-3.5 rounded-[20px]",
"transition-all duration-250 ease-out",
"focus-within:border-accent-line focus-within:bg-[var(--surface-2)]",
"focus-within:shadow-[0_0_0_4px_var(--color-accent-soft)]",
)}
>
@@ -140,7 +159,10 @@ export function SearchBar({
ref={inputRef}
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
onChange={(e) => {
typed.current = true;
setQ(e.target.value);
}}
onKeyDown={key}
onFocus={() => items.length && setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 120)}
@@ -161,7 +183,7 @@ export function SearchBar({
}}
aria-label="Clear search"
className="w-6.5 h-6.5 shrink-0 grid place-items-center rounded-full
glass-hi text-dim hover:text-ink cursor-pointer"
card text-dim hover:text-ink cursor-pointer"
>
<X className="w-3.5 h-3.5" />
</button>
+6 -6
View File
@@ -43,14 +43,14 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
className="flex flex-col gap-4"
>
{anonymous && (
<div className="glass rounded-panel border-warn/40 p-4 text-callout text-warn">
<div className="card rounded-panel border-warn/40 p-4 text-callout text-warn">
Authentication is turned off on this instance, so there is no account to
save preferences against. Set <code className="font-mono text-[12px]">MUSE_AUTH</code>{" "}
to <code className="font-mono text-[12px]">github</code> or{" "}
<code className="font-mono text-[12px]">google</code> to enable sign-in.
</div>
)}
<section className="glass rounded-panel p-5">
<section className="card rounded-panel p-5">
<div className="mb-1 flex items-center gap-2">
<Mail className="h-4 w-4 text-accent" />
<h2 className="text-headline">Notifications</h2>
@@ -61,7 +61,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
</p>
{!me.mail_configured && (
<div className="mb-4 rounded-glass border border-warn/40 bg-[var(--glass)] p-3 text-callout text-warn">
<div className="mb-4 rounded-card border border-warn/40 bg-[var(--surface)] p-3 text-callout text-warn">
No SMTP server is configured on this instance, so nothing will actually be
sent. Set <code className="font-mono text-[12px]">MUSE_SMTP_HOST</code> and{" "}
<code className="font-mono text-[12px]">MUSE_SMTP_FROM</code> to turn it on.
@@ -79,7 +79,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
onKeyDown={(e) => e.key === "Enter" && dirty && save()}
disabled={anonymous}
placeholder={googleImplicit ? me.user ?? "you@example.com" : "you@example.com"}
className="ring rounded-control border border-edge-hi bg-transparent px-3 py-2
className="focus-ring rounded-control border border-edge-hi bg-transparent px-3 py-2
text-body outline-none transition focus:border-accent-line
disabled:opacity-45"
/>
@@ -95,7 +95,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
role="switch"
aria-checked={notify}
disabled={anonymous}
className="ring mt-4 flex cursor-pointer items-center gap-3 rounded-glass
className="focus-ring mt-4 flex cursor-pointer items-center gap-3 rounded-card
text-left disabled:cursor-default disabled:opacity-45"
>
<span
@@ -130,7 +130,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
</div>
</section>
<section className="glass rounded-panel p-5">
<section className="card rounded-panel p-5">
<h2 className="mb-3 text-headline">Account</h2>
{anonymous ? (
<p className="text-callout text-dim">
+36
View File
@@ -0,0 +1,36 @@
import {
Search,
Sparkles,
ListMusic,
ShieldCheck,
ClipboardList,
Settings2,
Gauge,
type LucideIcon,
} from "lucide-react";
import type { Tab } from "@/lib/nav";
export type NavItem = {
tab: Tab;
label: string;
icon: LucideIcon;
group: "Library" | "Activity" | "Manage";
};
/**
* Grouped, because a flat list of seven is a menu and a grouped list of seven
* is a structure. The order is the order of use: you search, you watch what
* you asked for, you administer occasionally.
*/
export const NAV: NavItem[] = [
{ tab: "search", label: "Search", icon: Search, group: "Library" },
{ tab: "discover", label: "Discover", icon: Sparkles, group: "Library" },
{ tab: "jobs", label: "Requests", icon: ListMusic, group: "Activity" },
{ tab: "review", label: "Review", icon: ShieldCheck, group: "Activity" },
{ tab: "bulk", label: "Import", icon: ClipboardList, group: "Manage" },
{ tab: "settings", label: "Settings", icon: Settings2, group: "Manage" },
{ tab: "admin", label: "Admin", icon: Gauge, group: "Manage" },
];
/** The five that earn a slot on a phone's bottom bar. */
export const MOBILE: Tab[] = ["search", "discover", "jobs", "review", "settings"];
+119
View File
@@ -0,0 +1,119 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { Music2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { href, type Nav, type Tab } from "@/lib/nav";
import { useMotionOK, spring } from "@/lib/motion";
import { NAV } from "./nav-items";
/**
* Sidebar navigation, the pattern Apple uses in Music, Mail, Finder and
* Settings. It replaces a horizontally scrolling row of pills, which is a
* mobile-web affordance that had been left on the desktop layout too.
*
* Collapses to a 68px icon rail between 768 and 1024, where a 240px sidebar
* would eat a third of the window.
*/
export function Sidebar({
nav,
go,
badges,
rail,
visible,
}: {
nav: Nav;
go: (n: Nav) => void;
badges: Partial<Record<Tab, number>>;
rail: boolean;
visible: (t: Tab) => boolean;
}) {
const ok = useMotionOK();
const groups = ["Library", "Activity", "Manage"] as const;
return (
<nav
aria-label="Sections"
className={cn(
"material sticky top-0 hidden h-dvh shrink-0 flex-col gap-1 border-r border-edge",
"md:flex",
rail ? "w-[var(--rail-w)] items-center px-2 py-3" : "w-[var(--sidebar-w)] px-3 py-3",
)}
>
<div className={cn("mb-4 flex items-center gap-2.5", rail ? "justify-center" : "px-2")}>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-card bg-accent text-[var(--accent-ink)]">
<Music2 className="h-4 w-4" />
</span>
{!rail && <span className="text-headline tracking-tight">muse</span>}
</div>
{groups.map((g) => {
const items = NAV.filter((n) => n.group === g && visible(n.tab));
if (!items.length) return null;
return (
<div key={g} className="mb-2 w-full">
{!rail && (
<h2 className="mb-1 px-2 text-caption font-semibold uppercase tracking-[0.06em] text-mute">
{g}
</h2>
)}
<ul className="flex flex-col gap-0.5">
{items.map(({ tab, label, icon: Icon }) => {
const on = nav.tab === tab;
const badge = badges[tab] ?? 0;
return (
<li key={tab}>
<a
href={href({ tab })}
aria-current={on ? "page" : undefined}
title={rail ? label : undefined}
onClick={(e) => {
// Left-click navigates in place; cmd/ctrl-click still
// opens a real new tab, because these are real links.
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
e.preventDefault();
go({ tab });
}}
className={cn(
"focus-ring relative flex items-center rounded-control text-callout",
"font-medium transition-colors duration-150",
rail ? "h-9 w-9 justify-center" : "gap-2.5 px-2 py-1.5",
on ? "text-ink" : "text-dim hover:bg-[var(--faint)] hover:text-ink",
)}
>
{on && (
<motion.span
layoutId="nav-selection"
transition={ok ? spring : { duration: 0 }}
className="absolute inset-0 rounded-control bg-[var(--surface-3)]"
/>
)}
<Icon className="relative h-4 w-4 shrink-0" />
{!rail && <span className="relative flex-1 clamp1">{label}</span>}
{badge > 0 && (
<span
className={cn(
"shrink-0 rounded-pill text-caption font-bold nums",
rail
? "absolute -right-1 -top-1 h-4 min-w-4 px-1 text-center leading-4"
: "relative px-1.5",
tab === "review"
? "bg-warn/20 text-warn"
: "bg-accent-soft text-accent",
)}
>
{badge}
</span>
)}
</a>
</li>
);
})}
</ul>
</div>
);
})}
</nav>
);
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { AlertTriangle, WifiOff } from "lucide-react";
import { api, type Health } from "@/lib/api";
import { useMotionOK } from "@/lib/motion";
/**
* System health, for everyone.
*
* This check already existed but lived inside the admin panel, so a regular
* user whose every request was failing saw nothing but a growing pile of red
* pills. "Reachable but not logged in" is the important case: the container is
* up and healthy, so no other signal reports a problem, yet every search
* returns nothing.
*/
export function StatusBanner() {
const [h, setH] = React.useState<Health | null>(null);
const ok = useMotionOK();
React.useEffect(() => {
let live = true;
const poll = async () => {
try {
const r = await api.health();
if (live) setH(r);
} catch {
/* a 401 here just means the poll outlived the session */
}
};
void poll();
const t = setInterval(poll, 20000);
return () => {
live = false;
clearInterval(t);
};
}, []);
const problem = !h
? null
: h.slskd !== 200
? {
icon: WifiOff,
text: "Can't reach slskd — nothing will download until it's back.",
}
: h.logged_in === false
? {
icon: AlertTriangle,
text: "slskd is running but not signed in to Soulseek — searches will return nothing.",
}
: h.library_writable === false
? { icon: AlertTriangle, text: "The music library isn't writable — imports will fail." }
: null;
return (
<AnimatePresence>
{problem && (
<motion.div
role="status"
initial={ok ? { height: 0, opacity: 0 } : false}
animate={{ height: "auto", opacity: 1 }}
exit={ok ? { height: 0, opacity: 0 } : { opacity: 0 }}
className="overflow-hidden"
>
<div className="flex items-center gap-2 border-b border-warn/30 bg-warn/10 px-4 py-2 text-callout text-warn">
<problem.icon className="h-4 w-4 shrink-0" />
<span className="clamp1">{problem.text}</span>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { href, type Nav, type Tab } from "@/lib/nav";
import { NAV, MOBILE } from "./nav-items";
/**
* The phone layout. A bottom bar is what a native app does, and it puts the
* targets under the thumb instead of at the top of a tall screen. Respects the
* home-indicator inset, which the previous layout did not.
*/
export function TabBar({
nav,
go,
badges,
visible,
}: {
nav: Nav;
go: (n: Nav) => void;
badges: Partial<Record<Tab, number>>;
visible: (t: Tab) => boolean;
}) {
const items = NAV.filter((n) => MOBILE.includes(n.tab) && visible(n.tab));
return (
<nav
aria-label="Sections"
className="material-hi fixed inset-x-0 bottom-0 z-40 flex border-t border-edge
pb-[env(safe-area-inset-bottom)] md:hidden"
>
{items.map(({ tab, label, icon: Icon }) => {
const on = nav.tab === tab;
const badge = badges[tab] ?? 0;
return (
<a
key={tab}
href={href({ tab })}
aria-current={on ? "page" : undefined}
onClick={(e) => {
if (e.metaKey || e.ctrlKey) return;
e.preventDefault();
go({ tab });
}}
className={cn(
"focus-ring relative flex flex-1 flex-col items-center gap-0.5 py-2",
"text-caption font-medium transition-colors",
on ? "text-accent" : "text-mute",
)}
>
<span className="relative">
<Icon className="h-5 w-5" />
{badge > 0 && (
<span
className={cn(
"absolute -right-2 -top-1 min-w-[15px] rounded-pill px-1 text-[9px]",
"font-bold leading-[15px] nums",
tab === "review" ? "bg-warn text-black" : "bg-accent text-[var(--accent-ink)]",
)}
>
{badge}
</span>
)}
</span>
{label}
</a>
);
})}
</nav>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import * as React from "react";
import { LogOut } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Me } from "@/lib/api";
import { Badge } from "@/components/ui/badge";
/**
* Sticky chrome over scrolling content: search on the left, identity on the
* right. Everything that used to be hidden below `sm:` — the quota chip, the
* display name — now degrades into the overflow rather than vanishing, since
* "how many requests do I have left" matters most on the device you are
* actually queueing from.
*/
export function Toolbar({
me,
quotaLeft,
children,
}: {
me: Me | null;
quotaLeft: number | null;
children?: React.ReactNode;
}) {
return (
<header className="material sticky top-0 z-30 border-b border-edge">
<div className="mx-auto flex w-full max-w-[var(--container-3xl)] items-center gap-3 px-4 py-2.5">
<div className="min-w-0 flex-1">{children}</div>
{me?.user && (
<div className="flex shrink-0 items-center gap-2">
{quotaLeft !== null && (
<Badge
tone={quotaLeft === 0 ? "err" : quotaLeft <= 5 ? "warn" : "neutral"}
title={`${me.usage!.requests} of ${me.quota} requests used this month`}
className="nums"
>
{quotaLeft} left
</Badge>
)}
{me.role && me.role !== "user" && (
<Badge tone="accent" className="hidden uppercase tracking-[0.06em] sm:inline-flex">
{me.role}
</Badge>
)}
{me.avatar && (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={me.avatar}
alt=""
className="h-7 w-7 rounded-full border border-edge object-cover"
/>
)}
<span className="hidden max-w-[14ch] text-callout font-medium clamp1 lg:inline">
{me.display || me.user}
</span>
<a
href="/auth/logout"
title="Log out"
aria-label="Log out"
className={cn(
"focus-ring grid h-8 w-8 place-items-center rounded-control text-dim",
"transition-colors hover:bg-[var(--faint)] hover:text-ink",
)}
>
<LogOut className="h-4 w-4" />
</a>
</div>
)}
</div>
</header>
);
}
-20
View File
@@ -1,20 +0,0 @@
"use client";
import { motion } from "framer-motion";
/** Shimmering placeholders. Sized to the real row so nothing jumps on load. */
export function Skeletons({ n = 6 }: { n?: number }) {
return (
<div className="flex flex-col gap-[7px]">
{Array.from({ length: n }).map((_, i) => (
<motion.div
key={i}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: i * 0.04 }}
className="shimmer h-[68px] rounded-glass border border-edge"
/>
))}
</div>
);
}
+70 -16
View File
@@ -2,43 +2,97 @@
import * as React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { AlertCircle, CheckCircle2, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useMotionOK, spring } from "@/lib/motion";
type Toast = { id: number; msg: string; err?: boolean };
type Ctx = (msg: string, err?: boolean) => void;
type Toast = { id: number; msg: string; err?: boolean; action?: Action };
type Action = { label: string; run: () => void };
type Ctx = (msg: string, err?: boolean, action?: Action) => void;
const ToastCtx = React.createContext<Ctx>(() => {});
export const useToast = () => React.useContext(ToastCtx);
/** An error you cannot read in time is the same as no error at all. */
const DWELL_OK = 3400;
const DWELL_ERR = 7000;
/** Beyond this the stack is a wall, and the oldest is the least relevant. */
const MAX = 3;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = React.useState<Toast[]>([]);
const ok = useMotionOK();
const push = React.useCallback<Ctx>((msg, err) => {
const id = Date.now() + Math.random();
setItems((x) => [...x, { id, msg, err }]);
setTimeout(() => setItems((x) => x.filter((t) => t.id !== id)), 3400);
}, []);
const drop = React.useCallback(
(id: number) => setItems((x) => x.filter((t) => t.id !== id)),
[],
);
const push = React.useCallback<Ctx>(
(msg, err, action) => {
const id = Date.now() + Math.random();
setItems((x) => [...x, { id, msg, err, action }].slice(-MAX));
setTimeout(() => drop(id), err ? DWELL_ERR : DWELL_OK);
},
[drop],
);
return (
<ToastCtx.Provider value={push}>
{children}
<div className="fixed bottom-24 left-1/2 -translate-x-1/2 z-90 flex flex-col items-center gap-2 pointer-events-none">
{/*
aria-live so a screen reader hears "Queued" or a failure at all — these
were previously silent, which made the only confirmation that a request
had been accepted a purely visual one.
*/}
<div
role="status"
aria-live="polite"
aria-atomic="false"
className="pointer-events-none fixed inset-x-0 bottom-24 z-[110] flex flex-col
items-center gap-2 px-4 md:bottom-8"
>
<AnimatePresence>
{items.map((t) => (
<motion.div
key={t.id}
layout
initial={{ opacity: 0, y: 18, scale: 0.94 }}
layout={ok}
initial={ok ? { opacity: 0, y: 18, scale: 0.94 } : false}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.96 }}
transition={{ type: "spring", stiffness: 420, damping: 30 }}
exit={ok ? { opacity: 0, y: 10, scale: 0.96 } : { opacity: 0 }}
transition={spring}
className={cn(
"sheet rounded-full px-5 py-2.5 text-callout font-medium text-center",
"max-w-[calc(100vw-32px)]",
t.err && "border-err/50 text-err",
"sheet pointer-events-auto flex max-w-[min(30rem,100%)] items-center gap-2.5",
"rounded-pill py-2 pl-3.5 pr-2 text-callout font-medium",
t.err && "border-err/45",
)}
>
{t.msg}
{t.err ? (
<AlertCircle className="h-4 w-4 shrink-0 text-err" />
) : (
<CheckCircle2 className="h-4 w-4 shrink-0 text-ok" />
)}
<span className={cn("min-w-0 flex-1", t.err && "text-err")}>{t.msg}</span>
{t.action && (
<button
onClick={() => {
t.action!.run();
drop(t.id);
}}
className="focus-ring shrink-0 rounded-pill px-2.5 py-0.5 text-callout
font-semibold text-accent transition-colors hover:bg-accent-soft"
>
{t.action.label}
</button>
)}
<button
onClick={() => drop(t.id)}
aria-label="Dismiss"
className="focus-ring grid h-6 w-6 shrink-0 place-items-center rounded-full
text-mute transition-colors hover:bg-[var(--faint)] hover:text-ink"
>
<X className="h-3.5 w-3.5" />
</button>
</motion.div>
))}
</AnimatePresence>
+2 -2
View File
@@ -73,8 +73,8 @@ export function TrackRow({
transition={{ delay: Math.min(index * 0.022, 0.4), type: "spring", stiffness: 380, damping: 34 }}
whileHover={{ y: -1 }}
className={cn(
"glass rounded-glass flex items-center gap-3 py-2.5 pl-2.5 pr-3",
"transition-colors hover:bg-glass-hi hover:border-edge-hi",
"card rounded-card flex items-center gap-3 py-2.5 pl-2.5 pr-3",
"transition-colors hover:bg-[var(--surface-2)] hover:border-edge-hi",
isPlaying && "border-accent-line bg-accent-soft",
)}
>
+30
View File
@@ -0,0 +1,30 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badge = cva(
"inline-flex items-center gap-1 rounded-pill border px-2 py-0.5 " +
"text-caption font-medium whitespace-nowrap",
{
variants: {
tone: {
neutral: "border-edge text-dim",
accent: "border-accent-line bg-accent-soft text-accent",
ok: "border-ok/40 bg-ok/10 text-ok",
warn: "border-warn/40 bg-warn/10 text-warn",
err: "border-err/40 bg-err/10 text-err",
},
},
defaultVariants: { tone: "neutral" },
},
);
export function Badge({
className,
tone,
...props
}: React.HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badge>) {
return <span className={cn(badge({ tone }), className)} {...props} />;
}
+25 -21
View File
@@ -6,30 +6,36 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const button = cva(
"inline-flex items-center justify-center gap-2 font-semibold cursor-pointer " +
"transition-all duration-200 ease-glass disabled:opacity-45 " +
"disabled:cursor-default disabled:pointer-events-none select-none " +
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-accent-soft",
"inline-flex items-center justify-center gap-1.5 font-semibold cursor-pointer " +
"select-none whitespace-nowrap focus-ring " +
"transition-[background,border-color,color,transform,box-shadow] " +
"duration-150 ease-out active:scale-[0.97] " +
"disabled:opacity-40 disabled:cursor-default disabled:pointer-events-none " +
"disabled:active:scale-100",
{
variants: {
variant: {
// Solid accent. The label is near-black because the accent is clamped
// to a light-ish luminance in lib/colour.ts — the two decisions have
// to stay in step or this button becomes unreadable on some albums.
primary:
"bg-accent text-[#08090d] shadow-[var(--e1)] hover:brightness-110 " +
"active:scale-[0.97] active:brightness-95",
ghost:
"glass-hi text-ink hover:border-accent-line hover:bg-accent-soft active:scale-[0.97]",
// The accent is fixed now, not sampled from artwork, so a solid button
// has a known contrast ratio and can carry a plain white label. It
// used to be near-black text on a colour taken from the album cover,
// which only worked because colour.ts clamped the luminance.
primary: "bg-accent text-[var(--accent-ink)] shadow-[var(--e1)] hover:brightness-110",
secondary:
"bg-[var(--surface-3)] text-ink border border-edge hover:border-edge-hi " +
"hover:bg-[var(--surface-2)]",
outline:
"bg-transparent border border-edge-hi text-ink hover:border-accent-line " +
"hover:bg-accent-soft hover:text-accent active:scale-[0.95]",
quiet: "bg-transparent border border-edge-hi text-dim hover:text-ink hover:border-accent-line",
"bg-transparent border border-edge-hi text-ink " +
"hover:border-accent-line hover:bg-accent-soft hover:text-accent",
quiet: "bg-transparent text-dim hover:text-ink hover:bg-[var(--faint)]",
danger: "bg-transparent border border-err/45 text-err hover:bg-err/10",
},
size: {
sm: "text-caption px-3 py-1.5 rounded-pill",
md: "text-callout px-4.5 py-2.5 rounded-control",
pill: "text-callout px-4 py-2 rounded-pill",
xs: "text-caption px-2.5 py-1 rounded-control gap-1",
sm: "text-callout px-3 py-1.5 rounded-control",
md: "text-callout px-4 py-2 rounded-control",
lg: "text-body px-5 py-2.5 rounded-control font-semibold",
icon: "h-8 w-8 rounded-control",
pill: "text-callout px-4 py-1.5 rounded-pill",
},
},
defaultVariants: { variant: "primary", size: "md" },
@@ -45,9 +51,7 @@ export interface ButtonProps
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp ref={ref} className={cn(button({ variant, size }), className)} {...props} />
);
return <Comp ref={ref} className={cn(button({ variant, size }), className)} {...props} />;
},
);
Button.displayName = "Button";
+43
View File
@@ -0,0 +1,43 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* The content surface. `"card rounded-panel p-5"` was copy-pasted across eight
* files with the padding drifting between p-3, p-3.5, p-4 and p-5; this fixes
* the scale in one place.
*/
export function Card({
className,
pad = "md",
interactive,
...props
}: React.HTMLAttributes<HTMLDivElement> & {
pad?: "none" | "sm" | "md" | "lg";
interactive?: boolean;
}) {
return (
<div
className={cn(
"card rounded-panel",
{ none: "", sm: "p-3", md: "p-4", lg: "p-5 sm:p-6" }[pad],
interactive && "lift cursor-pointer",
className,
)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("mb-3 flex items-center justify-between gap-3", className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h2 className={cn("text-headline", className)} {...props} />;
}
export function CardNote({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("text-callout text-dim", className)} {...props} />;
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Seven screens rendered a bare centred string for "nothing here" and two had
* a real designed state. This is the designed one, made reusable.
*/
export function EmptyState({
icon: Icon,
title,
body,
action,
tone = "neutral",
className,
}: {
icon?: React.ComponentType<{ className?: string }>;
title: string;
body?: React.ReactNode;
action?: React.ReactNode;
tone?: "neutral" | "err";
className?: string;
}) {
return (
<div className={cn("flex flex-col items-center px-6 py-14 text-center", className)}>
{Icon && (
<div
className={cn(
"mb-4 grid h-12 w-12 place-items-center rounded-panel border",
tone === "err"
? "border-err/35 bg-err/10 text-err"
: "border-edge bg-[var(--surface-2)] text-mute",
)}
>
<Icon className="h-5 w-5" />
</div>
)}
<h3 className={cn("text-headline", tone === "err" && "text-err")}>{title}</h3>
{body && <p className="mt-1.5 max-w-[42ch] text-callout text-dim">{body}</p>}
{action && <div className="mt-5">{action}</div>}
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/** Shared shell for every text control, so focus looks the same everywhere. */
export const controlBase =
"w-full rounded-control border border-edge bg-[var(--surface-2)] px-3 py-2 " +
"text-body text-ink outline-none placeholder:text-mute " +
"transition-[border-color,box-shadow,background] duration-150 ease-out " +
"focus:border-accent-line focus:bg-[var(--surface)] " +
"focus:shadow-[0_0_0_3px_var(--accent-soft)] " +
"disabled:opacity-45 disabled:cursor-not-allowed";
export const Input = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(({ className, ...props }, ref) => (
<input ref={ref} className={cn(controlBase, className)} {...props} />
));
Input.displayName = "Input";
export const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => (
<textarea ref={ref} className={cn(controlBase, "resize-y leading-relaxed", className)} {...props} />
));
Textarea.displayName = "Textarea";
/**
* Options inherit the surface token rather than a hardcoded `#12141c`, which
* previously rendered dark dropdown items on a white page in light mode.
*/
export const Select = React.forwardRef<
HTMLSelectElement,
React.SelectHTMLAttributes<HTMLSelectElement>
>(({ className, children, ...props }, ref) => (
<select ref={ref} className={cn(controlBase, "cursor-pointer pr-8", className)} {...props}>
{children}
</select>
));
Select.displayName = "Select";
export function Field({
label,
hint,
htmlFor,
children,
className,
}: {
label: string;
hint?: React.ReactNode;
htmlFor?: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<label htmlFor={htmlFor} className="text-callout font-medium text-dim">
{label}
</label>
{children}
{hint && <p className="text-caption text-mute">{hint}</p>}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* One progress bar. There were six hand-rolled ones with four different
* heights, while @radix-ui/react-progress sat installed and unimported.
*/
export function Progress({
value,
tone = "accent",
size = "md",
className,
label,
}: {
value: number; // 0..100
tone?: "accent" | "ok" | "warn" | "err" | "art";
size?: "sm" | "md";
className?: string;
label?: string;
}) {
const pct = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
return (
<div
role="progressbar"
aria-valuenow={Math.round(pct)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
className={cn(
"w-full overflow-hidden rounded-pill bg-[var(--surface-3)]",
size === "sm" ? "h-1" : "h-1.5",
className,
)}
>
<div
className={cn(
"h-full rounded-pill transition-[width] duration-500 ease-out",
{ accent: "bg-accent", ok: "bg-ok", warn: "bg-warn", err: "bg-err", art: "bg-art" }[tone],
)}
style={{ width: `${pct}%` }}
/>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import * as React from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useMotionOK } from "@/lib/motion";
/**
* A real modal, on Radix, which was already a dependency and never imported.
* That matters beyond tidiness: it brings focus trapping, restore-on-close,
* Escape, scroll locking and aria wiring that hand-rolled inline confirms
* (the previous pattern) do not have.
*
* Presents as a bottom sheet on phones and a centred panel from `sm:` up.
*/
export function Sheet({
open,
onOpenChange,
title,
description,
children,
footer,
className,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
title: React.ReactNode;
description?: React.ReactNode;
children?: React.ReactNode;
footer?: React.ReactNode;
className?: string;
}) {
const ok = useMotionOK();
// Radix locks scrolling by injecting a <style> element, which this app's CSP
// (style-src 'self', no 'unsafe-inline') blocks outright — the console says
// so every time a sheet opens. Setting the property through the CSSOM is not
// markup, so it is allowed, and it is the half that actually matters:
// without it the page behind the sheet scrolls under your finger on a phone.
React.useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<AnimatePresence>
{open && (
<Dialog.Portal forceMount>
<Dialog.Overlay asChild forceMount>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: ok ? 0.18 : 0 }}
className="fixed inset-0 z-[100] bg-[var(--scrim)] backdrop-blur-[2px]"
/>
</Dialog.Overlay>
<Dialog.Content asChild forceMount>
<motion.div
initial={ok ? { opacity: 0, y: 24, scale: 0.98 } : false}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={ok ? { opacity: 0, y: 16, scale: 0.98 } : { opacity: 0 }}
transition={{ type: "spring", stiffness: 420, damping: 34 }}
className={cn(
"sheet fixed z-[101] flex flex-col overflow-hidden",
"inset-x-0 bottom-0 max-h-[86vh] rounded-t-sheet rounded-b-none",
"sm:inset-x-auto sm:bottom-auto sm:left-1/2 sm:top-1/2",
"sm:max-h-[82vh] sm:w-[min(560px,calc(100vw-32px))]",
"sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-sheet",
className,
)}
>
<header className="flex items-start justify-between gap-3 border-b border-edge px-5 py-4">
<div className="min-w-0">
<Dialog.Title className="text-headline">{title}</Dialog.Title>
{description && (
<Dialog.Description className="mt-0.5 text-callout text-dim">
{description}
</Dialog.Description>
)}
</div>
<Dialog.Close
aria-label="Close"
className="focus-ring -mr-1 grid h-8 w-8 shrink-0 place-items-center
rounded-control text-dim transition-colors hover:bg-[var(--faint)] hover:text-ink"
>
<X className="h-4 w-4" />
</Dialog.Close>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">{children}</div>
{footer && (
<footer className="flex justify-end gap-2 border-t border-edge px-5 py-3.5">
{footer}
</footer>
)}
</motion.div>
</Dialog.Content>
</Dialog.Portal>
)}
</AnimatePresence>
</Dialog.Root>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { cn } from "@/lib/utils";
/**
* Shape-aware. There was one fixed 68px row used for the admin dashboard, the
* discover grid and the review queue alike, so the loading state never
* resembled what actually arrived.
*/
export function Skeleton({ className }: { className?: string }) {
return <div className={cn("shimmer rounded-card border border-edge", className)} />;
}
export function SkeletonRows({ n = 6 }: { n?: number }) {
return (
<div className="flex flex-col gap-row">
{Array.from({ length: n }, (_, i) => (
<Skeleton key={i} className="h-[64px]" />
))}
</div>
);
}
export function SkeletonCards({ n = 4 }: { n?: number }) {
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: n }, (_, i) => (
<Skeleton key={i} className="h-[84px] rounded-panel" />
))}
</div>
);
}
export function SkeletonGrid({ n = 8 }: { n?: number }) {
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(148px,1fr))] gap-4">
{Array.from({ length: n }, (_, i) => (
<div key={i} className="flex flex-col gap-2">
<Skeleton className="aspect-square w-full rounded-panel" />
<Skeleton className="h-3 w-3/4 rounded-pill border-0" />
</div>
))}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { cn } from "@/lib/utils";
/** CSS-driven, so the reduced-motion block in globals.css actually stops it. */
export function Spinner({ className }: { className?: string }) {
return (
<span
aria-hidden
className={cn(
"inline-block h-3.5 w-3.5 shrink-0 rounded-full border-2 border-current",
"border-r-transparent [animation:spin_0.7s_linear_infinite]",
className,
)}
/>
);
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { Search } from "lucide-react";
import type { Job } from "@/lib/api";
import { item, useMotionOK } from "@/lib/motion";
const EXAMPLES = [
"Radiohead Creep",
"Kendrick Lamar Money Trees",
"Fleetwood Mac Dreams",
"Tame Impala The Less I Know The Better",
"Frank Ocean Ivy",
];
/**
* The first screen.
*
* It used to be a floating disc, a heading, and five hardcoded example chips —
* a decorated placeholder that said nothing about this install. Leading with
* the artwork of what has actually been imported makes the empty state a
* library view instead, and falls back to the examples only when there is
* genuinely nothing to show.
*/
export function Welcome({
onPick,
recent,
}: {
onPick: (q: string) => void;
recent: Job[];
}) {
const ok = useMotionOK();
const seen = new Set<string>();
const imported = recent
.filter((j) => j.state === "imported" && j.cover)
.filter((j) => {
const k = `${j.artist}|${j.album ?? j.title}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
})
.slice(0, 12);
return (
<div className="py-2">
<div className="mb-8 max-w-[52ch] pt-6">
<h1 className="text-hero">Find anything.</h1>
<p className="mt-2.5 text-body text-dim">
Search a song, preview it, and press Get. It arrives in your library correctly
tagged — right artist, right album, right track number.
</p>
</div>
{imported.length > 0 ? (
<section aria-labelledby="recent-h">
<h2 id="recent-h" className="mb-3 text-headline">
Recently added
</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(132px,1fr))] gap-4">
{imported.map((j, i) => (
<motion.button
key={j.id}
{...item(i, ok)}
onClick={() => onPick(`${j.artist} ${j.title}`)}
className="focus-ring group text-left"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={j.cover!}
alt=""
loading="lazy"
className="lift mb-2 aspect-square w-full rounded-panel border border-edge
object-cover shadow-[var(--e1)]"
/>
<div className="text-callout font-medium clamp1">{j.title}</div>
<div className="text-caption text-dim clamp1">{j.artist}</div>
</motion.button>
))}
</div>
</section>
) : (
<section aria-labelledby="try-h">
<h2 id="try-h" className="mb-3 flex items-center gap-2 text-headline">
<Search className="h-4 w-4 text-mute" /> Try one of these
</h2>
<div className="flex flex-wrap gap-2">
{EXAMPLES.map((e, i) => (
<motion.button
key={e}
{...item(i, ok)}
onClick={() => onPick(e)}
className="focus-ring rounded-pill border border-edge bg-[var(--surface)] px-3.5
py-1.5 text-callout text-dim transition-colors hover:border-accent-line
hover:bg-accent-soft hover:text-accent"
>
{e}
</motion.button>
))}
</div>
</section>
)}
</div>
);
}
+52
View File
@@ -129,6 +129,15 @@ export type Job = {
duration: number | null;
requested_by: string | null;
created_at: string;
/** The peer currently serving this transfer, if one is. */
username: string | null;
remote_file: string | null;
confidence: number | null;
match_why: string | null;
/** Peers already attempted for this request, and why each one fell through. */
tried: { username: string; filename: string; reason: string }[];
attempts: number;
remaining: number;
};
export type Usage = { requests: number; storage: number };
@@ -248,8 +257,51 @@ function send<T>(method: string, path: string, body?: unknown): Promise<T> {
const post = <T,>(path: string, body?: unknown) => send<T>("POST", path, body);
export type Health = {
ok: boolean;
slskd?: number | string;
logged_in?: boolean;
shared_files?: number | null;
library_writable?: boolean;
downloads_readable?: boolean;
};
export type PlaylistPreview = {
source: "deezer" | "spotify" | "apple";
name: string;
count: number;
tracks: { artist: string; title: string }[];
};
export type PlaylistRow = {
id: string;
name: string;
source: string;
source_url: string | null;
tracks_total: number;
tracks_written: number;
m3u_path: string | null;
created_at: string;
done: number;
pending: number;
failed: number;
review: number;
};
export const api = {
playlistPreview: (url: string) =>
post<PlaylistPreview>("/api/playlist", { url, preview: true }),
playlistImport: (url: string, name?: string) =>
post<{
playlist_id: string;
name: string;
source: string;
queued: { id: string; line: string; matched: string; cover: string | null }[];
skipped: { line: string; why: string }[];
}>("/api/playlist", { url, name }),
playlists: () => get<{ playlists: PlaylistRow[] }>("/api/playlists"),
me: () => get<Me>("/api/me"),
health: () => get<Health>("/api/health"),
search: (q: string) =>
get<{ results: Track[] }>(`/api/search?q=${encodeURIComponent(q)}`),
suggest: (q: string, signal?: AbortSignal) =>
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useReducedMotion, type Transition, type Variants } from "framer-motion";
/**
* One motion vocabulary.
*
* There were four different stagger formulas and five entrance offsets across
* the components, so nothing quite moved like anything else. More importantly,
* the `prefers-reduced-motion` block in globals.css only governs CSS: Framer
* animates via the CSSOM from JS, so every entrance, the aurora blobs and the
* pulsing status dots all ran at full strength for people who had explicitly
* asked them not to. `useMotionOK()` is the fix, and every animated component
* consults it.
*/
export function useMotionOK() {
return !useReducedMotion();
}
/** Apple's motion reads as weight, not duration: overshoot slightly, settle. */
export const spring: Transition = { type: "spring", stiffness: 420, damping: 34 };
export const springSoft: Transition = { type: "spring", stiffness: 260, damping: 30 };
export const rise: Variants = {
hidden: { opacity: 0, y: 8 },
shown: { opacity: 1, y: 0 },
};
export const fade: Variants = {
hidden: { opacity: 0 },
shown: { opacity: 1 },
};
/**
* Capped, so item 400 of a long list does not wait eight seconds for its turn.
*/
export function stagger(i: number, step = 0.025, max = 0.3) {
return Math.min(i * step, max);
}
/**
* Entrance props for a list item, honouring the reduced-motion setting.
*
* Note what the reduced-motion branch does NOT do: return `{}`. Dropping the
* props entirely removes `animate` while `initial` has already painted
* opacity 0, and the element stays invisible forever. `useReducedMotion()`
* also reports false on the first render and flips after mount, so that lands
* exactly on the people who asked for less motion and shows them a blank page.
* Instead the element is mounted already in its final state.
*/
export function item(i: number, ok: boolean) {
if (!ok) return { initial: false as const, animate: "shown" as const, variants: rise };
return {
initial: "hidden" as const,
animate: "shown" as const,
variants: rise,
transition: { ...springSoft, delay: stagger(i) },
};
}
/**
* Entrance for a whole view. Deliberately translate-only, with no fade.
*
* This wrapper contains the entire screen, so anything that can leave it at
* opacity 0 leaves the app looking broken rather than looking unanimated. A
* background tab throttles rAF and freezes an in-flight animation part way —
* it resumes on focus, but a stalled fade is a blank page and a stalled slide
* is a few pixels of offset nobody notices. Fades stay on individual items,
* where a stall is cosmetic.
*/
const slide: Variants = {
hidden: { y: 8 },
shown: { y: 0 },
};
export function panel(ok: boolean) {
if (!ok) return { initial: false as const, animate: "shown" as const, variants: slide };
return {
initial: "hidden" as const,
animate: "shown" as const,
variants: slide,
transition: { duration: 0.22, ease: [0.22, 0.9, 0.28, 1] as const },
};
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import * as React from "react";
/**
* URL state for a statically exported app.
*
* There was none: `tab` and the album/artist view lived in React state alone,
* so a refresh always dumped you back at the welcome screen, the browser Back
* button did nothing, and no view could be linked to anyone. `output: "export"`
* rules out dynamic route segments, so the state rides in the query string and
* is driven through the History API.
*/
export type Tab =
| "search"
| "discover"
| "jobs"
| "review"
| "bulk"
| "settings"
| "admin";
export type Nav = {
tab: Tab;
view?: "album" | "artist";
id?: string;
q?: string;
};
const TABS: Tab[] = ["search", "discover", "jobs", "review", "bulk", "settings", "admin"];
export function parse(search: string): Nav {
const p = new URLSearchParams(search);
const raw = p.get("tab");
const tab = (TABS as string[]).includes(raw || "") ? (raw as Tab) : "search";
const v = p.get("view");
return {
tab,
view: v === "album" || v === "artist" ? v : undefined,
id: p.get("id") || undefined,
q: p.get("q") || undefined,
};
}
export function href(n: Nav): string {
const p = new URLSearchParams();
if (n.tab !== "search") p.set("tab", n.tab);
if (n.view) p.set("view", n.view);
if (n.id) p.set("id", n.id);
if (n.q) p.set("q", n.q);
const s = p.toString();
return s ? `?${s}` : "/";
}
/**
* Reads the URL and writes it back, without Next's router.
*
* `next/navigation`'s useSearchParams forces a Suspense boundary under static
* export and re-renders the tree on every keystroke of a search query. The
* History API is enough for what this needs and keeps Back working.
*
* useSyncExternalStore rather than useState-in-an-effect: the URL is external
* state that React does not own, and this is the hook designed for that. It
* also gets the static-export case right — the prerendered HTML has no URL to
* read, so the server snapshot is the empty query, and the client subscribes
* from its first render without a mismatch.
*/
const LOCATION_EVENT = "muse:navigate";
function subscribe(cb: () => void) {
window.addEventListener("popstate", cb);
window.addEventListener(LOCATION_EVENT, cb);
return () => {
window.removeEventListener("popstate", cb);
window.removeEventListener(LOCATION_EVENT, cb);
};
}
const clientSearch = () => window.location.search;
const serverSearch = () => "";
export function useNav(): [Nav, (n: Nav, replace?: boolean) => void] {
const search = React.useSyncExternalStore(subscribe, clientSearch, serverSearch);
const nav = React.useMemo(() => parse(search), [search]);
const go = React.useCallback((n: Nav, replace = false) => {
const url = href(n);
if (replace) window.history.replaceState(null, "", url);
else window.history.pushState(null, "", url);
// pushState does not fire popstate, so tell our own subscribers.
window.dispatchEvent(new Event(LOCATION_EVENT));
}, []);
return [nav, go];
}