Build Resonant v0.1 as a polished single-user Tauri/OpenSubsonic music player with demo playback, responsive library browsing, search, favorites, queue, server setup, tests, and docs
Hop-State: A_06FNDQKBYQT60ENR92VY6KG Hop-Proposal: R_06FNDQJ64YSD232PJ7AT47R Hop-Task: T_06FNDK6YRGJ4TEATJEC6EQR Hop-Attempt: AT_06FNDK6YRMRZ1VHNB189B48
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
src-tauri/target/
|
||||
.DS_Store
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
product
|
||||
|
||||
## Users
|
||||
|
||||
Resonant is for an individual music collector who owns a local library and wants to listen to it across their devices without thinking like a server administrator. The first release optimizes for one listener, one personal library, and the familiar flow of opening an app, finding something meaningful, and pressing play.
|
||||
|
||||
## Product Purpose
|
||||
|
||||
Resonant makes a self-hosted music collection feel as immediate and considered as a premium streaming service. It connects to an OpenSubsonic-compatible server, starting with Navidrome, and turns the user's own files into a beautiful, searchable, dependable personal listening experience. Success means setup fades into the background and the library itself feels alive.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
Calm, tactile, and intimate. Resonant should reach Plexamp's level of fit and finish while maintaining its own quieter identity. The voice is warm and direct, never technical for its own sake and never over-familiar.
|
||||
|
||||
## Anti-references
|
||||
|
||||
Resonant must not resemble a server dashboard, Docker control panel, generic admin template, or a thin Spotify clone. It should avoid neon gamer styling, ornamental glass effects, dense configuration-first screens, novelty playback controls, and copy that foregrounds protocols or infrastructure during ordinary listening.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. Put listening before library management. The current song, the next choice, and the path back into the collection should always be clear.
|
||||
2. Hide the server without hiding control. Use plain language by default and reveal connection details only where they help.
|
||||
3. Make the user's collection feel personal. Artwork, history, favorites, and musical context carry more emotional weight than catalog statistics.
|
||||
4. Earn polish through behavior. Fast feedback, resilient states, keyboard fluency, and precise playback interactions matter as much as visual finish.
|
||||
5. Stay engine-independent. Product concepts map to OpenSubsonic capabilities so Navidrome is a strong default, not a permanent architectural trap.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
Target WCAG 2.2 AA. Support full keyboard navigation, visible focus states, semantic controls, sufficient non-color contrast, screen-reader labels, reduced motion, and layouts that remain usable at 200% zoom. Playback state and connection status must never rely on color alone.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Resonant
|
||||
|
||||
Your library, wherever you listen.
|
||||
|
||||
Resonant is a desktop-first music player for personal, self-hosted collections. It connects directly to Navidrome and other OpenSubsonic-compatible servers while keeping the listening experience calm, immediate, and separate from server administration.
|
||||
|
||||
## What works in v0.1
|
||||
|
||||
- A responsive library home with albums, tracks, favorites, search, and queue management
|
||||
- Functional playback controls with seek, volume, previous, and next actions
|
||||
- An offline demo library with locally synthesized audio, so the app is useful immediately after cloning
|
||||
- OpenSubsonic token authentication, connection testing, album loading, random-track loading, artwork, and streaming URLs
|
||||
- A Tauri 2 desktop shell for macOS, Windows, and Linux development
|
||||
- Keyboard-visible controls, reduced-motion support, semantic labels, and a WCAG 2.2 AA target
|
||||
|
||||
The server password is held only in memory. Resonant remembers the server address and username locally, but secure credential persistence is intentionally deferred until the desktop keychain integration is added.
|
||||
|
||||
## Run it
|
||||
|
||||
Requirements: Node.js 22+, Rust 1.85+, and the platform prerequisites for Tauri 2.
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
To run the desktop shell:
|
||||
|
||||
```sh
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
To validate the full project:
|
||||
|
||||
```sh
|
||||
npm run check
|
||||
```
|
||||
|
||||
## Connect a server
|
||||
|
||||
Open the server control in the lower-left corner, then enter the URL and credentials for a Navidrome or OpenSubsonic server. Browser development requires the server to allow the development origin. The Tauri networking layer will replace browser fetch before packaged distribution so remote libraries do not depend on browser CORS policy.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
Resonant desktop client
|
||||
├── React product interface
|
||||
├── Playback and queue state
|
||||
├── OpenSubsonic client
|
||||
│ ├── salted token authentication
|
||||
│ ├── library and artwork requests
|
||||
│ └── authenticated stream URLs
|
||||
└── Tauri shell
|
||||
|
||||
Navidrome or another OpenSubsonic server
|
||||
├── scans and indexes music
|
||||
├── owns metadata and artwork
|
||||
└── streams or transcodes audio
|
||||
```
|
||||
|
||||
The protocol boundary is deliberate. Resonant owns the product experience without forking the media engine, and the server can be replaced as long as it speaks OpenSubsonic.
|
||||
|
||||
## Next milestones
|
||||
|
||||
1. Move remote requests into Tauri's HTTP plugin and store credentials in the OS keychain.
|
||||
2. Add album and artist detail routes with deterministic library pagination.
|
||||
3. Persist the queue, favorites, and playback position, then synchronize favorites back to the server.
|
||||
4. Add native media keys, system now-playing metadata, ReplayGain, and gapless transition support.
|
||||
5. Bundle an optional managed Navidrome sidecar for the “choose a folder” setup path.
|
||||
|
||||
Product direction and accessibility principles live in [`PRODUCT.md`](./PRODUCT.md).
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#171413" />
|
||||
<meta name="description" content="Resonant, your library wherever you listen." />
|
||||
<title>Resonant</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3298
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "resonant",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"tauri": "tauri",
|
||||
"check": "npm run test && npm run build && cargo check --manifest-path src-tauri/Cargo.toml"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"spark-md5": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "latest",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/spark-md5": "^3.0.5",
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"esbuild": "latest",
|
||||
"jsdom": "latest",
|
||||
"typescript": "latest",
|
||||
"vite": "^8.1.4",
|
||||
"vitest": "latest"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Generated
+4383
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "resonant"
|
||||
version = "0.1.0"
|
||||
description = "Your library, wherever you listen."
|
||||
authors = ["GnosysLabs"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "resonant_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", features = [] }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default desktop permissions for Resonant",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default desktop permissions for Resonant","local":true,"windows":["main"],"permissions":["core:default"]}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="116" fill="#d99265"/>
|
||||
<g fill="none" stroke="#2a1d19" stroke-linecap="round" stroke-width="30">
|
||||
<path d="M130 272v-32"/>
|
||||
<path d="M193 327V185"/>
|
||||
<path d="M256 369V143"/>
|
||||
<path d="M319 327V185"/>
|
||||
<path d="M382 272v-32"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 382 B |
@@ -0,0 +1,6 @@
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Resonant");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
resonant_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Resonant",
|
||||
"version": "0.1.0",
|
||||
"identifier": "xyz.gnosyslabs.resonant",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Resonant",
|
||||
"width": 1280,
|
||||
"height": 820,
|
||||
"minWidth": 760,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src 'self' http: https:; img-src 'self' asset: http: https: data: blob:; media-src 'self' http: https: blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": false,
|
||||
"targets": "all"
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import {
|
||||
Album as AlbumIcon,
|
||||
AudioLines,
|
||||
ChevronRight,
|
||||
CircleUserRound,
|
||||
Disc3,
|
||||
Ellipsis,
|
||||
Heart,
|
||||
Home,
|
||||
Library,
|
||||
ListMusic,
|
||||
Menu,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
Radio,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
Shuffle,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
SlidersHorizontal,
|
||||
Volume2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { albums as demoAlbums, formatTime, tracks as demoTracks } from "./data/demo";
|
||||
import { usePlayer } from "./hooks/usePlayer";
|
||||
import { buildApiUrl, requestSubsonic, streamUrl } from "./lib/subsonic";
|
||||
import type { Album, ConnectionStatus, CoverTone, ServerConfig, Track, View } from "./types";
|
||||
|
||||
const tones: CoverTone[] = ["ember", "moss", "dusk", "clay", "indigo", "sand"];
|
||||
|
||||
function Cover({ album, size = "medium" }: { album: Pick<Album, "title" | "artist" | "coverTone" | "coverUrl">; size?: "small" | "medium" | "large" }) {
|
||||
return (
|
||||
<div className={`cover cover--${album.coverTone} cover--${size}`} aria-label={`${album.title} cover`}>
|
||||
{album.coverUrl ? <img src={album.coverUrl} alt="" /> : <><span>{album.title.slice(0, 2)}</span><i /></>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({ label, children, onClick, active = false, className = "" }: { label: string; children: React.ReactNode; onClick?: () => void; active?: boolean; className?: string }) {
|
||||
return <button type="button" className={`icon-button ${active ? "is-active" : ""} ${className}`} aria-label={label} aria-pressed={active || undefined} onClick={onClick}>{children}</button>;
|
||||
}
|
||||
|
||||
const navItems: Array<{ view: View; label: string; icon: React.ReactNode }> = [
|
||||
{ view: "home", label: "Listen now", icon: <Home size={18} /> },
|
||||
{ view: "albums", label: "Albums", icon: <AlbumIcon size={18} /> },
|
||||
{ view: "artists", label: "Artists", icon: <CircleUserRound size={18} /> },
|
||||
{ view: "favorites", label: "Favorites", icon: <Heart size={18} /> },
|
||||
];
|
||||
|
||||
interface ServerAlbum { id: string; name: string; artist?: string; year?: number; coverArt?: string }
|
||||
interface ServerSong { id: string; title: string; artist?: string; album?: string; albumId?: string; duration?: number; coverArt?: string; starred?: string }
|
||||
|
||||
export default function App() {
|
||||
const [view, setView] = useState<View>("home");
|
||||
const [mobileNav, setMobileNav] = useState(false);
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [libraryAlbums, setLibraryAlbums] = useState<Album[]>(demoAlbums);
|
||||
const [libraryTracks, setLibraryTracks] = useState<Track[]>(demoTracks);
|
||||
const [favorites, setFavorites] = useState(() => new Set(demoTracks.filter((track) => track.favorite).map((track) => track.id)));
|
||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>("demo");
|
||||
const [connectionMessage, setConnectionMessage] = useState("Listening with the demo library");
|
||||
const [server, setServer] = useState<ServerConfig>({
|
||||
url: localStorage.getItem("resonant.serverUrl") ?? "",
|
||||
username: localStorage.getItem("resonant.username") ?? "",
|
||||
password: "",
|
||||
});
|
||||
const player = usePlayer(libraryTracks);
|
||||
|
||||
const visibleTracks = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
let result = view === "favorites" ? libraryTracks.filter((track) => favorites.has(track.id)) : libraryTracks;
|
||||
if (needle) result = result.filter((track) => `${track.title} ${track.artist} ${track.album}`.toLowerCase().includes(needle));
|
||||
return result;
|
||||
}, [favorites, libraryTracks, query, view]);
|
||||
|
||||
const visibleAlbums = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
return needle ? libraryAlbums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(needle)) : libraryAlbums;
|
||||
}, [libraryAlbums, query]);
|
||||
|
||||
const toggleFavorite = (id: string) => setFavorites((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const connect = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setConnectionStatus("connecting");
|
||||
setConnectionMessage("Reaching your library…");
|
||||
try {
|
||||
await requestSubsonic(server, "ping");
|
||||
const [albumResponse, songResponse] = await Promise.all([
|
||||
requestSubsonic<{ albumList2?: { album?: ServerAlbum[] } }>(server, "getAlbumList2", { type: "recent", size: 18 }),
|
||||
requestSubsonic<{ randomSongs?: { song?: ServerSong[] } }>(server, "getRandomSongs", { size: 40 }),
|
||||
]);
|
||||
const nextAlbums = (albumResponse.albumList2?.album ?? []).map((album, index): Album => ({
|
||||
id: album.id,
|
||||
title: album.name,
|
||||
artist: album.artist ?? "Unknown artist",
|
||||
year: album.year ?? 0,
|
||||
coverTone: tones[index % tones.length],
|
||||
coverUrl: album.coverArt ? buildApiUrl(server, "getCoverArt", { id: album.coverArt, size: 600 }).toString() : undefined,
|
||||
}));
|
||||
const nextTracks = (songResponse.randomSongs?.song ?? []).map((song, index): Track => ({
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist ?? "Unknown artist",
|
||||
album: song.album ?? "Unknown album",
|
||||
albumId: song.albumId ?? "",
|
||||
duration: song.duration ?? 0,
|
||||
coverTone: tones[index % tones.length],
|
||||
coverUrl: song.coverArt ? buildApiUrl(server, "getCoverArt", { id: song.coverArt, size: 300 }).toString() : undefined,
|
||||
streamUrl: streamUrl(server, song.id),
|
||||
favorite: Boolean(song.starred),
|
||||
}));
|
||||
if (nextAlbums.length) setLibraryAlbums(nextAlbums);
|
||||
if (nextTracks.length) {
|
||||
setLibraryTracks(nextTracks);
|
||||
setFavorites(new Set(nextTracks.filter((track) => track.favorite).map((track) => track.id)));
|
||||
player.replaceTracks(nextTracks);
|
||||
}
|
||||
localStorage.setItem("resonant.serverUrl", server.url);
|
||||
localStorage.setItem("resonant.username", server.username);
|
||||
setConnectionStatus("connected");
|
||||
setConnectionMessage(`Connected as ${server.username}`);
|
||||
setView("home");
|
||||
} catch (error) {
|
||||
setConnectionStatus("error");
|
||||
setConnectionMessage(error instanceof Error ? error.message : "Could not connect to that server.");
|
||||
}
|
||||
};
|
||||
|
||||
const goTo = (next: View) => {
|
||||
setView(next);
|
||||
setMobileNav(false);
|
||||
if (next !== "favorites") setQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<a className="skip-link" href="#main-content">Skip to library</a>
|
||||
<aside className={`sidebar ${mobileNav ? "is-open" : ""}`}>
|
||||
<div className="brand"><div className="brand-mark"><AudioLines size={19} /></div><span>Resonant</span></div>
|
||||
<nav aria-label="Main navigation">
|
||||
<p className="nav-label">Library</p>
|
||||
{navItems.map((item) => (
|
||||
<button key={item.view} type="button" className={view === item.view ? "active" : ""} onClick={() => goTo(item.view)}>
|
||||
{item.icon}<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<p className="nav-label nav-label--second">Discover</p>
|
||||
<button type="button" onClick={() => goTo("home")}><Radio size={18} /><span>Resonant Radio</span><em>Soon</em></button>
|
||||
</nav>
|
||||
<button className="server-pill" type="button" onClick={() => goTo("settings")}>
|
||||
<span className={`status-dot status-dot--${connectionStatus}`} /><span><strong>{connectionStatus === "connected" ? "Your server" : "Demo library"}</strong><small>{connectionMessage}</small></span><Settings size={16} />
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
{mobileNav && <button className="nav-scrim" type="button" aria-label="Close menu" onClick={() => setMobileNav(false)} />}
|
||||
|
||||
<main id="main-content" tabIndex={-1}>
|
||||
<header className="topbar">
|
||||
<IconButton label="Open menu" className="menu-button" onClick={() => setMobileNav(true)}><Menu size={20} /></IconButton>
|
||||
<label className="search-field"><Search size={17} /><span className="sr-only">Search your library</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search your library" />{query && <button type="button" aria-label="Clear search" onClick={() => setQuery("")}><X size={15} /></button>}</label>
|
||||
<div className="topbar-actions"><IconButton label="Audio settings"><SlidersHorizontal size={18} /></IconButton><button type="button" className="avatar" aria-label="Profile">C</button></div>
|
||||
</header>
|
||||
|
||||
{view === "settings" ? (
|
||||
<section className="settings-view" aria-labelledby="settings-title">
|
||||
<div className="eyebrow"><Server size={15} /> Library source</div>
|
||||
<h1 id="settings-title">Connect your music</h1>
|
||||
<p>Use any Navidrome or OpenSubsonic-compatible server. Your password stays in memory for this session.</p>
|
||||
<form className="connection-form" onSubmit={connect}>
|
||||
<label>Server address<input type="url" required placeholder="https://music.example.com" value={server.url} onChange={(event) => setServer({ ...server, url: event.target.value })} /></label>
|
||||
<div className="form-row"><label>Username<input required autoComplete="username" value={server.username} onChange={(event) => setServer({ ...server, username: event.target.value })} /></label><label>Password<input type="password" required autoComplete="current-password" value={server.password} onChange={(event) => setServer({ ...server, password: event.target.value })} /></label></div>
|
||||
<div className={`connection-feedback connection-feedback--${connectionStatus}`} role="status"><span className={`status-dot status-dot--${connectionStatus}`} />{connectionMessage}</div>
|
||||
<div className="form-actions"><button type="button" className="button-secondary" onClick={() => goTo("home")}>Cancel</button><button type="submit" className="button-primary" disabled={connectionStatus === "connecting"}>{connectionStatus === "connecting" ? "Connecting…" : "Connect library"}<ChevronRight size={17} /></button></div>
|
||||
</form>
|
||||
<div className="privacy-note"><Disc3 size={22} /><div><strong>A client, not another server</strong><p>Resonant speaks OpenSubsonic directly. Navidrome remains in charge of scanning, metadata, artwork, and transcoding.</p></div></div>
|
||||
</section>
|
||||
) : (
|
||||
<div className="library-view">
|
||||
{view === "home" && !query && (
|
||||
<section className="hero" aria-labelledby="hero-title">
|
||||
<div className="hero-copy"><span className="eyebrow">Picked from your library</span><h1 id="hero-title">Let it find you again.</h1><p>{player.current.artist} has been in your rotation lately. Start here, then let the queue wander.</p><div className="hero-actions"><button className="button-primary button-primary--light" type="button" onClick={player.toggle}>{player.isPlaying ? <Pause size={17} fill="currentColor" /> : <Play size={17} fill="currentColor" />} {player.isPlaying ? "Pause" : "Play now"}</button><button className="button-quiet" type="button" onClick={() => player.addToQueue(libraryTracks[Math.min(4, libraryTracks.length - 1)])}><Shuffle size={17} /> Mix from here</button></div></div>
|
||||
<div className="hero-art" aria-hidden="true"><Cover album={player.current} size="large" /><div className="orbit orbit-one" /><div className="orbit orbit-two" /></div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="content-section" aria-labelledby="albums-heading">
|
||||
<div className="section-heading"><div><span className="eyebrow">{query ? "Matching your search" : view === "albums" ? "The full shelf" : "Back in rotation"}</span><h2 id="albums-heading">{view === "favorites" ? "Loved records" : query ? "Albums" : "Recently added"}</h2></div><button type="button" onClick={() => goTo("albums")}>View all <ChevronRight size={16} /></button></div>
|
||||
{visibleAlbums.length ? <div className="album-grid">{visibleAlbums.slice(0, view === "albums" || query ? 18 : 6).map((album) => { const firstTrack = libraryTracks.find((track) => track.albumId === album.id); return <article className="album-tile" key={album.id}><button className="cover-button" type="button" aria-label={`Play ${album.title}`} onClick={() => firstTrack && player.play(firstTrack)}><Cover album={album} /><span className="cover-play"><Play size={20} fill="currentColor" /></span></button><div><h3>{album.title}</h3><p>{album.artist} · {album.year || "Unknown year"}</p></div><IconButton label={`More options for ${album.title}`}><Ellipsis size={18} /></IconButton></article>; })}</div> : <div className="empty-state"><Search size={26} /><h3>No albums found</h3><p>Try a title, artist, or track name.</p></div>}
|
||||
</section>
|
||||
|
||||
<section className="content-section track-section" aria-labelledby="tracks-heading">
|
||||
<div className="section-heading"><div><span className="eyebrow">{view === "favorites" ? "Kept close" : "A familiar thread"}</span><h2 id="tracks-heading">{view === "favorites" ? "Favorite tracks" : "Heavy rotation"}</h2></div><span>{visibleTracks.length} tracks</span></div>
|
||||
<div className="track-list" role="list">{visibleTracks.slice(0, view === "home" && !query ? 6 : 30).map((track, index) => <div className={`track-row ${player.current.id === track.id ? "is-current" : ""}`} role="listitem" key={track.id}><button className="track-main" type="button" onClick={() => player.play(track)}><span className="track-number">{player.current.id === track.id && player.isPlaying ? <AudioLines size={15} /> : String(index + 1).padStart(2, "0")}</span><Cover album={track} size="small" /><span><strong>{track.title}</strong><small>{track.artist}</small></span></button><span className="track-album">{track.album}</span><IconButton label={favorites.has(track.id) ? `Remove ${track.title} from favorites` : `Add ${track.title} to favorites`} active={favorites.has(track.id)} onClick={() => toggleFavorite(track.id)}><Heart size={16} fill={favorites.has(track.id) ? "currentColor" : "none"} /></IconButton><span className="track-time">{formatTime(track.duration)}</span><IconButton label={`Add ${track.title} to queue`} onClick={() => player.addToQueue(track)}><Plus size={17} /></IconButton></div>)}</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside className={`queue-panel ${queueOpen ? "is-open" : ""}`} aria-label="Play queue" aria-hidden={!queueOpen}><div className="queue-header"><div><span className="eyebrow">Up next</span><h2>Your queue</h2></div><IconButton label="Close queue" onClick={() => setQueueOpen(false)}><X size={19} /></IconButton></div><div className="queue-now"><Cover album={player.current} size="medium" /><span>Now playing</span><strong>{player.current.title}</strong><small>{player.current.artist}</small></div><div className="queue-list">{player.queue.map((track, index) => <div className="queue-item" key={`${track.id}-${index}`}><button type="button" onClick={() => player.play(track)}><span>{index + 1}</span><div><strong>{track.title}</strong><small>{track.artist}</small></div></button><IconButton label={`Remove ${track.title} from queue`} onClick={() => player.removeFromQueue(track.id)}><X size={15} /></IconButton></div>)}</div></aside>
|
||||
|
||||
<footer className="player-bar">
|
||||
<button className="now-playing" type="button" onClick={() => setQueueOpen(true)}><Cover album={player.current} size="small" /><span><strong>{player.current.title}</strong><small>{player.current.artist}</small></span><Heart size={16} className={favorites.has(player.current.id) ? "filled-heart" : ""} fill={favorites.has(player.current.id) ? "currentColor" : "none"} /></button>
|
||||
<div className="transport"><div className="transport-buttons"><IconButton label="Shuffle"><Shuffle size={16} /></IconButton><IconButton label="Previous track" onClick={player.previous}><SkipBack size={19} fill="currentColor" /></IconButton><button className="play-button" type="button" aria-label={player.isPlaying ? "Pause" : "Play"} onClick={player.toggle}>{player.isPlaying ? <Pause size={20} fill="currentColor" /> : <Play size={20} fill="currentColor" />}</button><IconButton label="Next track" onClick={player.next}><SkipForward size={19} fill="currentColor" /></IconButton><IconButton label="Repeat"><Radio size={16} /></IconButton></div><div className="scrubber"><span>{formatTime(player.progress)}</span><input type="range" aria-label="Playback position" min="0" max={Math.max(player.duration, 1)} step="1" value={Math.min(player.progress, player.duration)} onChange={(event) => player.seek(Number(event.target.value))} style={{ "--range-progress": `${(player.progress / Math.max(player.duration, 1)) * 100}%` } as React.CSSProperties} /><span>{formatTime(player.duration)}</span></div></div>
|
||||
<div className="player-tools"><IconButton label="Open queue" active={queueOpen} onClick={() => setQueueOpen(!queueOpen)}><ListMusic size={19} /></IconButton><Volume2 size={18} /><input type="range" aria-label="Volume" min="0" max="1" step="0.01" value={player.volume} onChange={(event) => player.changeVolume(Number(event.target.value))} style={{ "--range-progress": `${player.volume * 100}%` } as React.CSSProperties} /></div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Album, Track } from "../types";
|
||||
|
||||
export const albums: Album[] = [
|
||||
{ id: "a1", title: "Afterimage", artist: "Hollow Cities", year: 2026, coverTone: "ember" },
|
||||
{ id: "a2", title: "Soft Geometry", artist: "Night Archive", year: 2025, coverTone: "moss" },
|
||||
{ id: "a3", title: "A Quiet Current", artist: "Mara Venn", year: 2024, coverTone: "dusk" },
|
||||
{ id: "a4", title: "Signal Fires", artist: "Orchard Bloom", year: 2026, coverTone: "clay" },
|
||||
{ id: "a5", title: "Low Season", artist: "North Window", year: 2023, coverTone: "indigo" },
|
||||
{ id: "a6", title: "Rooms of Air", artist: "Serein", year: 2025, coverTone: "sand" },
|
||||
];
|
||||
|
||||
export const tracks: Track[] = [
|
||||
{ id: "t1", title: "Still Life in Motion", artist: "Hollow Cities", album: "Afterimage", albumId: "a1", duration: 238, coverTone: "ember", favorite: true },
|
||||
{ id: "t2", title: "Violet Hours", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 284, coverTone: "moss" },
|
||||
{ id: "t3", title: "Understory", artist: "Mara Venn", album: "A Quiet Current", albumId: "a3", duration: 217, coverTone: "dusk", favorite: true },
|
||||
{ id: "t4", title: "Antenna Weather", artist: "Orchard Bloom", album: "Signal Fires", albumId: "a4", duration: 195, coverTone: "clay" },
|
||||
{ id: "t5", title: "The Long Interior", artist: "North Window", album: "Low Season", albumId: "a5", duration: 309, coverTone: "indigo" },
|
||||
{ id: "t6", title: "Nearer Than Before", artist: "Serein", album: "Rooms of Air", albumId: "a6", duration: 246, coverTone: "sand" },
|
||||
{ id: "t7", title: "Everything Returns", artist: "Hollow Cities", album: "Afterimage", albumId: "a1", duration: 261, coverTone: "ember" },
|
||||
{ id: "t8", title: "Unfolding", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 228, coverTone: "moss" },
|
||||
];
|
||||
|
||||
export const formatTime = (seconds: number) => {
|
||||
if (!Number.isFinite(seconds)) return "0:00";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${minutes}:${Math.floor(seconds % 60).toString().padStart(2, "0")}`;
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Track } from "../types";
|
||||
|
||||
export function usePlayer(initialTracks: Track[]) {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [current, setCurrent] = useState<Track>(initialTracks[0]);
|
||||
const [queue, setQueue] = useState<Track[]>(initialTracks.slice(1, 5));
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [duration, setDuration] = useState(initialTracks[0]?.duration ?? 0);
|
||||
const [volume, setVolume] = useState(0.72);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = new Audio();
|
||||
audio.preload = "metadata";
|
||||
audio.volume = volume;
|
||||
audioRef.current = audio;
|
||||
const update = () => {
|
||||
setProgress(audio.currentTime);
|
||||
if (Number.isFinite(audio.duration)) setDuration(audio.duration);
|
||||
};
|
||||
const ended = () => {
|
||||
setQueue((items) => {
|
||||
const next = items[0];
|
||||
if (next) setCurrent(next);
|
||||
else setIsPlaying(false);
|
||||
return next ? items.slice(1) : items;
|
||||
});
|
||||
};
|
||||
audio.addEventListener("timeupdate", update);
|
||||
audio.addEventListener("loadedmetadata", update);
|
||||
audio.addEventListener("ended", ended);
|
||||
return () => {
|
||||
audio.pause();
|
||||
audio.removeEventListener("timeupdate", update);
|
||||
audio.removeEventListener("loadedmetadata", update);
|
||||
audio.removeEventListener("ended", ended);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !current) return;
|
||||
audio.src = current.streamUrl ?? "/demo-tone.wav";
|
||||
audio.load();
|
||||
setProgress(0);
|
||||
setDuration(current.streamUrl ? current.duration : 24);
|
||||
if (isPlaying) void audio.play().catch(() => setIsPlaying(false));
|
||||
}, [current]);
|
||||
|
||||
const play = useCallback((track: Track) => {
|
||||
setCurrent(track);
|
||||
setIsPlaying(true);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (audio.paused) {
|
||||
void audio.play().then(() => setIsPlaying(true)).catch(() => setIsPlaying(false));
|
||||
} else {
|
||||
audio.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const seek = useCallback((next: number) => {
|
||||
if (!audioRef.current) return;
|
||||
audioRef.current.currentTime = next;
|
||||
setProgress(next);
|
||||
}, []);
|
||||
|
||||
const changeVolume = useCallback((next: number) => {
|
||||
setVolume(next);
|
||||
if (audioRef.current) audioRef.current.volume = next;
|
||||
}, []);
|
||||
|
||||
const next = useCallback(() => {
|
||||
const nextTrack = queue[0];
|
||||
if (!nextTrack) return;
|
||||
setCurrent(nextTrack);
|
||||
setQueue((items) => items.slice(1));
|
||||
}, [queue]);
|
||||
|
||||
const previous = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (audio && audio.currentTime > 3) {
|
||||
audio.currentTime = 0;
|
||||
return;
|
||||
}
|
||||
const index = initialTracks.findIndex((track) => track.id === current.id);
|
||||
if (index > 0) setCurrent(initialTracks[index - 1]);
|
||||
}, [current.id, initialTracks]);
|
||||
|
||||
return {
|
||||
current,
|
||||
queue,
|
||||
isPlaying,
|
||||
progress,
|
||||
duration,
|
||||
volume,
|
||||
play,
|
||||
toggle,
|
||||
seek,
|
||||
changeVolume,
|
||||
next,
|
||||
previous,
|
||||
addToQueue: (track: Track) => setQueue((items) => [...items, track]),
|
||||
removeFromQueue: (id: string) => setQueue((items) => items.filter((track) => track.id !== id)),
|
||||
replaceTracks: (items: Track[]) => {
|
||||
if (items.length === 0) return;
|
||||
setCurrent(items[0]);
|
||||
setQueue(items.slice(1, 8));
|
||||
setIsPlaying(false);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildApiUrl } from "./subsonic";
|
||||
|
||||
describe("buildApiUrl", () => {
|
||||
it("builds a token-authenticated OpenSubsonic URL", () => {
|
||||
const url = buildApiUrl(
|
||||
{ url: "https://music.example.test", username: "listener", password: "secret" },
|
||||
"getAlbumList2",
|
||||
{ type: "recent", size: 12 },
|
||||
"fixedsalt",
|
||||
);
|
||||
|
||||
expect(url.pathname).toBe("/rest/getAlbumList2.view");
|
||||
expect(url.searchParams.get("u")).toBe("listener");
|
||||
expect(url.searchParams.get("s")).toBe("fixedsalt");
|
||||
expect(url.searchParams.get("t")).toMatch(/^[a-f0-9]{32}$/);
|
||||
expect(url.searchParams.get("type")).toBe("recent");
|
||||
expect(url.searchParams.get("size")).toBe("12");
|
||||
expect(url.search).not.toContain("secret");
|
||||
});
|
||||
|
||||
it("normalizes trailing slashes and endpoint suffixes", () => {
|
||||
vi.stubGlobal("crypto", { getRandomValues: (array: Uint8Array) => array.fill(7) });
|
||||
const url = buildApiUrl(
|
||||
{ url: "https://music.example.test/base/", username: "u", password: "p" },
|
||||
"/ping.view",
|
||||
);
|
||||
expect(url.pathname).toBe("/base/rest/ping.view");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import SparkMD5 from "spark-md5";
|
||||
import type { ServerConfig } from "../types";
|
||||
|
||||
const CLIENT_NAME = "resonant";
|
||||
const API_VERSION = "1.16.1";
|
||||
|
||||
export class SubsonicError extends Error {
|
||||
constructor(message: string, public readonly code?: number) {
|
||||
super(message);
|
||||
this.name = "SubsonicError";
|
||||
}
|
||||
}
|
||||
|
||||
export function createSalt(bytes = 6): string {
|
||||
const values = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(values);
|
||||
return Array.from(values, (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function buildApiUrl(
|
||||
config: ServerConfig,
|
||||
endpoint: string,
|
||||
params: Record<string, string | number | boolean | undefined> = {},
|
||||
salt = createSalt(),
|
||||
): URL {
|
||||
const base = config.url.endsWith("/") ? config.url : `${config.url}/`;
|
||||
const cleanEndpoint = endpoint.replace(/^\/+|\.view$/g, "");
|
||||
const url = new URL(`rest/${cleanEndpoint}.view`, base);
|
||||
const query = new URLSearchParams({
|
||||
u: config.username,
|
||||
t: SparkMD5.hash(`${config.password}${salt}`),
|
||||
s: salt,
|
||||
v: API_VERSION,
|
||||
c: CLIENT_NAME,
|
||||
f: "json",
|
||||
});
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) query.set(key, String(value));
|
||||
});
|
||||
url.search = query.toString();
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function requestSubsonic<T>(
|
||||
config: ServerConfig,
|
||||
endpoint: string,
|
||||
params?: Record<string, string | number | boolean | undefined>,
|
||||
): Promise<T> {
|
||||
const response = await fetch(buildApiUrl(config, endpoint, params), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) throw new SubsonicError(`Server returned ${response.status}.`);
|
||||
|
||||
const payload = await response.json();
|
||||
const envelope = payload["subsonic-response"];
|
||||
if (!envelope || envelope.status !== "ok") {
|
||||
throw new SubsonicError(envelope?.error?.message ?? "The server rejected the request.", envelope?.error?.code);
|
||||
}
|
||||
return envelope as T;
|
||||
}
|
||||
|
||||
export function streamUrl(config: ServerConfig, trackId: string): string {
|
||||
return buildApiUrl(config, "stream", { id: trackId }).toString();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap");
|
||||
|
||||
:root {
|
||||
font-family: "DM Sans", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: oklch(0.91 0.012 55);
|
||||
background: oklch(0.145 0.012 38);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
--ink: oklch(0.91 0.012 55);
|
||||
--muted: oklch(0.67 0.018 45);
|
||||
--faint: oklch(0.49 0.015 45);
|
||||
--canvas: oklch(0.145 0.012 38);
|
||||
--surface: oklch(0.18 0.014 38);
|
||||
--surface-raised: oklch(0.225 0.017 39);
|
||||
--line: oklch(0.31 0.018 42 / 0.66);
|
||||
--accent: oklch(0.69 0.135 39);
|
||||
--accent-bright: oklch(0.76 0.13 45);
|
||||
--success: oklch(0.72 0.12 145);
|
||||
--danger: oklch(0.68 0.16 24);
|
||||
--focus: oklch(0.82 0.115 78);
|
||||
--player-height: 94px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { min-height: 100%; margin: 0; }
|
||||
body { min-width: 320px; min-height: 100vh; overflow: hidden; }
|
||||
button, input { font: inherit; }
|
||||
button { color: inherit; }
|
||||
button, input[type="range"] { cursor: pointer; }
|
||||
button:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
|
||||
button:disabled { cursor: wait; opacity: 0.65; }
|
||||
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
.skip-link { position: fixed; z-index: 100; top: 12px; left: 12px; padding: 10px 14px; border-radius: 8px; color: var(--canvas); background: var(--focus); transform: translateY(-150%); transition: transform 160ms cubic-bezier(.22, 1, .36, 1); }
|
||||
.skip-link:focus { transform: translateY(0); }
|
||||
|
||||
.app-shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); height: 100vh; background: var(--canvas); }
|
||||
|
||||
.sidebar { position: relative; z-index: 30; display: flex; flex-direction: column; min-width: 0; padding: 25px 16px 16px; border-right: 1px solid var(--line); background: oklch(0.16 0.014 38); }
|
||||
.brand { display: flex; align-items: center; gap: 10px; padding: 0 8px 33px; font-size: 1.06rem; font-weight: 700; letter-spacing: -0.03em; }
|
||||
.brand-mark { display: grid; width: 29px; height: 29px; place-items: center; border-radius: 9px; color: oklch(0.2 0.05 37); background: var(--accent-bright); box-shadow: 0 8px 30px oklch(0.59 0.13 37 / 0.18); }
|
||||
.sidebar nav { display: grid; gap: 4px; }
|
||||
.nav-label { margin: 0 10px 8px; color: var(--faint); font-size: 0.67rem; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; }
|
||||
.nav-label--second { margin-top: 25px; }
|
||||
.sidebar nav button { display: flex; width: 100%; align-items: center; gap: 11px; padding: 9px 10px; border: 0; border-radius: 9px; color: var(--muted); background: transparent; font-size: 0.86rem; font-weight: 500; text-align: left; transition: color 160ms ease, background 160ms ease; }
|
||||
.sidebar nav button:hover { color: var(--ink); background: oklch(0.24 0.015 40 / 0.58); }
|
||||
.sidebar nav button.active { color: var(--ink); background: oklch(0.28 0.029 39 / 0.72); }
|
||||
.sidebar nav button.active svg { color: var(--accent-bright); }
|
||||
.sidebar nav em { margin-left: auto; padding: 2px 6px; border: 1px solid var(--line); border-radius: 99px; color: var(--faint); font-size: 0.58rem; font-style: normal; letter-spacing: 0.05em; text-transform: uppercase; }
|
||||
|
||||
.server-pill { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 9px; width: 100%; margin-top: auto; padding: 10px; border: 1px solid var(--line); border-radius: 11px; background: oklch(0.205 0.016 39); text-align: left; }
|
||||
.server-pill:hover { border-color: oklch(0.42 0.03 43); background: oklch(0.225 0.018 39); }
|
||||
.server-pill strong, .server-pill small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.server-pill strong { font-size: 0.72rem; font-weight: 600; }
|
||||
.server-pill small { margin-top: 2px; color: var(--faint); font-size: 0.61rem; }
|
||||
.server-pill > svg { color: var(--faint); }
|
||||
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); box-shadow: 0 0 0 4px oklch(0.55 0.01 40 / 0.12); }
|
||||
.status-dot--connected { background: var(--success); box-shadow: 0 0 0 4px oklch(0.72 0.12 145 / 0.12); }
|
||||
.status-dot--connecting { background: var(--focus); animation: pulse 1.3s ease-in-out infinite; }
|
||||
.status-dot--error { background: var(--danger); box-shadow: 0 0 0 4px oklch(0.68 0.16 24 / 0.12); }
|
||||
|
||||
main { min-width: 0; height: calc(100vh - var(--player-height)); overflow-y: auto; scrollbar-color: oklch(0.34 0.018 42) transparent; }
|
||||
.topbar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; gap: 18px; height: 68px; padding: 0 34px; background: oklch(0.145 0.012 38 / 0.94); border-bottom: 1px solid oklch(0.28 0.015 40 / 0.5); }
|
||||
.search-field { display: flex; width: min(380px, 48vw); align-items: center; gap: 9px; padding: 8px 11px; border: 1px solid transparent; border-radius: 9px; color: var(--faint); background: oklch(0.2 0.013 39); transition: border-color 160ms ease, background 160ms ease; }
|
||||
.search-field:focus-within { border-color: oklch(0.49 0.06 41); background: var(--surface-raised); }
|
||||
.search-field input { width: 100%; padding: 0; border: 0; outline: 0; color: var(--ink); background: transparent; font-size: 0.82rem; }
|
||||
.search-field input::placeholder { color: var(--faint); }
|
||||
.search-field button { display: grid; padding: 2px; border: 0; background: transparent; color: var(--muted); }
|
||||
.topbar-actions { display: flex; align-items: center; gap: 7px; margin-left: auto; }
|
||||
.avatar { display: grid; width: 31px; height: 31px; margin-left: 4px; place-items: center; border: 0; border-radius: 50%; color: oklch(0.21 0.03 40); background: oklch(0.77 0.075 61); font-size: 0.73rem; font-weight: 700; }
|
||||
.menu-button { display: none !important; }
|
||||
|
||||
.icon-button { display: inline-grid; width: 32px; height: 32px; flex: 0 0 auto; place-items: center; padding: 0; border: 0; border-radius: 8px; color: var(--muted); background: transparent; transition: color 150ms ease, background 150ms ease, transform 150ms ease; }
|
||||
.icon-button:hover { color: var(--ink); background: oklch(0.3 0.015 40 / 0.58); }
|
||||
.icon-button:active { transform: scale(0.94); }
|
||||
.icon-button.is-active { color: var(--accent-bright); background: oklch(0.69 0.135 39 / 0.12); }
|
||||
|
||||
.library-view { padding: 26px 34px 70px; }
|
||||
.hero { position: relative; display: grid; min-height: 318px; grid-template-columns: minmax(0, 1fr) minmax(280px, 40%); align-items: center; overflow: hidden; margin-bottom: 45px; padding: 42px 58px; border-radius: 20px; background: oklch(0.26 0.045 36); box-shadow: 0 24px 80px oklch(0.06 0.02 35 / 0.26); isolation: isolate; }
|
||||
.hero::before { position: absolute; z-index: -1; inset: 0; background: radial-gradient(circle at 86% 20%, oklch(0.67 0.11 37 / 0.32), transparent 34%), linear-gradient(118deg, oklch(0.275 0.055 35), oklch(0.205 0.026 42)); content: ""; }
|
||||
.hero-copy { position: relative; z-index: 2; max-width: 540px; }
|
||||
.eyebrow { display: inline-flex; align-items: center; gap: 7px; color: var(--accent-bright); font-size: 0.66rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||
.hero h1 { max-width: 500px; margin: 12px 0 13px; color: oklch(0.95 0.014 59); font-size: clamp(2.4rem, 4.4vw, 4.2rem); font-weight: 600; letter-spacing: -0.06em; line-height: 0.98; }
|
||||
.hero p { max-width: 51ch; margin: 0; color: oklch(0.76 0.018 51); font-size: 0.96rem; line-height: 1.62; }
|
||||
.hero-actions { display: flex; gap: 10px; margin-top: 27px; }
|
||||
.button-primary, .button-secondary, .button-quiet { display: inline-flex; min-height: 39px; align-items: center; justify-content: center; gap: 8px; padding: 0 15px; border-radius: 9px; font-size: 0.78rem; font-weight: 700; transition: transform 150ms ease, background 150ms ease, border-color 150ms ease; }
|
||||
.button-primary { border: 1px solid var(--accent); color: oklch(0.18 0.02 37); background: var(--accent-bright); }
|
||||
.button-primary--light { border-color: oklch(0.95 0.01 60); color: oklch(0.18 0.02 37); background: oklch(0.95 0.01 60); }
|
||||
.button-primary:hover { transform: translateY(-1px); background: oklch(0.81 0.12 48); }
|
||||
.button-secondary { border: 1px solid var(--line); color: var(--ink); background: transparent; }
|
||||
.button-secondary:hover { border-color: oklch(0.48 0.025 42); background: var(--surface-raised); }
|
||||
.button-quiet { border: 1px solid oklch(0.79 0.03 50 / 0.22); color: oklch(0.88 0.012 56); background: oklch(0.15 0.02 37 / 0.25); }
|
||||
.button-quiet:hover { background: oklch(0.74 0.03 50 / 0.13); }
|
||||
.hero-art { position: relative; display: grid; min-height: 236px; place-items: center; }
|
||||
.hero-art .cover { position: relative; z-index: 2; transform: rotate(3deg); box-shadow: -20px 28px 70px oklch(0.08 0.02 38 / 0.48); }
|
||||
.orbit { position: absolute; width: 290px; height: 290px; border: 1px solid oklch(0.82 0.08 48 / 0.13); border-radius: 50%; }
|
||||
.orbit-one { transform: translate(38px, -13px); }
|
||||
.orbit-two { width: 210px; height: 210px; transform: translate(-65px, 18px); }
|
||||
|
||||
.content-section { margin-bottom: 45px; }
|
||||
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 18px; }
|
||||
.section-heading h2 { margin: 5px 0 0; color: var(--ink); font-size: 1.45rem; font-weight: 600; letter-spacing: -0.035em; }
|
||||
.section-heading > button { display: flex; align-items: center; gap: 3px; padding: 5px 0; border: 0; color: var(--muted); background: transparent; font-size: 0.73rem; }
|
||||
.section-heading > button:hover { color: var(--ink); }
|
||||
.section-heading > span { color: var(--faint); font-size: 0.72rem; }
|
||||
.album-grid { display: grid; grid-template-columns: repeat(6, minmax(110px, 1fr)); gap: clamp(14px, 2vw, 25px); }
|
||||
.album-tile { position: relative; min-width: 0; }
|
||||
.album-tile > div:not(.cover) { min-width: 0; padding-top: 11px; }
|
||||
.album-tile h3, .album-tile p { overflow: hidden; margin: 0; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.album-tile h3 { color: var(--ink); font-size: 0.83rem; font-weight: 600; }
|
||||
.album-tile p { margin-top: 4px; color: var(--faint); font-size: 0.68rem; }
|
||||
.album-tile > .icon-button { position: absolute; right: -5px; bottom: 12px; opacity: 0; }
|
||||
.album-tile:hover > .icon-button, .album-tile:focus-within > .icon-button { opacity: 1; }
|
||||
.cover-button { position: relative; display: block; width: 100%; padding: 0; border: 0; border-radius: 10px; background: transparent; }
|
||||
.cover-button:hover .cover { transform: translateY(-4px); box-shadow: 0 18px 38px oklch(0.06 0.02 38 / 0.36); }
|
||||
.cover-play { position: absolute; right: 10px; bottom: 10px; display: grid; width: 38px; height: 38px; place-items: center; border-radius: 50%; color: oklch(0.16 0.02 38); background: oklch(0.93 0.01 60); box-shadow: 0 8px 24px oklch(0.08 0.02 35 / 0.45); opacity: 0; transform: translateY(7px); transition: opacity 180ms ease, transform 180ms cubic-bezier(.22, 1, .36, 1); }
|
||||
.cover-button:hover .cover-play, .cover-button:focus-visible .cover-play { opacity: 1; transform: translateY(0); }
|
||||
|
||||
.cover { position: relative; display: grid; aspect-ratio: 1; overflow: hidden; place-items: center; border-radius: 9px; isolation: isolate; transition: transform 180ms cubic-bezier(.22, 1, .36, 1), box-shadow 180ms ease; }
|
||||
.cover img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.cover span { position: relative; z-index: 2; color: oklch(0.94 0.018 60 / 0.77); font-size: 1.55rem; font-weight: 700; letter-spacing: -0.1em; text-transform: uppercase; }
|
||||
.cover i { position: absolute; width: 56%; height: 56%; border: 1px solid oklch(0.92 0.04 60 / 0.3); border-radius: 50%; box-shadow: 0 0 0 18px oklch(0.9 0.04 60 / 0.06), 0 0 0 38px oklch(0.9 0.04 60 / 0.04); }
|
||||
.cover--medium { width: 100%; }
|
||||
.cover--small { width: 42px; min-width: 42px; border-radius: 7px; }
|
||||
.cover--small span { font-size: 0.68rem; }
|
||||
.cover--large { width: min(250px, 27vw); }
|
||||
.cover--large span { font-size: 2.4rem; }
|
||||
.cover--ember { background: radial-gradient(circle at 68% 28%, oklch(0.78 0.12 53), transparent 23%), linear-gradient(145deg, oklch(0.56 0.16 25), oklch(0.25 0.055 15)); }
|
||||
.cover--moss { background: radial-gradient(circle at 30% 65%, oklch(0.76 0.08 113), transparent 21%), linear-gradient(135deg, oklch(0.43 0.09 145), oklch(0.21 0.035 128)); }
|
||||
.cover--dusk { background: radial-gradient(circle at 72% 32%, oklch(0.72 0.1 305), transparent 18%), linear-gradient(145deg, oklch(0.44 0.1 281), oklch(0.21 0.04 300)); }
|
||||
.cover--clay { background: radial-gradient(circle at 65% 75%, oklch(0.79 0.09 65), transparent 19%), linear-gradient(150deg, oklch(0.58 0.12 42), oklch(0.24 0.05 31)); }
|
||||
.cover--indigo { background: radial-gradient(circle at 34% 28%, oklch(0.69 0.12 247), transparent 18%), linear-gradient(140deg, oklch(0.4 0.13 261), oklch(0.18 0.04 270)); }
|
||||
.cover--sand { background: radial-gradient(circle at 68% 30%, oklch(0.86 0.065 84), transparent 22%), linear-gradient(145deg, oklch(0.67 0.08 74), oklch(0.29 0.045 53)); }
|
||||
|
||||
.track-list { overflow: hidden; border-top: 1px solid var(--line); }
|
||||
.track-row { display: grid; grid-template-columns: minmax(245px, 1.5fr) minmax(130px, 1fr) 34px 48px 34px; min-height: 57px; align-items: center; border-bottom: 1px solid oklch(0.28 0.015 40 / 0.55); color: var(--muted); transition: background 140ms ease; }
|
||||
.track-row:hover, .track-row:focus-within { background: oklch(0.21 0.015 39 / 0.72); }
|
||||
.track-row.is-current { color: var(--accent-bright); background: oklch(0.23 0.025 39 / 0.62); }
|
||||
.track-main { display: grid; min-width: 0; height: 100%; grid-template-columns: 31px 42px minmax(0, 1fr); align-items: center; gap: 11px; padding: 7px 12px 7px 0; border: 0; color: inherit; background: transparent; text-align: left; }
|
||||
.track-main span:last-child { min-width: 0; }
|
||||
.track-main strong, .track-main small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.track-main strong { color: var(--ink); font-size: 0.78rem; font-weight: 600; }
|
||||
.is-current .track-main strong { color: var(--accent-bright); }
|
||||
.track-main small { margin-top: 3px; color: var(--faint); font-size: 0.66rem; }
|
||||
.track-number { display: grid; place-items: center; color: var(--faint); font-size: 0.65rem; font-variant-numeric: tabular-nums; }
|
||||
.track-album { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.7rem; }
|
||||
.track-time { color: var(--faint); font-size: 0.68rem; font-variant-numeric: tabular-nums; }
|
||||
.track-row .icon-button { width: 29px; height: 29px; }
|
||||
.track-row .icon-button.is-active { background: transparent; }
|
||||
|
||||
.empty-state { display: grid; min-height: 220px; place-items: center; align-content: center; border-top: 1px solid var(--line); color: var(--faint); text-align: center; }
|
||||
.empty-state h3 { margin: 12px 0 3px; color: var(--ink); font-size: 0.9rem; }
|
||||
.empty-state p { margin: 0; font-size: 0.75rem; }
|
||||
|
||||
.settings-view { width: min(760px, calc(100% - 64px)); margin: 70px auto 120px; }
|
||||
.settings-view h1 { margin: 10px 0 9px; font-size: 2.35rem; font-weight: 600; letter-spacing: -0.055em; }
|
||||
.settings-view > p { max-width: 58ch; margin: 0 0 32px; color: var(--muted); line-height: 1.6; }
|
||||
.connection-form { display: grid; gap: 20px; padding: 28px; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); }
|
||||
.connection-form label { display: grid; gap: 8px; color: var(--muted); font-size: 0.71rem; font-weight: 600; }
|
||||
.connection-form input { width: 100%; height: 42px; padding: 0 12px; border: 1px solid var(--line); border-radius: 8px; outline: 0; color: var(--ink); background: oklch(0.145 0.012 38); font-size: 0.82rem; }
|
||||
.connection-form input:focus { border-color: var(--focus); box-shadow: 0 0 0 3px oklch(0.82 0.115 78 / 0.09); }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 9px; }
|
||||
.connection-feedback { display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 12px; border-radius: 8px; color: var(--muted); background: oklch(0.21 0.015 39); font-size: 0.73rem; }
|
||||
.connection-feedback--error { color: oklch(0.81 0.09 25); background: oklch(0.28 0.055 24); }
|
||||
.connection-feedback--connected { color: oklch(0.81 0.08 145); background: oklch(0.26 0.05 145); }
|
||||
.privacy-note { display: flex; gap: 14px; margin-top: 20px; padding: 7px 14px; color: var(--muted); }
|
||||
.privacy-note svg { flex: 0 0 auto; margin-top: 3px; color: var(--accent-bright); }
|
||||
.privacy-note strong { color: var(--ink); font-size: 0.78rem; }
|
||||
.privacy-note p { max-width: 64ch; margin: 5px 0 0; font-size: 0.72rem; line-height: 1.55; }
|
||||
|
||||
.player-bar { position: fixed; z-index: 50; right: 0; bottom: 0; left: 0; display: grid; height: var(--player-height); grid-template-columns: minmax(210px, 1fr) minmax(320px, 1.5fr) minmax(210px, 1fr); align-items: center; gap: 18px; padding: 10px 21px; border-top: 1px solid oklch(0.37 0.025 42 / 0.75); background: oklch(0.175 0.016 38); box-shadow: 0 -16px 45px oklch(0.06 0.018 35 / 0.22); }
|
||||
.now-playing { display: flex; min-width: 0; align-items: center; gap: 11px; padding: 0; border: 0; background: transparent; text-align: left; }
|
||||
.now-playing > span { min-width: 0; }
|
||||
.now-playing strong, .now-playing small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.now-playing strong { color: var(--ink); font-size: 0.75rem; }
|
||||
.now-playing small { margin-top: 3px; color: var(--faint); font-size: 0.65rem; }
|
||||
.now-playing > svg { flex: 0 0 auto; margin-left: 6px; color: var(--muted); }
|
||||
.now-playing > svg.filled-heart { color: var(--accent-bright); }
|
||||
.transport { display: grid; justify-items: center; gap: 7px; }
|
||||
.transport-buttons { display: flex; align-items: center; gap: 8px; }
|
||||
.play-button { display: grid; width: 37px; height: 37px; margin: 0 3px; place-items: center; padding: 0; border: 0; border-radius: 50%; color: oklch(0.18 0.02 38); background: oklch(0.93 0.01 58); transition: transform 150ms ease, background 150ms ease; }
|
||||
.play-button:hover { transform: scale(1.05); background: oklch(0.98 0.008 58); }
|
||||
.scrubber { display: grid; width: min(520px, 100%); grid-template-columns: 35px minmax(80px, 1fr) 35px; align-items: center; gap: 8px; color: var(--faint); font-size: 0.59rem; font-variant-numeric: tabular-nums; }
|
||||
.scrubber span:last-child { text-align: right; }
|
||||
input[type="range"] { width: 100%; height: 3px; margin: 0; appearance: none; border-radius: 99px; background: linear-gradient(to right, var(--ink) var(--range-progress, 0%), oklch(0.37 0.02 42) var(--range-progress, 0%)); }
|
||||
input[type="range"]::-webkit-slider-thumb { width: 10px; height: 10px; appearance: none; border: 0; border-radius: 50%; background: var(--ink); opacity: 0; transition: opacity 140ms ease; }
|
||||
input[type="range"]:hover::-webkit-slider-thumb, input[type="range"]:focus-visible::-webkit-slider-thumb { opacity: 1; }
|
||||
.player-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; color: var(--muted); }
|
||||
.player-tools input { max-width: 90px; }
|
||||
|
||||
.queue-panel { position: fixed; z-index: 45; top: 0; right: 0; bottom: var(--player-height); width: min(370px, 92vw); padding: 25px; border-left: 1px solid var(--line); background: oklch(0.185 0.016 39); box-shadow: -24px 0 70px oklch(0.06 0.018 36 / 0.36); transform: translateX(105%); transition: transform 240ms cubic-bezier(.22, 1, .36, 1); }
|
||||
.queue-panel.is-open { transform: translateX(0); }
|
||||
.queue-header { display: flex; align-items: flex-start; justify-content: space-between; }
|
||||
.queue-header h2 { margin: 5px 0 0; font-size: 1.35rem; letter-spacing: -0.04em; }
|
||||
.queue-now { display: grid; margin: 28px 0 25px; }
|
||||
.queue-now .cover { box-shadow: 0 22px 45px oklch(0.06 0.02 38 / 0.34); }
|
||||
.queue-now > span { margin-top: 15px; color: var(--accent-bright); font-size: 0.62rem; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; }
|
||||
.queue-now > strong { margin-top: 5px; font-size: 0.93rem; }
|
||||
.queue-now > small { margin-top: 3px; color: var(--faint); font-size: 0.72rem; }
|
||||
.queue-list { border-top: 1px solid var(--line); }
|
||||
.queue-item { display: flex; align-items: center; border-bottom: 1px solid var(--line); }
|
||||
.queue-item > button:first-child { display: grid; min-width: 0; flex: 1; grid-template-columns: 22px minmax(0, 1fr); align-items: center; gap: 8px; padding: 12px 0; border: 0; background: transparent; text-align: left; }
|
||||
.queue-item > button:first-child > span { color: var(--faint); font-size: 0.62rem; }
|
||||
.queue-item strong, .queue-item small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.queue-item strong { font-size: 0.72rem; }
|
||||
.queue-item small { margin-top: 3px; color: var(--faint); font-size: 0.63rem; }
|
||||
|
||||
.nav-scrim { display: none; }
|
||||
@keyframes pulse { 50% { opacity: 0.45; transform: scale(0.82); } }
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.album-grid { grid-template-columns: repeat(4, minmax(120px, 1fr)); }
|
||||
.album-tile:nth-child(n + 5) { display: none; }
|
||||
.hero { padding: 38px 42px; }
|
||||
.player-bar { grid-template-columns: minmax(180px, 0.9fr) minmax(320px, 1.4fr) minmax(150px, 0.7fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
:root { --player-height: 86px; }
|
||||
.app-shell { grid-template-columns: minmax(0, 1fr); }
|
||||
.sidebar { position: fixed; top: 0; bottom: var(--player-height); left: 0; width: 230px; transform: translateX(-105%); transition: transform 220ms cubic-bezier(.22, 1, .36, 1); }
|
||||
.sidebar.is-open { transform: translateX(0); }
|
||||
.nav-scrim { position: fixed; z-index: 25; inset: 0 0 var(--player-height); display: block; border: 0; background: oklch(0.06 0.01 38 / 0.7); }
|
||||
.menu-button { display: inline-grid !important; }
|
||||
.topbar { padding: 0 20px; }
|
||||
.library-view { padding: 22px 20px 60px; }
|
||||
.hero { min-height: 280px; grid-template-columns: 1fr 34%; padding: 34px; }
|
||||
.hero h1 { font-size: 2.65rem; }
|
||||
.hero-art .cover { width: min(190px, 24vw); }
|
||||
.album-grid { grid-template-columns: repeat(3, minmax(100px, 1fr)); }
|
||||
.album-tile:nth-child(n + 4) { display: none; }
|
||||
.track-row { grid-template-columns: minmax(230px, 1fr) 34px 45px 34px; }
|
||||
.track-album { display: none; }
|
||||
.player-bar { grid-template-columns: minmax(170px, 1fr) auto; padding: 9px 16px; }
|
||||
.transport { display: flex; flex-direction: row-reverse; align-items: center; gap: 11px; }
|
||||
.transport-buttons .icon-button:first-child, .transport-buttons .icon-button:last-child, .scrubber, .player-tools > svg, .player-tools > input { display: none; }
|
||||
.player-tools { position: absolute; right: 179px; }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.topbar { gap: 8px; height: 61px; padding: 0 12px; }
|
||||
.search-field { width: 100%; }
|
||||
.topbar-actions .icon-button { display: none; }
|
||||
.avatar { width: 29px; height: 29px; }
|
||||
.library-view { padding: 14px 14px 50px; }
|
||||
.hero { display: block; min-height: 330px; padding: 31px 25px; }
|
||||
.hero h1 { max-width: 330px; font-size: 2.55rem; }
|
||||
.hero p { max-width: 34ch; }
|
||||
.hero-art { position: absolute; right: -22px; bottom: -35px; min-height: 160px; opacity: 0.6; }
|
||||
.hero-art .cover { width: 165px; }
|
||||
.hero-actions { position: relative; z-index: 4; }
|
||||
.album-grid { grid-template-columns: repeat(2, minmax(100px, 1fr)); gap: 19px 13px; }
|
||||
.album-tile:nth-child(n + 5) { display: block; }
|
||||
.section-heading { align-items: flex-end; }
|
||||
.section-heading h2 { font-size: 1.25rem; }
|
||||
.track-row { grid-template-columns: minmax(0, 1fr) 32px 32px; }
|
||||
.track-time, .track-row > .icon-button:last-child { display: none; }
|
||||
.track-main { grid-template-columns: 25px 38px minmax(0, 1fr); gap: 8px; }
|
||||
.cover--small { width: 38px; min-width: 38px; }
|
||||
.settings-view { width: calc(100% - 28px); margin-top: 36px; }
|
||||
.settings-view h1 { font-size: 1.9rem; }
|
||||
.connection-form { padding: 20px; }
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
.form-actions { flex-direction: column-reverse; }
|
||||
.form-actions button { width: 100%; }
|
||||
.player-bar { grid-template-columns: minmax(0, 1fr) auto; gap: 6px; padding: 8px 10px; }
|
||||
.now-playing .cover { width: 46px; min-width: 46px; }
|
||||
.now-playing > svg { display: none; }
|
||||
.transport-buttons { gap: 0; }
|
||||
.transport-buttons .icon-button:nth-child(2), .transport-buttons .icon-button:nth-child(4) { display: none; }
|
||||
.player-tools { right: 63px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type CoverTone = "ember" | "moss" | "dusk" | "clay" | "indigo" | "sand";
|
||||
|
||||
export interface Album {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
year: number;
|
||||
coverTone: CoverTone;
|
||||
coverUrl?: string;
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
albumId: string;
|
||||
duration: number;
|
||||
coverTone: CoverTone;
|
||||
coverUrl?: string;
|
||||
streamUrl?: string;
|
||||
favorite?: boolean;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type ConnectionStatus = "demo" | "connecting" | "connected" | "error";
|
||||
export type View = "home" | "albums" | "artists" | "favorites" | "settings";
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
},
|
||||
envPrefix: ["VITE_", "TAURI_ENV_*"],
|
||||
build: {
|
||||
target: "es2022",
|
||||
minify: process.env.TAURI_ENV_DEBUG ? false : "oxc",
|
||||
sourcemap: Boolean(process.env.TAURI_ENV_DEBUG),
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user