React + Vite + TypeScript web client for Lumen, also packaged as a Windows, macOS, and Linux desktop app via Electron.
- Library browser, search, album/artist views, persistent player with queue and scrubbing
- Playlists (create, edit, local sort), favorites, recently played, and a yearly Replay summary
- Share previews for tracks/albums/playlists
- Admin pages: invites (create / list / revoke with copyable one-time registration links) and library/music-root management
- Login, invite-link registration, forced password reset flows
- Desktop build extras: Discord Rich Presence (off by default — create an
application at
discord.com/developers and
put its ID in
.env, copied from .env.example; the build step bakes it into the packaged app, and the app'sconfig.jsoncan override it at runtime), a local now playing API for stream overlays, automatic desktop updates
Styling is Tailwind CSS v4 (via the Vite plugin). Shared logic (API client,
player state, auth, favorites) comes from
@music-library/core — aliased to ../core/src in
vite.config.ts and tsconfig.json, so changes to core are picked up live.
Reusable UI lives under src/components/:
- Layout / chrome:
PageHeader,ListPageHeader,Section,CenteredCard,Shell,DialogShell,DialogFooter - Form primitives:
Button,Field,TextInput,Select,SearchInput,SegmentedControl,RadioCardOption,Fieldset - Data display:
TrackList,MediaCard,PlaylistCard,StatCard,EmptyState,LoadingState,ErrorBanner,DataState - Track-specific:
TrackCheckbox,TrackSelectionToolbar,TrackContextMenu - Admin helpers:
admin/AdminPanel,admin/AdminSection - Library helpers:
library/BrowseToolbar
Shared hooks are in src/lib/:
useTrackSelection— selection mode, range selection, select-all, and export lifecycle for track tables (used byTrackListand playlist detail).
npm install
npm run dev # http://localhost:5173The dev server proxies /api, /share, and /embed to
http://localhost:8080 — run the backend alongside with
COOKIE_SECURE=false so session cookies survive plain HTTP.
npm run typecheck
npm run build # emits dist/Run npm run lint from frontend/, or npm --prefix frontend run lint
from the repository root. CI runs the same command.
eslint.config.js registers @shadcn/lint for
frontend source files alongside the existing ESLint rules. Relative imports
into src/components/ are configured for component recognition; the plugin
automatically discovers the Tailwind theme in src/index.css.
The plugin requires Node.js 20.19+ and ESLint 9.30+.
The following checks run as errors through the existing lint command and CI:
| Rule | Lumen policy |
|---|---|
no-raw-colors |
Use theme colors instead of Tailwind palette colors or literal SVG colors. |
no-unknown-classes |
Classes must exist in Tailwind or the CSS imported by src/index.css. |
no-restyle |
Consumers may place and size components with layout classes; appearance belongs in the component. |
no-arbitrary-values |
Consumers use theme variables and scale values for appearance; arbitrary layout values are allowed. |
require-static-classes |
Component consumers must provide classes the linter can read. |
Use Button's variant and size props for its appearance. Existing named
CSS treatments (such as card-art on CoverArt and playlist-search on
SearchInput) have explicit component contracts in eslint.config.js.
Keep new exceptions specific to the component and an existing CSS class.
Component implementations in src/components/ are exempt from no-restyle,
no-arbitrary-values, and require-static-classes so they can define their
appearance and forward class props. Color and class-existence checks still
apply there.
Use CSS variable shorthand such as text-(--fg-subtle) for Lumen's theme
variables. no-inline-styles remains off because inline styles, including
dynamic dimensions and colors, are part of the existing component APIs.
Inline styles and plain CSS declarations are therefore outside these checks;
a passing lint run does not enforce every styling path.
After UI changes, run lint and fix violations. Review new tokens, component contracts, and suppression comments as design decisions. See the available rules and configuration examples when updating the policy.
npm run lint:ui renders the app in Chromium and checks every main screen,
dialog and menu with lintapple
against ui-policy.json: the type scale and fonts, control sizes and gaps,
text clipping and viewport bounds, contrast, and accessible names. It runs at
390px and 1440px in light and dark. The specs live in tests/ui/; the Vite
dev server serves the app and tests/ui/mock-api.ts answers the API from
fixtures, so no backend is needed. Run npx playwright install --only-shell chromium once first.
Fix findings in the interface. Each exception in ui-policy.json names its
reason; add one only for a deliberate design (such as ellipsis truncation)
or for something the checker can't measure.
npm run electron:compile # compile main/preload
npm run electron:dev # build web + run Electron locally
npm run electron:build # package Windows portable + NSIS installer
npm run electron:build:linux # package Linux AppImage + deb
npm run electron:build:mac # package a DMG for this Mac's architecture
npm run electron:build:mac:universal # package the universal DMG (release artifact)Packaging config is electron-builder.cjs.
Icon files (icon.ico/icon.icns/icon.png/Assets.car) and the DMG
background are committed, so packaging never regenerates them. After changing
the app icon run npm run icons (needs Xcode 26 on macOS for the appearance
variants); after changing the DMG layout run npm run dmg:background.
On macOS, if CSC_NAME and APPLE_KEYCHAIN_PROFILE are set the build also
signs, notarizes, and staples the DMG container automatically (see
AGENTS.md for the one-time credential setup). Without them you
get an unsigned dev build.
GitHub releases intentionally build unsigned binaries for all three desktop platforms. Dispatch the release workflow from the branch you want to ship:
| Branch | Version | Release |
|---|---|---|
main |
0.1.4 |
stable, latest update channel |
dev |
0.1.4-dev.57 |
prerelease, dev update channel |
| anything else | 0.1.4-branch-<name>.57 |
prerelease that no updater follows |
Patch versions count up from the highest stable tag, and the number after the
branch name is the workflow run. To start a new minor or major line, raise
version in package.json. A pushed v* tag builds as exactly that version.
Each release is a draft until every platform has uploaded. Windows may show an unknown-publisher warning and macOS may require
the user to explicitly approve the app in Gatekeeper.
Packaged desktop builds check for updates shortly after launch and every six hours. A found update is offered in a card in the corner of the main pane (and under Updates in Settings): the user downloads it, then chooses Restart & install or lets it install on quit. The Updates section in the tweaks panel lets users choose one of two strictly whitelisted release streams:
mainfollows stable GitHub Releases and readslatest*.ymlmetadata.devfollows prereleases built from thedevbranch and readsdev*.ymlmetadata.
Dev builds default to dev; stable builds default to main. Users may switch
between them and may override the public GitHub repository URL, which must be
an https://github.com/owner/repo URL. UPDATE_REPO_URL is baked into the app
by electron:compile just like DISCORD_CLIENT_ID; it defaults to this
repository and can be set in .env locally or as a GitHub Actions repository
variable.
Auto-update works for installed Windows NSIS builds and supported Linux packages. It is unavailable in the Windows portable build. macOS auto-update requires a signed app and ZIP update artifact; the ZIP is produced by the release workflow, but the intentionally unsigned CI builds cannot auto-update until signing is configured.
Settings → Connections → Now playing API starts a read-only HTTP server
at http://127.0.0.1:48638 for stream overlays (an OBS browser source, say)
and other tools on the same computer. It's off by default and listens on
loopback only. It follows the same track Discord status does, whether or not
that's on: this player, or another of your devices that's playing while this
one isn't.
Every request needs the API's token, as ?token= (which <img> and
EventSource can send) or an Authorization: Bearer header; anything else
gets 401. Settings shows the link with the token in it and can make a new
token, which cuts off links and event streams using the old one. With the
token, any origin may read it (CORS *), so overlays hosted elsewhere work.
| Endpoint | Returns |
|---|---|
GET /now-playing |
The current state as JSON (below) |
GET /cover |
The current track's cover image, or 404 when there's none |
GET /events |
Server-sent events: the same JSON on connect and on every change |
{
"playing": true,
"track": {
"id": "8c1f…",
"title": "Musze To Mieć",
"artist": "Kaz Bałagane",
"album": "Musze To Mieć",
"durationSec": 203,
"positionSec": 181.4,
"coverUrl": "http://127.0.0.1:48638/cover?v=fe60119b72c2&token=…"
}
}track is null when nothing is loaded, and id, artist, album,
durationSec and coverUrl can be null. While playing, positionSec
counts forward from the last player event (to within about a second), so an
overlay can run its own progress bar between messages. coverUrl carries the
token and changes when the cover does. If another app holds port 48638,
Settings says so; set nowPlayingApiPort in the app's config.json and
restart to use another.
A minimal overlay, with TOKEN replaced by the token from Settings:
<img id="cover" width="96" height="96" hidden />
<span id="line"></span>
<script>
const cover = document.getElementById("cover");
const line = document.getElementById("line");
new EventSource("http://127.0.0.1:48638/events?token=TOKEN").onmessage = ({ data }) => {
const { track } = JSON.parse(data);
line.textContent = track ? [track.title, track.artist].filter(Boolean).join(" · ") : "";
cover.hidden = !track?.coverUrl;
if (track?.coverUrl && cover.src !== track.coverUrl) cover.src = track.coverUrl;
};
</script>For a fuller one, docs/now-playing-overlay.html is a card with the cover, a progress bar, a paused mark and scrolling for long titles, ready for an OBS browser source. Its header lists the options (token, port, theme, hiding while paused, scale).
The Dockerfile builds the app and serves dist/ with nginx
(config in nginx.conf). The build context is the repo
root, because the image needs both frontend/ and core/:
docker build -f frontend/Dockerfile .The deployment compose at the repo root does this for you.