Skip to content

Latest commit

 

History

History

frontend

README.md

frontend

React + Vite + TypeScript web client for Lumen, also packaged as a Windows, macOS, and Linux desktop app via Electron.

Features

  • 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's config.json can 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.

Component inventory

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 by TrackList and playlist detail).

Develop

npm install
npm run dev        # http://localhost:5173

The 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/

Lint

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+.

Design-system rules

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.

Rendered UI checks

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.

Electron (desktop)

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.

Desktop updates

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:

  • main follows stable GitHub Releases and reads latest*.yml metadata.
  • dev follows prereleases built from the dev branch and reads dev*.yml metadata.

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.

Now playing API

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).

Docker

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.