Initial commit: web frontend

React web app for the self-hosted music streaming project,
carved out from the music-app monorepo. Uses @music-app/shared
for shared types and utilities.
This commit is contained in:
Jarian Cottingham 2026-08-21 18:44:05 +00:00
commit bc40a19c49
66 changed files with 19881 additions and 0 deletions

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Jarian Cottingham
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

36
README.md Normal file
View File

@ -0,0 +1,36 @@
# Music App — Web
React web client for the Music App streaming project: player UI, mood radio, lofi channels, SharePlay, search, and settings. Built with Vite, React 18, Tailwind CSS, and Radix UI.
Part of the Music App project:
| Repo | What it is |
|------|------------|
| [music-app](https://git.jarianc.com/jarianc/music-app) | FastAPI backend (server) |
| [music-mobile](https://git.jarianc.com/jarianc/music-mobile) | React Native mobile app (Expo) |
## Quick Start
```bash
npm install
npm run dev
```
Dev server on `http://localhost:5173`. Point it at a running music-app server with `VITE_API_URL` (default `http://localhost:8000`).
## Project Structure
- `web/` — React app (Vite + Tailwind): screens, player, SharePlay UI
- `shared/` — Shared TypeScript types and API client (`@music-app/shared` workspace package)
- `e2e/` — Playwright end-to-end specs
- `playwright.config.ts` — Playwright config (chromium)
## Scripts
| Command | What it does |
|---------|--------------|
| `npm run dev` | Vite dev server |
| `npm run build` | Typecheck + production build |
| `npm run typecheck` | `tsc --noEmit` across workspaces |
| `npm run lint` | ESLint across workspaces |
| `npm run test:e2e` | Playwright E2E suite (server must be running) |

130
e2e/features.spec.ts Normal file
View File

@ -0,0 +1,130 @@
import { test, expect } from '@playwright/test';
test.describe('Mood Radio Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/mood');
});
test('shows mood radio title', async ({ page }) => {
await expect(page.getByText('Mood Radio')).toBeVisible();
});
test('shows mood name', async ({ page }) => {
// Should show one of the mood names
await expect(page.getByText(/Sad|Happy|Energetic|Focused|Chill/)).toBeVisible();
});
test('shows play mood button', async ({ page }) => {
await expect(page.getByText(/Play Mood|Playing/)).toBeVisible();
});
test('shows set the mood button', async ({ page }) => {
await expect(page.getByText('Set the Mood')).toBeVisible();
});
test('clicking set the mood changes mood', async ({ page }) => {
const currentMood = await page.getByRole('heading', { level: 2 }).textContent();
await page.getByText('Set the Mood').click();
await page.waitForTimeout(500);
const newMood = await page.getByRole('heading', { level: 2 }).textContent();
// Mood should change (might rarely be same by random chance)
});
test('shows circular progress indicator', async ({ page }) => {
// SVG circle should be present
const circle = page.locator('svg circle').last();
await expect(circle).toBeVisible();
});
});
test.describe('LoFi Channel Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/lofi');
});
test('shows lofi channel title', async ({ page }) => {
await expect(page.getByText('LoFi Channel')).toBeVisible();
});
test('shows lofi channels', async ({ page }) => {
// Should show at least 3 channels
const channels = page.locator('[class*="aspect-video"]');
await expect(channels).toHaveCount(atLeast(3));
});
test('shows LoFi label on channels', async ({ page }) => {
await expect(page.getByText('LoFi')).toBeVisible();
});
function atLeast(n: number) {
return {
pass(received: number) {
return received >= n;
},
message: () => `expected at least ${n} elements`,
};
}
});
test.describe('Internet Radio Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/radio');
});
test('shows internet radio title', async ({ page }) => {
await expect(page.getByText('Internet Radio')).toBeVisible();
});
test('shows search bar', async ({ page }) => {
const searchInput = page.getByPlaceholder(/What music is calling/);
await expect(searchInput).toBeVisible();
});
test('shows stations section', async ({ page }) => {
await expect(page.getByText('Stations')).toBeVisible();
});
});
test.describe('New Releases Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/releases');
});
test('shows new releases title', async ({ page }) => {
await expect(page.getByText('New Releases')).toBeVisible();
});
test('shows empty state or releases', async ({ page }) => {
// Either shows releases or empty state with "Add music" message
const hasContent = await page.locator('h2.font-display').count();
expect(hasContent >= 0).toBeTruthy();
});
});
test.describe('SharePlay Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/shareplay');
});
test('shows shareplay title', async ({ page }) => {
await expect(page.getByText('SharePlay')).toBeVisible();
});
test('shows create room option', async ({ page }) => {
await expect(page.getByText(/Create Room|Start a SharePlay/)).toBeVisible();
});
test('can create a room', async ({ page }) => {
await page.getByRole('button', { name: /Create Room/ }).click();
await page.waitForTimeout(500);
// Should show room UI
await expect(page.getByText(/Currently Playing|Add Song to Cue/)).toBeVisible();
});
});
test.describe('Playlist Page', () => {
test('shows 404 for non-existent playlist', async ({ page }) => {
await page.goto('/playlist/nonexistent-id');
await expect(page.getByText(/not found|back to library/i)).toBeVisible();
});
});

56
e2e/home.spec.ts Normal file
View File

@ -0,0 +1,56 @@
import { test, expect } from '@playwright/test';
test.describe('Home Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('loads home page', async ({ page }) => {
await expect(page).toHaveTitle(/Music App/);
});
test('shows navigation hub with feature tabs', async ({ page }) => {
await expect(page.getByText('Music')).toBeVisible();
await expect(page.getByText('New Music')).toBeVisible();
await expect(page.getByText('Mood')).toBeVisible();
await expect(page.getByText('LoFi')).toBeVisible();
});
test('shows quick access cards', async ({ page }) => {
await expect(page.getByText('My Music')).toBeVisible();
await expect(page.getByText('New Releases')).toBeVisible();
});
test('shows mood radio section', async ({ page }) => {
await expect(page.getByText('Mood Radio')).toBeVisible();
await expect(page.getByText('Sad')).toBeVisible();
await expect(page.getByText('Happy')).toBeVisible();
await expect(page.getByText('Energetic')).toBeVisible();
});
test('profile link navigates to account', async ({ page }) => {
await page.getByRole('link', { name: /person/ }).first().click();
// Should navigate to /account
await expect(page).toHaveURL(/\/account/);
});
test('Music tab navigates to library', async ({ page }) => {
await page.getByRole('link', { name: 'Music' }).first().click();
await expect(page).toHaveURL(/\/library/);
});
test('New Music tab navigates to releases', async ({ page }) => {
await page.getByRole('link', { name: 'New Music' }).first().click();
await expect(page).toHaveURL(/\/releases/);
});
test('Mood tab navigates to mood radio', async ({ page }) => {
await page.getByRole('link', { name: 'Mood' }).first().click();
await expect(page).toHaveURL(/\/mood/);
});
test('LoFi tab navigates to lofi channels', async ({ page }) => {
await page.getByRole('link', { name: 'LoFi' }).first().click();
await expect(page).toHaveURL(/\/lofi/);
});
});

46
e2e/navigation.spec.ts Normal file
View File

@ -0,0 +1,46 @@
import { test, expect } from '@playwright/test';
test.describe('Bottom Navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('shows all 5 nav items', async ({ page }) => {
await expect(page.getByText('Home')).toBeVisible();
await expect(page.getByText('Playlist')).toBeVisible();
await expect(page.getByText('Search')).toBeVisible();
await expect(page.getByText('Internet Radio')).toBeVisible();
await expect(page.getByText('Create')).toBeVisible();
});
test('Home nav item is active on home page', async ({ page }) => {
const homeNav = page.getByText('Home');
await expect(homeNav).toBeVisible();
});
test('clicking Playlist nav navigates to playlist page', async ({ page }) => {
await page.getByText('Playlist').click();
await expect(page).toHaveURL(/\/playlist/);
});
test('clicking Search nav navigates to search page', async ({ page }) => {
await page.getByText('Search').click();
await expect(page).toHaveURL(/\/search/);
});
test('clicking Internet Radio nav navigates to radio page', async ({ page }) => {
await page.getByText('Internet Radio').click();
await expect(page).toHaveURL(/\/radio/);
});
test('clicking Create nav navigates to create page', async ({ page }) => {
await page.getByText('Create').click();
await expect(page).toHaveURL(/\/create/);
});
test('nav is visible on all pages', async ({ page }) => {
await page.getByText('Search').click();
await expect(page.getByText('Home')).toBeVisible();
await expect(page.getByText('Create')).toBeVisible();
});
});

95
e2e/pages.spec.ts Normal file
View File

@ -0,0 +1,95 @@
import { test, expect } from '@playwright/test';
test.describe('Library Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/library');
});
test('shows library title', async ({ page }) => {
await expect(page.getByText('Library')).toBeVisible();
});
test('shows search input for playlists', async ({ page }) => {
const searchInput = page.getByPlaceholder(/Search playlists/);
await expect(searchInput).toBeVisible();
});
test('shows empty state when no playlists', async ({ page }) => {
// Either shows playlists or empty state
const hasPlaylists = await page.locator('[class*="VinylStack"]').count();
if (hasPlaylists === 0) {
await expect(page.getByText(/No playlists|Create your first/)).toBeVisible();
}
});
test('search filters playlists', async ({ page }) => {
const searchInput = page.getByPlaceholder(/Search playlists/);
await searchInput.fill('nonexistent-xyz');
await page.waitForTimeout(300);
// Should show empty state or no results
});
test('profile link navigates to account', async ({ page }) => {
await page.getByRole('link', { name: /person/ }).first().click();
await expect(page).toHaveURL(/\/account/);
});
});
test.describe('Create Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/create');
});
test('shows create title', async ({ page }) => {
await expect(page.getByText('Create')).toBeVisible();
});
test('shows all 4 create tabs', async ({ page }) => {
await expect(page.getByText('Playlist')).toBeVisible();
await expect(page.getByText('Mood Playlist')).toBeVisible();
await expect(page.getByText('Radio')).toBeVisible();
await expect(page.getByText('Collab')).toBeVisible();
});
test('Playlist tab is active by default', async ({ page }) => {
const playlistTab = page.getByText('Playlist').first();
await expect(playlistTab).toBeVisible();
});
test('switching tabs changes content', async ({ page }) => {
await page.getByText('Mood Playlist').click();
await expect(page.getByText('Browse Moods')).toBeVisible();
});
test('can create a playlist', async ({ page }) => {
const nameInput = page.getByPlaceholder(/Playlist name/);
await nameInput.fill('My New Playlist');
await page.getByRole('button', { name: /Create Playlist/ }).click();
await page.waitForTimeout(500);
await expect(page.getByText(/Playlist created|Create another/)).toBeVisible();
});
test('cannot create empty playlist', async ({ page }) => {
const createBtn = page.getByRole('button', { name: /Create Playlist/ });
await expect(createBtn).toBeDisabled();
});
});
test.describe('Account Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/account');
});
test('shows welcome message', async ({ page }) => {
await expect(page.getByText(/Welcome/)).toBeVisible();
});
test('shows all menu items', async ({ page }) => {
await expect(page.getByText('Plugins')).toBeVisible();
await expect(page.getByText('Servers')).toBeVisible();
await expect(page.getByText('About You')).toBeVisible();
await expect(page.getByText('Internet Radio')).toBeVisible();
await expect(page.getByText('Updates')).toBeVisible();
await expect(page.getByText('Settings & Privacy')).toBeVisible();
});
});

48
e2e/search.spec.ts Normal file
View File

@ -0,0 +1,48 @@
import { test, expect } from '@playwright/test';
test.describe('Search Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/search');
});
test('shows search bar with placeholder', async ({ page }) => {
const searchInput = page.getByPlaceholder(/What music is calling/);
await expect(searchInput).toBeVisible();
});
test('shows feature grid', async ({ page }) => {
await expect(page.getByText('Music')).toBeVisible();
await expect(page.getByText('New Music')).toBeVisible();
await expect(page.getByText('Live Events')).toBeVisible();
await expect(page.getByText('Internet Radio')).toBeVisible();
await expect(page.getByText('Mood Radio')).toBeVisible();
});
test('shows music suggestions section', async ({ page }) => {
await expect(page.getByText('Music Suggestions')).toBeVisible();
});
test('shows your library section', async ({ page }) => {
await expect(page.getByText('Your Library')).toBeVisible();
});
test('search input accepts text', async ({ page }) => {
const searchInput = page.getByPlaceholder(/What music is calling/);
await searchInput.fill('test song');
await expect(searchInput).toHaveValue('test song');
});
test('search on enter triggers search', async ({ page }) => {
const searchInput = page.getByPlaceholder(/What music is calling/);
await searchInput.fill('test');
await searchInput.press('Enter');
// Should show search results or no results message
await page.waitForTimeout(500);
});
test('feature grid items are clickable', async ({ page }) => {
const musicLink = page.getByRole('link', { name: 'Music' }).first();
await musicLink.click();
await expect(page).toHaveURL(/\/library/);
});
});

141
e2e/visual.spec.ts Normal file
View File

@ -0,0 +1,141 @@
import { test, expect } from '@playwright/test';
test.describe('Visual & Responsive', () => {
test('bottom nav is fixed at bottom', async ({ page }) => {
await page.goto('/');
const nav = page.locator('nav.fixed.bottom-0');
await expect(nav).toBeVisible();
});
test('page uses dark theme', async ({ page }) => {
await page.goto('/');
const bgColor = await page.locator('#root').evaluate(el =>
window.getComputedStyle(el).backgroundColor
);
// Should be dark (close to #0a0a0a)
expect(bgColor).toMatch(/rgba?\(\s*10/);
});
test('music-accent color is used', async ({ page }) => {
await page.goto('/');
// Check that accent-colored elements exist
const accentElements = page.locator('[class*="music-accent"]');
await expect(accentElements).toHaveCount(atLeast(1));
});
test('material icons are loaded', async ({ page }) => {
await page.goto('/');
const icon = page.locator('.material-icons').first();
await expect(icon).toBeVisible();
});
test('page transitions work', async ({ page }) => {
await page.goto('/');
await page.getByText('Search').click();
await page.waitForTimeout(300);
await expect(page).toHaveURL(/\/search/);
await page.getByText('Home').click();
await page.waitForTimeout(300);
await expect(page).toHaveURL(/\/$/);
});
test('responsive: content fits on mobile width', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto('/');
await expect(page.getByText('Mood Radio')).toBeVisible();
});
test('responsive: content fits on tablet width', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await page.goto('/');
await expect(page.getByText('Mood Radio')).toBeVisible();
});
function atLeast(n: number) {
return {
pass(received: number) {
return received >= n;
},
message: () => `expected at least ${n} elements`,
};
}
});
test.describe('Accessibility', () => {
test('all images have alt text or are decorative', async ({ page }) => {
await page.goto('/');
const images = await page.locator('img').all();
for (const img of images) {
const alt = await img.getAttribute('alt');
expect(alt !== null).toBeTruthy();
}
});
test('buttons have accessible names', async ({ page }) => {
await page.goto('/');
const buttons = await page.locator('button').all();
// Buttons should either have text content, aria-label, or be icon buttons
for (const btn of buttons) {
const hasText = await btn.textContent();
const hasAria = await btn.getAttribute('aria-label');
const hasTitle = await btn.getAttribute('title');
const hasMaterialIcon = await btn.locator('.material-icons').count();
// Button is accessible if it has text, aria-label, title, or material icon
expect(
(hasText && hasText.trim().length > 0) ||
hasAria ||
hasTitle ||
hasMaterialIcon > 0
).toBeTruthy();
}
});
test('links have accessible names', async ({ page }) => {
await page.goto('/');
const links = await page.locator('a').all();
for (const link of links) {
const hasText = await link.textContent();
const hasAria = await link.getAttribute('aria-label');
const hasMaterialIcon = await link.locator('.material-icons').count();
expect(
(hasText && hasText.trim().length > 0) ||
hasAria ||
hasMaterialIcon > 0
).toBeTruthy();
}
});
test('page has proper HTML structure', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1, h2')).toHaveCount(atLeast(1));
});
function atLeast(n: number) {
return {
pass(received: number) {
return received >= n;
},
message: () => `expected at least ${n} elements`,
};
}
});
test.describe('Performance', () => {
test('home page loads under 3 seconds', async ({ page }) => {
const start = Date.now();
await page.goto('/');
const loadTime = Date.now() - start;
expect(loadTime).toBeLessThan(3000);
});
test('page navigation is fast', async ({ page }) => {
await page.goto('/');
const start = Date.now();
await page.getByText('Search').click();
await page.waitForURL(/\/search/);
const navTime = Date.now() - start;
expect(navTime).toBeLessThan(1000);
});
});

16344
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "music-web",
"private": true,
"workspaces": [
"shared",
"web"
],
"scripts": {
"dev": "npm run dev:web",
"dev:web": "npm run dev --workspace=web -- --host 0.0.0.0",
"build": "npm run build --workspace=web",
"lint": "npm run lint --workspaces --if-present",
"typecheck": "npm run typecheck --workspaces --if-present",
"test:e2e": "npx playwright test"
},
"devDependencies": {
"@playwright/test": "^1.59.1"
}
}

24
playwright.config.ts Normal file
View File

@ -0,0 +1,24 @@
module.exports = {
testDir: './e2e',
timeout: 30000,
expect: {
timeout: 5000,
},
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { browserName: 'chromium' },
},
],
}

15
shared/package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "@music-app/shared",
"version": "0.1.0",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint src/"
},
"devDependencies": {
"typescript": "^5.3.3",
"@types/node": "^20.10.0"
}
}

193
shared/src/api/client.ts Normal file
View File

@ -0,0 +1,193 @@
import { API_BASE, ENDPOINTS } from '../constants/api';
import { PaginatedResponse, ApiResponse } from '../types/common';
class ApiClient {
private baseUrl: string;
constructor(baseUrl?: string) {
this.baseUrl = baseUrl || API_BASE;
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
const url = `${this.baseUrl}${endpoint}`;
const headers = {
'Content-Type': 'application/json',
...options.headers,
} as Record<string, string>;
try {
const response = await fetch(url, { ...options, headers });
const data = await response.json();
if (!response.ok) {
return { success: false, error: data.detail || data.error || 'Request failed' };
}
return { success: true, data };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Network error',
};
}
}
async get<T>(endpoint: string): Promise<ApiResponse<T>> {
return this.request<T>(endpoint, { method: 'GET' });
}
async post<T>(endpoint: string, body?: unknown): Promise<ApiResponse<T>> {
return this.request<T>(endpoint, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
});
}
async put<T>(endpoint: string, body?: unknown): Promise<ApiResponse<T>> {
return this.request<T>(endpoint, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
});
}
async delete<T>(endpoint: string): Promise<ApiResponse<T>> {
return this.request<T>(endpoint, { method: 'DELETE' });
}
async upload<T>(endpoint: string, formData: FormData): Promise<ApiResponse<T>> {
try {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: 'POST',
body: formData,
});
const data = await response.json();
if (!response.ok) {
return { success: false, error: data.detail || data.error || 'Upload failed' };
}
return { success: true, data };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Upload error',
};
}
}
// Songs
getSongs = (page = 1, perPage = 50) =>
this.get<PaginatedResponse<unknown>>(`${ENDPOINTS.songs}?page=${page}&per_page=${perPage}`);
getSong = (id: string) => this.get<unknown>(ENDPOINTS.song(id));
deleteSong = (id: string) => this.delete<unknown>(ENDPOINTS.song(id));
uploadSong = (file: File) => {
const formData = new FormData();
formData.append('file', file);
return this.upload<unknown>(ENDPOINTS.upload, formData);
};
scanSongs = (directory?: string) =>
this.post<unknown>(ENDPOINTS.scan, directory ? { directory } : undefined);
// Playlists
getPlaylists = () => this.get<unknown[]>(ENDPOINTS.playlists);
getPlaylist = (id: string) => this.get<unknown>(ENDPOINTS.playlist(id));
createPlaylist = (body: { name: string; description?: string; moodCategory?: string; songIds?: string[] }) =>
this.post<unknown>(ENDPOINTS.playlists, body);
updatePlaylist = (id: string, body: Partial<{ name: string; description: string }>) =>
this.put<unknown>(ENDPOINTS.playlist(id), body);
deletePlaylist = (id: string) => this.delete<unknown>(ENDPOINTS.playlist(id));
addSongToPlaylist = (playlistId: string, songId: string) =>
this.post<unknown>(ENDPOINTS.playlistSongs(playlistId), { song_id: songId });
removeSongFromPlaylist = (playlistId: string, songId: string) =>
this.delete<unknown>(`${ENDPOINTS.playlistSongs(playlistId)}/${songId}`);
sharePlaylist = (id: string) => this.post<unknown>(ENDPOINTS.playlistShare(id));
getSharedPlaylist = (token: string) => this.get<unknown>(ENDPOINTS.sharedPlaylist(token));
// Search
search = (query: string) => this.get<unknown>(`${ENDPOINTS.search}?q=${encodeURIComponent(query)}`);
// Mood
getMoodCategories = () => this.get<unknown[]>(ENDPOINTS.moods);
analyzeMood = (songId?: string) => this.post<unknown>(ENDPOINTS.moodAnalyze, songId ? { song_id: songId } : undefined);
getMoodPlaylist = (mood: string) => this.get<unknown>(ENDPOINTS.moodPlaylist(mood));
saveMoodPlaylist = (mood: string, name?: string) =>
this.post<unknown>(ENDPOINTS.moodSave, { mood, name });
setMood = (mood: string) => this.post<unknown>(ENDPOINTS.moodSet, { mood });
// Radio
getRadioStations = (country?: string, genre?: string, limit = 50) =>
this.get<unknown[]>(`${ENDPOINTS.radioStations}?limit=${limit}${country ? `&country=${country}` : ''}${genre ? `&genre=${genre}` : ''}`);
getNearbyStations = (lat?: number, lon?: number, radius = 100) =>
this.get<unknown[]>(`${ENDPOINTS.radioNearby}${lat ? `?lat=${lat}&lon=${lon}&radius=${radius}` : ''}`);
getRadioStation = (id: string) => this.get<unknown>(ENDPOINTS.radioStation(id));
getRadioCurrent = () => this.get<unknown>(ENDPOINTS.radioCurrent);
// LoFi
getLofiChannels = () => this.get<unknown[]>(ENDPOINTS.lofiChannels);
// SharePlay
createSharePlay = () => this.post<unknown>(ENDPOINTS.sharePlayCreate);
joinSharePlay = (roomId: string) => this.post<unknown>(ENDPOINTS.sharePlayJoin, { room_id: roomId });
leaveSharePlay = (roomId: string) => this.post<unknown>(ENDPOINTS.sharePlayLeave, { room_id: roomId });
getCue = (roomId: string) => this.get<unknown>(`${ENDPOINTS.sharePlayCue}?room_id=${roomId}`);
addToCue = (roomId: string, songId: string) =>
this.post<unknown>(ENDPOINTS.sharePlayCue, { room_id: roomId, song_id: songId });
sendControl = (roomId: string, type: string, payload?: unknown) =>
this.post<unknown>(ENDPOINTS.sharePlayControl, { room_id: roomId, type, payload });
// Releases
getReleases = () => this.get<unknown[]>(ENDPOINTS.releases);
getArtistReleases = (artist: string) => this.get<unknown>(ENDPOINTS.releaseArtist(artist));
// Events
getEvents = (lat?: number, lon?: number) =>
this.get<unknown[]>(`${ENDPOINTS.events}${lat ? `?lat=${lat}&lon=${lon}` : ''}`);
// Settings
getSettings = () => this.get<unknown>(ENDPOINTS.settings);
updateSettings = (settings: Record<string, unknown>) => this.put<unknown>(ENDPOINTS.settings, settings);
getServers = () => this.get<unknown[]>(ENDPOINTS.settingsServers);
addServer = (path: string, name?: string) =>
this.post<unknown>(ENDPOINTS.settingsServers, { path, name });
removeServer = (id: string) => this.delete<unknown>(ENDPOINTS.settingsServer(id));
// Account
getAccountStats = () => this.get<unknown>(ENDPOINTS.accountStats);
getAccountHistory = () => this.get<unknown[]>(ENDPOINTS.accountHistory);
}
export const api = new ApiClient();
export { ApiClient };

View File

@ -0,0 +1,65 @@
export const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
export const ENDPOINTS = {
// Songs
songs: '/api/songs',
song: (id: string) => `/api/songs/${id}`,
songStream: (id: string) => `/api/songs/${id}/stream`,
songLyrics: (id: string) => `/api/songs/${id}/lyrics`,
upload: '/api/songs/upload',
scan: '/api/songs/scan',
// Playlists
playlists: '/api/playlists',
playlist: (id: string) => `/api/playlists/${id}`,
playlistSongs: (id: string) => `/api/playlists/${id}/songs`,
playlistShare: (id: string) => `/api/playlists/${id}/share`,
sharedPlaylist: (token: string) => `/api/playlists/shared/${token}`,
// Search
search: '/api/search',
// Mood
moods: '/api/mood/categories',
moodAnalyze: '/api/mood/analyze',
moodPlaylist: (mood: string) => `/api/mood/${mood}/playlist`,
moodSave: '/api/mood/save',
moodSet: '/api/mood/set',
// Radio
radioStations: '/api/radio/stations',
radioNearby: '/api/radio/nearby',
radioStation: (id: string) => `/api/radio/stations/${id}`,
radioStream: (id: string) => `/api/radio/stream/${id}`,
radioCurrent: '/api/radio/current',
// LoFi
lofiChannels: '/api/lofi/channels',
lofiStream: (id: string) => `/api/lofi/stream/${id}`,
lofiAdd: '/api/lofi/add',
// SharePlay
sharePlayCreate: '/api/shareplay/create',
sharePlayJoin: '/api/shareplay/join',
sharePlayLeave: '/api/shareplay/leave',
sharePlayCue: '/api/shareplay/cue',
sharePlayControl: '/api/shareplay/control',
sharePlayWS: (roomId: string) => `/ws/shareplay/${roomId}`,
// Releases
releases: '/api/releases',
releaseArtist: (artist: string) => `/api/releases/${artist}`,
releaseAdd: '/api/releases/add',
// Events
events: '/api/events',
// Settings
settings: '/api/settings',
settingsServers: '/api/settings/servers',
settingsServer: (id: string) => `/api/settings/servers/${id}`,
// Account
accountStats: '/api/account/stats',
accountHistory: '/api/account/history',
} as const;

View File

@ -0,0 +1,97 @@
import { MoodCategory, MoodKeyword } from '../types/mood';
export const MOOD_CATEGORIES: MoodCategory[] = [
{ id: 'sad', name: 'Sad', colorHex: '#1a2a4a', description: 'Melancholic and reflective tracks', backgroundImage: '/moods/sad.jpg', iconPath: '/icons/mood-sad.svg' },
{ id: 'happy', name: 'Happy', colorHex: '#f5c542', description: 'Uplifting and cheerful tunes', backgroundImage: '/moods/happy.jpg', iconPath: '/icons/mood-happy.svg' },
{ id: 'energetic', name: 'Energetic', colorHex: '#e63946', description: 'High-energy and driving beats', backgroundImage: '/moods/energetic.jpg', iconPath: '/icons/mood-energetic.svg' },
{ id: 'focused', name: 'Focused', colorHex: '#2d6a4f', description: 'Concentration and productivity music', backgroundImage: '/moods/focused.jpg', iconPath: '/icons/mood-focused.svg' },
{ id: 'chill', name: 'Chill', colorHex: '#48957e', description: 'Relaxed and smooth vibes', backgroundImage: '/moods/chill.jpg', iconPath: '/icons/mood-chill.svg' },
{ id: 'romantic', name: 'Romantic', colorHex: '#bc6a7e', description: 'Love songs and intimate melodies', backgroundImage: '/moods/romantic.jpg', iconPath: '/icons/mood-romantic.svg' },
{ id: 'angry', name: 'Angry', colorHex: '#9d0208', description: 'Intense and powerful tracks', backgroundImage: '/moods/angry.jpg', iconPath: '/icons/mood-angry.svg' },
{ id: 'nostalgic', name: 'Nostalgic', colorHex: '#a67c52', description: 'Throwback and sentimental favorites', backgroundImage: '/moods/nostalgic.jpg', iconPath: '/icons/mood-nostalgic.svg' },
{ id: 'melancholy', name: 'Melancholy', colorHex: '#5a189c', description: 'Deep and contemplative soundscapes', backgroundImage: '/moods/melancholy.jpg', iconPath: '/icons/mood-melancholy.svg' },
{ id: 'dreamy', name: 'Dreamy', colorHex: '#9b5de5', description: 'Ethereal and atmospheric music', backgroundImage: '/moods/dreamy.jpg', iconPath: '/icons/mood-dreamy.svg' },
];
export const MOOD_KEYWORDS: Record<string, MoodKeyword[]> = {
Sad: [
{ word: 'cry', weight: 3 }, { word: 'alone', weight: 3 }, { word: 'tears', weight: 3 },
{ word: 'hurt', weight: 2 }, { word: 'lonely', weight: 3 }, { word: 'heartbreak', weight: 3 },
{ word: 'pain', weight: 2 }, { word: 'lost', weight: 2 }, { word: 'goodbye', weight: 2 },
{ word: 'miss', weight: 2 }, { word: 'broken', weight: 3 }, { word: 'empty', weight: 2 },
{ word: 'dark', weight: 1 }, { word: 'rain', weight: 2 }, { word: 'fall', weight: 1 },
],
Happy: [
{ word: 'happy', weight: 3 }, { word: 'joy', weight: 3 }, { word: 'smile', weight: 2 },
{ word: 'sunshine', weight: 2 }, { word: 'dance', weight: 2 }, { word: 'celebrate', weight: 2 },
{ word: 'laugh', weight: 2 }, { word: 'bright', weight: 2 }, { word: 'free', weight: 2 },
{ word: 'light', weight: 1 }, { word: 'party', weight: 2 }, { word: 'fun', weight: 2 },
{ word: 'good', weight: 1 }, { word: 'wonderful', weight: 2 }, { word: 'beautiful', weight: 1 },
],
Energetic: [
{ word: 'fire', weight: 3 }, { word: 'power', weight: 3 }, { word: 'strong', weight: 2 },
{ word: 'fight', weight: 2 }, { word: 'run', weight: 2 }, { word: 'fast', weight: 2 },
{ word: 'beat', weight: 2 }, { word: 'rise', weight: 2 }, { word: 'burn', weight: 2 },
{ word: 'wild', weight: 2 }, { word: 'storm', weight: 2 }, { word: 'thunder', weight: 2 },
{ word: 'war', weight: 2 }, { word: 'crash', weight: 2 }, { word: 'break', weight: 1 },
],
Focused: [
{ word: 'think', weight: 3 }, { word: 'mind', weight: 2 }, { word: 'clear', weight: 2 },
{ word: 'flow', weight: 2 }, { word: 'calm', weight: 2 }, { word: 'deep', weight: 2 },
{ word: 'still', weight: 2 }, { word: 'quiet', weight: 2 }, { word: 'concentrate', weight: 3 },
{ word: 'focus', weight: 3 }, { word: 'work', weight: 1 }, { word: 'study', weight: 2 },
{ word: 'peace', weight: 2 }, { word: 'steady', weight: 2 }, { word: 'control', weight: 2 },
],
Chill: [
{ word: 'relax', weight: 3 }, { word: 'chill', weight: 3 }, { word: 'smooth', weight: 2 },
{ word: 'easy', weight: 2 }, { word: 'vibes', weight: 2 }, { word: 'groove', weight: 2 },
{ word: 'lazy', weight: 2 }, { word: 'slow', weight: 2 }, { word: 'soft', weight: 2 },
{ word: 'gentle', weight: 2 }, { word: 'mellow', weight: 3 }, { word: 'unwind', weight: 2 },
{ word: 'breeze', weight: 2 }, { word: 'cloud', weight: 1 }, { word: 'drift', weight: 2 },
],
Romantic: [
{ word: 'love', weight: 3 }, { word: 'heart', weight: 3 }, { word: 'kiss', weight: 2 },
{ word: 'baby', weight: 2 }, { word: 'desire', weight: 2 }, { word: 'passion', weight: 3 },
{ word: 'touch', weight: 2 }, { word: 'embrace', weight: 2 }, { word: 'forever', weight: 2 },
{ word: 'sweetheart', weight: 2 }, { word: 'romance', weight: 3 }, { word: 'lover', weight: 2 },
{ word: 'darling', weight: 2 }, { word: 'soul', weight: 1 }, { word: 'together', weight: 2 },
],
Angry: [
{ word: 'anger', weight: 3 }, { word: 'hate', weight: 3 }, { word: 'fury', weight: 3 },
{ word: 'rage', weight: 3 }, { word: 'scream', weight: 2 }, { word: 'destroy', weight: 2 },
{ word: 'enemy', weight: 2 }, { word: 'betray', weight: 2 }, { word: 'lie', weight: 2 },
{ word: 'fight', weight: 2 }, { word: 'burn', weight: 2 }, { word: 'kill', weight: 3 },
{ word: 'war', weight: 2 }, { word: 'hell', weight: 2 }, { word: 'damn', weight: 2 },
],
Nostalgic: [
{ word: 'memory', weight: 3 }, { word: 'remember', weight: 3 }, { word: 'past', weight: 3 },
{ word: 'yesterday', weight: 3 }, { word: 'old', weight: 2 }, { word: 'back', weight: 2 },
{ word: 'days', weight: 2 }, { word: 'childhood', weight: 2 }, { word: 'home', weight: 2 },
{ word: 'then', weight: 2 }, { word: 'once', weight: 2 }, { word: 'before', weight: 2 },
{ word: 'gone', weight: 2 }, { word: 'time', weight: 1 }, { word: 'golden', weight: 2 },
],
Melancholy: [
{ word: 'sorrow', weight: 3 }, { word: 'grief', weight: 3 }, { word: 'blue', weight: 2 },
{ word: 'fade', weight: 2 }, { word: 'shadow', weight: 2 }, { word: 'silence', weight: 2 },
{ word: 'void', weight: 2 }, { word: 'night', weight: 2 }, { word: 'cold', weight: 2 },
{ word: 'end', weight: 2 }, { word: 'dying', weight: 2 }, { word: 'falling', weight: 2 },
{ word: 'heavy', weight: 2 }, { word: 'darkness', weight: 2 }, { word: 'whisper', weight: 1 },
],
Dreamy: [
{ word: 'dream', weight: 3 }, { word: 'sky', weight: 2 }, { word: 'cloud', weight: 2 },
{ word: 'float', weight: 2 }, { word: 'star', weight: 2 }, { word: 'moon', weight: 2 },
{ word: 'space', weight: 2 }, { word: 'cosmos', weight: 2 }, { word: 'ethereal', weight: 3 },
{ word: 'magic', weight: 2 }, { word: 'fantasy', weight: 2 }, { word: 'wonder', weight: 2 },
{ word: 'shimmer', weight: 2 }, { word: 'glow', weight: 2 }, { word: 'haze', weight: 2 },
],
};
export const MOOD_NAMES = MOOD_CATEGORIES.map(m => m.name);
export function getMoodById(id: string): MoodCategory | undefined {
return MOOD_CATEGORIES.find(m => m.id === id);
}
export function getMoodByName(name: string): MoodCategory | undefined {
return MOOD_CATEGORIES.find(m => m.name === name);
}

View File

@ -0,0 +1,29 @@
export const BOTTOM_NAV_ITEMS = [
{ id: 'home', label: 'Home', icon: 'home', route: '/' },
{ id: 'playlist', label: 'Playlist', icon: 'playlist', route: '/playlist' },
{ id: 'search', label: 'Search', icon: 'search', route: '/search' },
{ id: 'radio', label: 'Internet Radio', icon: 'radio', route: '/radio' },
{ id: 'create', label: 'Create', icon: 'add', route: '/create' },
] as const;
export const NAV_HUB_FEATURES = [
{ id: 'music', label: 'Music', route: '/music' },
{ id: 'new-music', label: 'New Music', route: '/releases' },
{ id: 'mood', label: 'Mood', route: '/mood' },
{ id: 'lofi', label: 'LoFi Channel', route: '/lofi' },
] as const;
export const CREATE_TABS = [
{ id: 'playlist', label: 'Playlist', description: 'Create a playlist with songs' },
{ id: 'mood-playlist', label: 'Mood Playlist', description: 'Create based on your mood' },
{ id: 'radio', label: 'Radio', description: 'Randomized songs with DJ' },
{ id: 'collab', label: 'Collab', description: 'Play friends playlists' },
] as const;
export const SEARCH_FEATURES = [
{ id: 'music', name: 'Music', color: '#e63946', icon: 'music', route: '/music' },
{ id: 'new-music', name: 'New Music', color: '#f5c542', icon: 'star', route: '/releases' },
{ id: 'live-events', name: 'Live Events', color: '#9b5de5', icon: 'event', route: '/events' },
{ id: 'internet-radio', name: 'Internet Radio', color: '#48957e', icon: 'radio', route: '/radio' },
{ id: 'mood-radio', name: 'Mood Radio', color: '#bc6a7e', icon: 'mood', route: '/mood' },
] as const;

16
shared/src/index.ts Normal file
View File

@ -0,0 +1,16 @@
export * from './types/song';
export * from './types/playlist';
export * from './types/mood';
export * from './types/radio';
export * from './types/lofi';
export * from './types/shareplay';
export * from './types/search';
export * from './types/settings';
export * from './types/account';
export * from './types/releases';
export * from './types/events';
export * from './types/common';
export * from './api/client';
export * from './constants/moods';
export * from './constants/navigation';
export * from './constants/api';

View File

@ -0,0 +1,16 @@
export interface AccountStats {
totalSongs: number;
totalPlaylists: number;
totalListeningTime: number;
topArtists: { name: string; count: number }[];
topGenres: { name: string; count: number }[];
topMoods: { name: string; count: number }[];
}
export interface ListeningHistoryItem {
songId: string;
title: string;
artist: string;
playedAt: string;
durationPlayed: number;
}

View File

@ -0,0 +1,36 @@
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
perPage: number;
totalPages: number;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}
export interface TimeDisplay {
minutes: number;
seconds: number;
raw: number;
}
export interface Position {
lat: number;
lon: number;
}
export interface Duration {
totalSeconds: number;
formatted: string;
}
export interface ImageAsset {
url: string;
width?: number;
height?: number;
blurHash?: string;
}

View File

@ -0,0 +1,10 @@
export interface ConcertEvent {
id: string;
name: string;
venue: string;
locationLat: number;
locationLon: number;
date: string;
description: string;
imageUrl: string | null;
}

9
shared/src/types/lofi.ts Normal file
View File

@ -0,0 +1,9 @@
export interface LofiChannel {
id: string;
name: string;
streamUrl: string;
imagePath: string;
description: string;
sourcePlatform: string;
isActive: boolean;
}

47
shared/src/types/mood.ts Normal file
View File

@ -0,0 +1,47 @@
import { Song } from './song';
export type MoodName =
| 'Sad'
| 'Happy'
| 'Energetic'
| 'Focused'
| 'Chill'
| 'Romantic'
| 'Angry'
| 'Nostalgic'
| 'Melancholy'
| 'Dreamy';
export interface MoodCategory {
id: string;
name: MoodName;
colorHex: string;
description: string;
backgroundImage: string;
iconPath: string;
}
export interface MoodScore {
mood: MoodName;
score: number;
keywords: string[];
}
export interface MoodAnalysis {
songId: string;
scores: MoodScore[];
topMood: MoodName;
confidence: number;
analyzedAt: string;
}
export interface MoodPlaylist {
mood: MoodName;
songs: Song[];
totalSongs: number;
}
export interface MoodKeyword {
word: string;
weight: number;
}

View File

@ -0,0 +1,30 @@
import { Song } from './song';
export interface Playlist {
id: string;
name: string;
description: string;
coverArt: string | null;
createdAt: string;
updatedAt: string;
moodCategory: string | null;
isShared: boolean;
shareToken: string | null;
songCount: number;
}
export interface PlaylistWithSongs extends Playlist {
songs: (Song & { position: number })[];
}
export interface PlaylistCreate {
name: string;
description?: string;
moodCategory?: string;
songIds?: string[];
}
export interface ShareLink {
token: string;
url: string;
}

29
shared/src/types/radio.ts Normal file
View File

@ -0,0 +1,29 @@
export interface RadioStation {
id: string;
name: string;
frequency: string;
streamUrl: string;
locationLat: number;
locationLon: number;
genre: string;
country: string;
language: string;
bitrate: number;
tags: string[];
votes: number;
isFavorite: boolean;
}
export interface RadioCurrent {
station: RadioStation;
songName: string | null;
artistName: string | null;
isPlaying: boolean;
}
export interface RadioSearch {
query: string;
country?: string;
genre?: string;
limit?: number;
}

View File

@ -0,0 +1,26 @@
export interface NewRelease {
artistName: string;
artistImage: string | null;
albums: ReleaseAlbum[];
lastChecked: string;
}
export interface ReleaseAlbum {
id: string;
title: string;
coverArt: string | null;
releaseDate: string;
tracks: ReleaseTrack[];
}
export interface ReleaseTrack {
title: string;
duration: number;
durationFormatted: string;
}
export interface ReleaseCheckResult {
artistName: string;
newAlbums: number;
checkedAt: string;
}

View File

@ -0,0 +1,22 @@
import { Song } from './song';
import { Playlist } from './playlist';
export interface SearchResult {
songs: Song[];
playlists: Playlist[];
query: string;
totalResults: number;
}
export interface MusicSuggestion {
song: Song;
reason: string;
}
export interface FeatureGridItem {
id: string;
name: string;
color: string;
icon: string;
route: string;
}

View File

@ -0,0 +1,18 @@
export interface UserSettings {
audioQuality: 'low' | 'medium' | 'high';
theme: 'dark' | 'light' | 'auto';
scanDirectories: string[];
userName: string;
userAvatar: string | null;
radioBrowserInstance: string;
autoTranscode: boolean;
defaultMood: string | null;
}
export interface ServerConfig {
id: string;
path: string;
name: string;
lastScanned: string | null;
songCount: number;
}

View File

@ -0,0 +1,43 @@
import { Song } from './song';
export interface SharePlayRoom {
id: string;
creatorUser: string;
createdAt: string;
currentSongId: string | null;
positionSec: number;
isPlaying: boolean;
shuffleMode: boolean;
activeConnections: number;
}
export interface SharePlayState {
songId: string | null;
position: number;
isPlaying: boolean;
shuffle: boolean;
volume: number;
}
export interface SharePlayCommand {
type: 'play' | 'pause' | 'skip_back' | 'skip_forward' | 'rewind' | 'fast_forward' | 'seek' | 'shuffle' | 'volume';
payload?: unknown;
}
export interface SharePlayCueItem {
song: Song;
position: number;
addedBy: string;
addedAt: string;
}
export interface SharePlayCue {
items: SharePlayCueItem[];
nextSong: Song | null;
}
export interface SharePlayPresence {
userId: string;
deviceName: string;
joinedAt: string;
}

32
shared/src/types/song.ts Normal file
View File

@ -0,0 +1,32 @@
import { ImageAsset } from './common';
export interface Song {
id: string;
title: string;
artist: string;
album: string;
durationSec: number;
genre: string;
filePath: string;
albumArt: ImageAsset | null;
addedAt: string;
fileFormat: string;
fileSizeBytes: number;
}
export interface SongWithLyrics extends Song {
lyrics: string | null;
moodTags: Record<string, number>;
}
export interface SongUpload {
file: File;
metadata?: Partial<Song>;
}
export interface ScanResult {
scanned: number;
added: number;
skipped: number;
errors: string[];
}

21
shared/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

12
web/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]

17
web/index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vinyl-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Music App</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body class="bg-music-black text-music-text font-body">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

41
web/package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "@music-app/web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint src/"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1",
"zustand": "^4.4.7",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7",
"@radix-ui/react-visually-hidden": "^1.0.3",
"framer-motion": "^11.0.5",
"@music-app/shared": "0.1.0",
"howler": "^2.2.4"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@types/howler": "^2.2.11",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3",
"vite": "^5.0.8"
}
}

6
web/postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

41
web/src/app/App.tsx Normal file
View File

@ -0,0 +1,41 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { PlayerProvider } from '../store/playerStore'
import { Layout } from './Layout'
import { HomePage } from '../pages/HomePage'
import { LibraryPage } from '../pages/LibraryPage'
import { CreatePage } from '../pages/CreatePage'
import { RadioPage } from '../pages/RadioPage'
import { SearchPage } from '../pages/SearchPage'
import { AccountPage } from '../pages/AccountPage'
import { NowPlayingPage } from '../pages/NowPlayingPage'
import { SharePlayPage } from '../pages/SharePlayPage'
import { MoodRadioPage } from '../pages/MoodRadioPage'
import { PlaylistPage } from '../pages/PlaylistPage'
import { NewReleasesPage } from '../pages/NewReleasesPage'
import { LofiChannelPage } from '../pages/LofiChannelPage'
export function App() {
return (
<PlayerProvider>
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<HomePage />} />
<Route path="/library" element={<LibraryPage />} />
<Route path="/create" element={<CreatePage />} />
<Route path="/radio" element={<RadioPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/account" element={<AccountPage />} />
<Route path="/now-playing" element={<NowPlayingPage />} />
<Route path="/shareplay" element={<SharePlayPage />} />
<Route path="/shareplay/:roomId" element={<SharePlayPage />} />
<Route path="/mood" element={<MoodRadioPage />} />
<Route path="/playlist/:id" element={<PlaylistPage />} />
<Route path="/releases" element={<NewReleasesPage />} />
<Route path="/lofi" element={<LofiChannelPage />} />
</Route>
</Routes>
</BrowserRouter>
</PlayerProvider>
)
}

33
web/src/app/Layout.tsx Normal file
View File

@ -0,0 +1,33 @@
import { Outlet, useLocation } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { BottomNavBar } from '../components/navigation/BottomNavBar'
import { NowPlayingMiniBar } from '../components/navigation/NowPlayingMiniBar'
import { usePlayerStore } from '../store/playerStore'
const HIDDEN_BOTTOM_NAV = ['/now-playing', '/mood']
export function Layout() {
const location = useLocation()
const currentSong = usePlayerStore(s => s.currentSong)
const showMiniBar = currentSong && !HIDDEN_BOTTOM_NAV.includes(location.pathname)
return (
<div className="flex flex-col h-screen bg-music-black overflow-hidden">
<main className="flex-1 overflow-y-auto pb-16">
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
>
<Outlet />
</motion.div>
</AnimatePresence>
</main>
{showMiniBar && <NowPlayingMiniBar />}
<BottomNavBar />
</div>
)
}

View File

@ -0,0 +1,16 @@
interface IconProps {
name: string
size?: number
className?: string
}
export function Icon({ name, size = 24, className = '' }: IconProps) {
return (
<span
className={`material-icons ${className}`}
style={{ fontSize: size }}
>
{name}
</span>
)
}

View File

@ -0,0 +1,13 @@
interface LoadingSpinnerProps {
size?: number
className?: string
}
export function LoadingSpinner({ size = 32, className = '' }: LoadingSpinnerProps) {
return (
<div
className={`animate-spin rounded-full border-2 border-music-border border-t-music-accent ${className}`}
style={{ width: size, height: size }}
/>
)
}

View File

@ -0,0 +1,47 @@
import { Link, useLocation } from 'react-router-dom'
import { motion } from 'framer-motion'
import { BOTTOM_NAV_ITEMS } from '@music-app/shared'
export function BottomNavBar() {
const location = useLocation()
return (
<nav className="fixed bottom-0 left-0 right-0 bg-music-dark/95 backdrop-blur-lg border-t border-music-border z-50">
<div className="flex items-center justify-around max-w-lg mx-auto">
{BOTTOM_NAV_ITEMS.map((item) => {
const isActive = location.pathname === item.route ||
(item.route !== '/' && location.pathname.startsWith(item.route))
return (
<Link
key={item.id}
to={item.route}
className="relative flex flex-col items-center py-2 px-3 min-w-0 transition-colors"
>
{isActive && (
<motion.div
layoutId="activeNav"
className="absolute -top-0.5 left-1/2 -translate-x-1/2 w-6 h-0.5 bg-music-accent rounded-full"
transition={{ type: 'spring', duration: 0.4, bounce: 0.2 }}
/>
)}
<motion.span
className={`material-icons text-xl ${isActive ? 'text-music-accent' : 'text-music-muted'}`}
animate={{ scale: isActive ? 1.1 : 1 }}
transition={{ type: 'spring', duration: 0.3 }}
>
{item.icon}
</motion.span>
<motion.span
className={`text-xs mt-0.5 truncate ${isActive ? 'text-music-accent' : 'text-music-muted'}`}
animate={{ opacity: isActive ? 1 : 0.7 }}
>
{item.label}
</motion.span>
</Link>
)
})}
</div>
</nav>
)
}

View File

@ -0,0 +1,30 @@
import { Link } from 'react-router-dom'
import { NAV_HUB_FEATURES } from '@music-app/shared'
interface NavHubProps {
title?: string
}
export function NavHub({ title }: NavHubProps) {
return (
<nav className="flex items-center justify-between py-4">
<Link to="/account" className="w-10 h-10 rounded-full bg-music-card flex items-center justify-center hover:bg-music-border transition-colors">
<span className="material-icons text-music-muted">person</span>
</Link>
{title && <h1 className="text-xl font-display font-semibold">{title}</h1>}
<div className="flex gap-2">
{NAV_HUB_FEATURES.map((feature) => (
<Link
key={feature.id}
to={feature.route}
className="px-4 py-2 rounded-full bg-music-card text-sm text-music-text hover:bg-music-border hover:text-music-accent transition-colors"
>
{feature.label}
</Link>
))}
</div>
</nav>
)
}

View File

@ -0,0 +1,68 @@
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { usePlayerStore } from '../../store/playerStore'
export function NowPlayingMiniBar() {
const currentSong = usePlayerStore(s => s.currentSong)
const isPlaying = usePlayerStore(s => s.isPlaying)
const togglePlay = usePlayerStore(s => s.togglePlay)
const next = usePlayerStore(s => s.next)
const previous = usePlayerStore(s => s.previous)
const progress = usePlayerStore(s => s.progress)
const progressPercent = currentSong ? (progress / currentSong.duration) * 100 : 0
return (
<motion.div
className="fixed bottom-12 left-0 right-0 z-40"
initial={{ y: 100 }}
animate={{ y: 0 }}
exit={{ y: 100 }}
transition={{ type: 'spring', duration: 0.5, bounce: 0.3 }}
>
<div className="h-0.5 bg-music-border relative overflow-hidden">
<motion.div
className="h-full bg-music-accent"
style={{ width: `${progressPercent}%` }}
transition={{ duration: 0.5 }}
/>
<div
className="absolute top-0 h-full bg-music-accent/30 blur-sm"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="bg-music-card/95 backdrop-blur-lg border-t border-music-border">
<Link to="/now-playing" className="flex items-center gap-3 px-4 py-2 max-w-6xl mx-auto">
<motion.div
className="w-10 h-10 rounded-full vinyl-record flex-shrink-0"
animate={isPlaying ? { rotate: 360 } : { rotate: 0 }}
transition={isPlaying ? { duration: 3, repeat: Infinity, ease: 'linear' } : {}}
/>
<div className="min-w-0 flex-1">
<motion.p
className="text-sm font-medium truncate"
key={currentSong?.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
>
{currentSong?.title || 'No song'}
</motion.p>
<p className="text-xs text-music-muted truncate">{currentSong?.artist}</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button onClick={previous} className="material-icons text-music-text hover:text-music-accent transition-colors">skip_previous</button>
<motion.button
onClick={togglePlay}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="material-icons text-xl text-music-accent"
>
{isPlaying ? 'pause_circle' : 'play_circle'}
</motion.button>
<button onClick={next} className="material-icons text-music-text hover:text-music-accent transition-colors">skip_next</button>
</div>
</Link>
</div>
</motion.div>
)
}

View File

@ -0,0 +1,63 @@
import { usePlayerStore } from '../../store/playerStore'
interface PlaybackControlsProps {
size?: 'sm' | 'md' | 'lg'
}
const SIZE_MAP = {
sm: { icon: 'text-xl', play: 'text-2xl', gap: 'gap-3' },
md: { icon: 'text-2xl', play: 'text-3xl', gap: 'gap-4' },
lg: { icon: 'text-3xl', play: 'text-5xl', gap: 'gap-6' },
}
export function PlaybackControls({ size = 'md' }: PlaybackControlsProps) {
const isPlaying = usePlayerStore(s => s.isPlaying)
const shuffle = usePlayerStore(s => s.shuffle)
const togglePlay = usePlayerStore(s => s.togglePlay)
const toggleShuffle = usePlayerStore(s => s.toggleShuffle)
const next = usePlayerStore(s => s.next)
const previous = usePlayerStore(s => s.previous)
const { icon, play, gap } = SIZE_MAP[size]
return (
<div className={`flex items-center justify-center ${gap}`}>
<button
onClick={toggleShuffle}
className={`material-icons ${icon} transition-colors ${
shuffle ? 'text-music-accent' : 'text-music-muted hover:text-music-text'
}`}
title="Shuffle"
>
shuffle
</button>
<button
onClick={previous}
className={`material-icons ${icon} text-music-text hover:text-music-accent transition-colors`}
title="Previous"
>
skip_previous
</button>
<button
onClick={togglePlay}
className={`material-icons ${play} text-music-accent hover:scale-110 transition-transform`}
title={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? 'pause_circle' : 'play_circle'}
</button>
<button
onClick={next}
className={`material-icons ${icon} text-music-text hover:text-music-accent transition-colors`}
title="Next"
>
skip_next
</button>
<button
className={`material-icons ${icon} text-music-muted hover:text-music-accent transition-colors`}
title="Add to playlist"
>
add
</button>
</div>
)
}

View File

@ -0,0 +1,69 @@
import { useState, useRef, useCallback } from 'react'
interface ProgressBarProps {
progress: number
duration: number
onSeek: (time: number) => void
className?: string
}
export function ProgressBar({ progress, duration, onSeek, className = '' }: ProgressBarProps) {
const [isDragging, setIsDragging] = useState(false)
const barRef = useRef<HTMLDivElement>(null)
const percent = duration > 0 ? (progress / duration) * 100 : 0
const handleSeek = useCallback((clientX: number) => {
if (!barRef.current) return
const rect = barRef.current.getBoundingClientRect()
const x = Math.max(0, Math.min(clientX - rect.left, rect.width))
const ratio = x / rect.width
onSeek(duration * ratio)
}, [duration, onSeek])
const handleClick = (e: React.MouseEvent) => handleSeek(e.clientX)
const handleMouseDown = (e: React.MouseEvent) => {
setIsDragging(true)
handleSeek(e.clientX)
}
const handleMouseMove = (e: React.MouseEvent) => {
if (isDragging) handleSeek(e.clientX)
}
const handleMouseUp = () => setIsDragging(false)
const formatTime = (sec: number) => {
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
return (
<div className={`w-full ${className}`}>
<div
ref={barRef}
className="relative h-1 bg-music-border rounded-full cursor-pointer group"
onClick={handleClick}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div
className="absolute left-0 top-0 h-full bg-music-accent rounded-full transition-none"
style={{ width: `${percent}%` }}
/>
<div
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-music-accent rounded-full opacity-0 group-hover:opacity-100 transition-opacity -ml-1.5"
style={{ left: `${percent}%` }}
/>
</div>
<div className="flex justify-between mt-1 text-xs text-music-muted">
<span>{formatTime(progress)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
)
}

View File

@ -0,0 +1,51 @@
import { CSSProperties } from 'react'
import { motion } from 'framer-motion'
interface VinylRecordProps {
size?: number
albumArt?: string | null
isPlaying?: boolean
className?: string
style?: CSSProperties
}
export function VinylRecord({ size = 120, albumArt, isPlaying = false, className = '', style }: VinylRecordProps) {
return (
<motion.div
className={`relative rounded-full vinyl-record border-2 border-music-border ${className}`}
style={{ width: size, height: size, ...style }}
animate={isPlaying ? { rotate: 360 } : { rotate: 0 }}
transition={isPlaying ? { duration: 3, repeat: Infinity, ease: 'linear' } : {}}
>
{/* Grooves with more detail */}
<div className="absolute inset-1 rounded-full border border-music-border/20" />
<div className="absolute inset-3 rounded-full border border-music-border/15" />
<div className="absolute inset-5 rounded-full border border-music-border/15" />
<div className="absolute inset-7 rounded-full border border-music-border/10" />
<div className="absolute inset-9 rounded-full border border-music-border/10" />
{/* Shine effect */}
<div
className="absolute inset-0 rounded-full"
style={{
background: 'linear-gradient(135deg, rgba(255,255,255,0.05) 0%, transparent 50%, rgba(255,255,255,0.02) 100%)',
}}
/>
{/* Center label */}
<div className="absolute inset-0 flex items-center justify-center">
{albumArt ? (
<img
src={albumArt}
alt="Album art"
className="rounded-full w-1/3 h-1/3 object-cover"
/>
) : (
<div className="w-1/3 h-1/3 rounded-full bg-music-accent/20 flex items-center justify-center">
<span className="material-icons text-music-accent/50" style={{ fontSize: size * 0.15 }}>music_note</span>
</div>
)}
</div>
</motion.div>
)
}

View File

@ -0,0 +1,47 @@
import { motion } from 'framer-motion'
interface VinylSleeveProps {
size?: number
albumArt?: string | null
name?: string
onClick?: () => void
}
export function VinylSleeve({ size = 80, albumArt, name, onClick }: VinylSleeveProps) {
return (
<motion.div
className="flex items-center cursor-pointer group"
onClick={onClick}
whileHover={{ scale: 1.05 }}
>
{/* Record emerging to the left */}
<div
className="rounded-full vinyl-record border border-music-border absolute group-hover:animate-vinyl-spin-slow"
style={{ width: size * 0.8, height: size * 0.8, left: -size * 0.2, top: size * 0.1 }}
>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-1/3 h-1/3 rounded-full bg-music-accent/20" />
</div>
</div>
{/* Sleeve */}
<div
className="relative rounded-lg overflow-hidden bg-music-card ml-auto z-10"
style={{ width: size, height: size }}
>
{albumArt ? (
<img src={albumArt} alt={name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-music-card to-music-dark">
<span className="material-icons text-music-muted">album</span>
</div>
)}
</div>
{name && (
<p className="text-sm text-music-text ml-3 truncate group-hover:text-music-accent transition-colors">
{name}
</p>
)}
</motion.div>
)
}

View File

@ -0,0 +1,36 @@
import { motion } from 'framer-motion'
import { VinylRecord } from './VinylRecord'
interface VinylStackProps {
count?: number
size?: number
name: string
isPlaying?: boolean
onClick?: () => void
}
export function VinylStack({ count = 4, size = 100, name, isPlaying = false, onClick }: VinylStackProps) {
return (
<motion.div
className="flex flex-col items-center cursor-pointer group"
onClick={onClick}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<div className="relative" style={{ width: size, height: size * 0.4 }}>
{Array.from({ length: count }).map((_, i) => (
<VinylRecord
key={i}
size={size}
isPlaying={isPlaying && i === 0}
className="absolute left-0 transition-shadow group-hover:shadow-lg group-hover:shadow-music-accent/10"
style={{ top: `${i * -((size * (count - 1)) / count) + (size * 0.1) * i}px`, zIndex: count - i }}
/>
))}
</div>
<p className="text-sm font-medium mt-3 truncate max-w-full text-music-text group-hover:text-music-accent transition-colors">
{name}
</p>
</motion.div>
)
}

10
web/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { App } from './app/App'
import './styles/globals.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@ -0,0 +1,111 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '@music-app/shared'
interface StatsData {
total_songs: number
total_playlists: number
total_listening_time: number
top_artists: { name: string; count: number }[]
top_genres: { name: string; count: number }[]
}
const MENU_ITEMS = [
{ icon: 'extension', label: 'Plugins', route: '#' },
{ icon: 'dns', label: 'Servers', route: '/settings/servers' },
{ icon: 'person', label: 'About You', route: '#', hasStats: true },
{ icon: 'radio', label: 'Internet Radio', route: '/radio' },
{ icon: 'update', label: 'Updates', route: '#' },
{ icon: 'settings', label: 'Settings & Privacy', route: '#' },
]
export function AccountPage() {
const [stats, setStats] = useState<StatsData | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
api.getAccountStats().then(res => {
if (res.success && res.data) {
setStats(res.data as StatsData)
}
setLoading(false)
})
}, [])
return (
<div className="px-4 py-4 max-w-6xl mx-auto">
<div className="flex items-center gap-4 mb-8">
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-music-accent/50 to-music-vinyl flex items-center justify-center">
<span className="material-icons text-3xl text-white">person</span>
</div>
<div>
<h1 className="text-2xl font-display font-semibold">Welcome, User</h1>
{loading ? (
<div className="flex items-center gap-2 mt-1">
<div className="animate-spin rounded-full h-3 w-3 border border-music-muted border-t-music-accent" />
<span className="text-sm text-music-muted">Loading stats...</span>
</div>
) : stats ? (
<p className="text-sm text-music-muted">{stats.total_songs} songs {stats.total_playlists} playlists</p>
) : null}
</div>
</div>
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
<div className="p-4 rounded-xl bg-music-card text-center">
<p className="text-2xl font-display font-semibold text-music-accent">{stats.total_songs}</p>
<p className="text-xs text-music-muted">Songs</p>
</div>
<div className="p-4 rounded-xl bg-music-card text-center">
<p className="text-2xl font-display font-semibold text-music-accent">{stats.total_playlists}</p>
<p className="text-xs text-music-muted">Playlists</p>
</div>
<div className="p-4 rounded-xl bg-music-card text-center">
<p className="text-2xl font-display font-semibold text-music-accent">{Math.floor(stats.total_listening_time / 3600)}</p>
<p className="text-xs text-music-muted">Hours</p>
</div>
<div className="p-4 rounded-xl bg-music-card text-center">
<p className="text-2xl font-display font-semibold text-music-accent">{stats.top_genres.length}</p>
<p className="text-xs text-music-muted">Genres</p>
</div>
</div>
)}
<nav className="space-y-1">
{MENU_ITEMS.map((item) => (
item.hasStats && stats ? (
<div key={item.label} className="p-4 rounded-xl bg-music-card">
<div className="flex items-center gap-4 mb-3">
<span className="material-icons text-music-muted">{item.icon}</span>
<span className="font-medium">{item.label}</span>
</div>
{stats.top_artists.length > 0 && (
<div>
<p className="text-xs text-music-muted mb-2">Top Artists</p>
<div className="space-y-1">
{stats.top_artists.slice(0, 3).map((artist, i) => (
<div key={artist.name} className="flex items-center justify-between text-sm">
<span>{i + 1}. {artist.name}</span>
<span className="text-music-muted">{artist.count} plays</span>
</div>
))}
</div>
</div>
)}
</div>
) : (
<Link
key={item.label}
to={item.route}
className="flex items-center gap-4 p-4 rounded-xl hover:bg-music-card transition-colors"
>
<span className="material-icons text-music-muted">{item.icon}</span>
<span className="font-medium">{item.label}</span>
</Link>
)
))}
</nav>
</div>
)
}

View File

@ -0,0 +1,41 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { CREATE_TABS } from '@music-app/shared'
export function CreatePage() {
const [activeTab, setActiveTab] = useState<string>(CREATE_TABS[0].id)
return (
<div className="px-4 py-4 max-w-6xl mx-auto">
<nav className="flex items-center justify-between mb-6">
<Link to="/account" className="w-10 h-10 rounded-full bg-music-card flex items-center justify-center">
<span className="material-icons text-music-muted">person</span>
</Link>
<h1 className="text-xl font-display font-semibold">Create</h1>
<div className="w-10" />
</nav>
<div className="flex gap-2 mb-6">
{CREATE_TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 rounded-full text-sm transition-colors ${
activeTab === tab.id
? 'bg-music-accent text-music-black font-medium'
: 'bg-music-card text-music-muted hover:text-music-text'
}`}
>
{tab.label}
</button>
))}
</div>
<div className="p-8 rounded-2xl bg-music-card text-center">
<span className="material-icons text-4xl text-music-muted mb-3">add_circle</span>
<h2 className="text-lg font-display font-semibold mb-1">{CREATE_TABS.find(t => t.id === activeTab)?.label}</h2>
<p className="text-sm text-music-muted">{CREATE_TABS.find(t => t.id === activeTab)?.description}</p>
</div>
</div>
)
}

View File

@ -0,0 +1,90 @@
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { usePlayerStore } from '../store/playerStore'
import { MOOD_CATEGORIES } from '@music-app/shared'
const MotionLink = motion(Link)
export function HomePage() {
const currentSong = usePlayerStore(s => s.currentSong)
return (
<div className="px-4 py-4 max-w-6xl mx-auto">
<nav className="flex items-center justify-between mb-6">
<Link to="/account" className="w-10 h-10 rounded-full bg-music-card flex items-center justify-center">
<span className="material-icons text-music-muted">person</span>
</Link>
<div className="flex gap-2">
{['Music', 'New Music', 'Mood', 'LoFi'].map((tab) => (
<Link
key={tab}
to={tab === 'Music' ? '/library' : tab === 'New Music' ? '/releases' : tab === 'Mood' ? '/mood' : '/lofi'}
className="px-4 py-2 rounded-full bg-music-card text-sm text-music-text hover:bg-music-border transition-colors"
>
{tab}
</Link>
))}
</div>
</nav>
{currentSong && (
<div className="mb-6 p-4 rounded-2xl bg-music-card">
<h2 className="text-xs text-music-muted uppercase tracking-wider mb-3">Now Playing</h2>
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-lg bg-music-vinyl flex items-center justify-center">
<span className="material-icons text-music-muted">music_note</span>
</div>
<div>
<p className="font-display font-semibold">{currentSong.title}</p>
<p className="text-sm text-music-muted">{currentSong.artist}</p>
</div>
</div>
</div>
)}
<section className="mb-6">
<h2 className="text-lg font-display font-semibold mb-3">Quick Access</h2>
<div className="grid grid-cols-2 gap-3">
<Link to="/library" className="p-4 rounded-xl bg-gradient-to-br from-music-card to-music-dark hover:from-music-border transition-all">
<span className="material-icons text-music-accent mb-2">library_music</span>
<p className="font-medium">My Music</p>
</Link>
<Link to="/releases" className="p-4 rounded-xl bg-gradient-to-br from-music-card to-music-dark hover:from-music-border transition-all">
<span className="material-icons text-mood-happy mb-2">fiber_new</span>
<p className="font-medium">New Releases</p>
</Link>
</div>
</section>
<section>
<h2 className="text-lg font-display font-semibold mb-3">Mood Radio</h2>
<motion.div
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3"
initial="hidden"
animate="visible"
variants={{
visible: { transition: { staggerChildren: 0.05 } }
}}
>
{MOOD_CATEGORIES.map((mood) => (
<MotionLink
key={mood.id}
to="/mood"
variants={{
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 }
}}
whileHover={{ scale: 1.05, y: -4 }}
whileTap={{ scale: 0.95 }}
className="relative overflow-hidden rounded-xl p-4 aspect-square flex flex-col items-center justify-center text-center"
style={{ background: `linear-gradient(135deg, ${mood.colorHex}40, ${mood.colorHex}20)` }}
>
<p className="font-medium text-sm">{mood.name}</p>
<p className="text-xs text-music-muted mt-1 line-clamp-2">{mood.description}</p>
</MotionLink>
))}
</motion.div>
</section>
</div>
)
}

View File

@ -0,0 +1,71 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '@music-app/shared'
import { VinylStack } from '../components/vinyl/VinylStack'
interface PlaylistData {
id: string
name: string
song_count: number
}
export function LibraryPage() {
const [playlists, setPlaylists] = useState<PlaylistData[]>([])
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
api.getPlaylists().then(res => {
if (res.success && res.data) {
setPlaylists(res.data as PlaylistData[])
}
setLoading(false)
})
}, [])
const filtered = playlists.filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
return (
<div className="px-4 py-4 max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<Link to="/account" className="w-10 h-10 rounded-full bg-music-card flex items-center justify-center hover:bg-music-border transition-colors">
<span className="material-icons text-music-muted">person</span>
</Link>
<h1 className="text-xl font-display font-semibold">Library</h1>
<div className="relative">
<input
type="text"
placeholder="Search playlists..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-64 bg-music-card rounded-full px-4 py-2 pl-10 text-sm outline-none focus:ring-1 focus:ring-music-accent placeholder:text-music-muted"
/>
<span className="material-icons absolute left-3 top-1/2 -translate-y-1/2 text-music-muted text-sm">search</span>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-music-border border-t-music-accent" />
</div>
) : filtered.length === 0 ? (
<div className="text-center py-20">
<span className="material-icons text-5xl text-music-muted mb-3">library_music</span>
<p className="text-music-muted">No playlists yet</p>
<Link to="/create" className="text-music-accent text-sm mt-2 inline-block hover:underline">Create your first playlist</Link>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-8 justify-items-center">
{filtered.map((playlist) => (
<VinylStack
key={playlist.id}
name={playlist.name}
count={Math.min(4, Math.max(1, playlist.song_count))}
onClick={() => window.location.href = `/playlist/${playlist.id}`}
/>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,86 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '@music-app/shared'
interface LofiData {
id: string
name: string
stream_url: string
image_path: string | null
description: string | null
source_platform: string
}
export function LofiChannelPage() {
const [channels, setChannels] = useState<LofiData[]>([])
const [loading, setLoading] = useState(true)
const [activeChannel, setActiveChannel] = useState<LofiData | null>(null)
useEffect(() => {
api.getLofiChannels().then(res => {
if (res.success && res.data) {
setChannels(res.data as LofiData[])
}
setLoading(false)
})
}, [])
const handlePlay = (channel: LofiData) => {
setActiveChannel(channel)
window.open(channel.stream_url, '_blank')
}
return (
<div className="px-4 py-4 max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<Link to="/account" className="w-10 h-10 rounded-full bg-music-card flex items-center justify-center hover:bg-music-border transition-colors">
<span className="material-icons text-music-muted">person</span>
</Link>
<h1 className="text-xl font-display font-semibold">LoFi Channel</h1>
<div className="w-10" />
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-music-border border-t-music-accent" />
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{channels.map((channel) => (
<button
key={channel.id}
onClick={() => handlePlay(channel)}
className={`relative rounded-2xl overflow-hidden aspect-video text-left transition-all group hover:scale-[1.02] ${
activeChannel?.id === channel.id ? 'ring-2 ring-music-accent' : ''
}`}
>
<div className="absolute inset-0 bg-gradient-to-br from-music-vinyl via-music-dark to-music-black flex items-center justify-center">
<span className="material-icons text-7xl text-music-muted/50 group-hover:text-music-muted/80 transition-colors">headphones</span>
</div>
{activeChannel?.id === channel.id && (
<div className="absolute top-3 left-3 px-2 py-1 rounded-full bg-music-accent text-music-black text-xs font-medium flex items-center gap-1">
<span className="animate-pulse"></span> LIVE
</div>
)}
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/90 to-transparent">
<p className="text-xs text-music-accent mb-1 uppercase tracking-wider">LoFi</p>
<p className="font-medium">{channel.name}</p>
{channel.description && (
<p className="text-xs text-music-muted mt-1 line-clamp-1">{channel.description}</p>
)}
</div>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/20">
<div className="w-14 h-14 rounded-full bg-music-accent flex items-center justify-center shadow-lg">
<span className="material-icons text-music-black text-2xl">play_arrow</span>
</div>
</div>
</button>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,129 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { api, MOOD_CATEGORIES } from '@music-app/shared'
import { usePlayerStore } from '../store/playerStore'
interface MoodSong {
id: string
title: string
artist: string
album: string
duration_sec: number
album_art_path: string | null
}
export function MoodRadioPage() {
const [activeMood, setActiveMood] = useState(MOOD_CATEGORIES[0])
const [songs, setSongs] = useState<MoodSong[]>([])
const [loading, setLoading] = useState(false)
const isPlaying = usePlayerStore(s => s.isPlaying)
const togglePlay = usePlayerStore(s => s.togglePlay)
const setPlaylist = usePlayerStore(s => s.setPlaylist)
const loadMoodSongs = useCallback((mood: string) => {
setLoading(true)
api.getMoodPlaylist(mood).then(res => {
if (res.success && res.data) {
setSongs((res.data as any).songs || [])
}
setLoading(false)
})
}, [])
useEffect(() => {
loadMoodSongs(activeMood.name)
}, [activeMood.name, loadMoodSongs])
const handlePlayMood = () => {
const playlistSongs = songs.map(s => ({
id: s.id,
title: s.title,
artist: s.artist,
album: s.album,
albumArt: s.album_art_path,
duration: s.duration_sec || 180,
}))
setPlaylist(playlistSongs, 0)
}
const handleSetMood = () => {
const moods = MOOD_CATEGORIES.filter(m => m.id !== activeMood.id)
const next = moods[Math.floor(Math.random() * moods.length)]
setActiveMood(next)
}
return (
<div
className="h-full flex flex-col items-center justify-between p-6 relative min-h-screen"
style={{ backgroundColor: activeMood.colorHex + '15' }}
>
<nav className="z-10 w-full flex items-center justify-between mb-6">
<Link to="/" className="w-10 h-10 rounded-full bg-music-card flex items-center justify-center hover:bg-music-border transition-colors">
<span className="material-icons text-music-muted">arrow_back</span>
</Link>
<h1 className="text-xl font-display font-semibold">Mood Radio</h1>
<div className="w-10" />
</nav>
<div className="z-10 relative flex items-center justify-center my-8">
<svg className="w-72 h-72 -rotate-90" viewBox="0 0 120 120">
<circle cx="60" cy="60" r="54" fill="none" stroke="#2a2a2a" strokeWidth="3" />
<circle
cx="60" cy="60" r="54" fill="none"
stroke={activeMood.colorHex} strokeWidth="3"
strokeDasharray={`${2 * Math.PI * 54}`}
strokeDashoffset={`${2 * Math.PI * 54 * (isPlaying ? 0.3 : 0.8)}`}
strokeLinecap="round"
className="transition-all duration-1000"
/>
</svg>
<div
className="absolute w-56 h-56 rounded-full overflow-hidden"
style={{
background: `radial-gradient(circle, ${activeMood.colorHex}80, ${activeMood.colorHex}20)`,
filter: 'blur(20px)',
}}
/>
<div className="absolute w-32 h-32 rounded-full bg-music-card flex items-center justify-center">
<span className="material-icons text-5xl" style={{ color: activeMood.colorHex }}>music_note</span>
</div>
</div>
<div className="z-10 text-center mt-4">
<h2 className="text-2xl font-display font-semibold">{activeMood.name}</h2>
<p className="text-sm text-music-muted mt-1">{activeMood.description}</p>
{loading && (
<div className="mt-3">
<div className="animate-spin rounded-full h-5 w-5 border-2 border-music-border border-t-music-accent mx-auto" />
</div>
)}
{!loading && songs.length > 0 && (
<p className="text-xs text-music-muted mt-2">{songs.length} songs</p>
)}
</div>
<div className="z-10 flex flex-col items-center gap-4 mt-6 mb-8">
<button
onClick={handlePlayMood}
disabled={songs.length === 0 || loading}
className="px-8 py-3 rounded-full bg-music-accent text-music-black font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-music-accent/90 transition-colors flex items-center gap-2"
>
<span className="material-icons">
{isPlaying ? 'pause_circle' : 'play_circle'}
</span>
{isPlaying ? 'Playing' : 'Play Mood'}
</button>
<p className="text-xs text-music-muted uppercase tracking-wider">Currently Playing</p>
<span className="material-icons text-music-muted animate-bounce">keyboard_arrow_up</span>
<button
onClick={handleSetMood}
className="px-8 py-3 rounded-full bg-music-card text-sm font-medium hover:bg-music-border transition-colors"
>
Set the Mood
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,93 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '@music-app/shared'
interface ReleaseTrack {
title: string
duration: number
duration_formatted: string
}
interface ReleaseAlbum {
id: string
title: string
cover_art: string | null
release_date: string
tracks: ReleaseTrack[]
}
interface ReleaseData {
artist_name: string
albums: ReleaseAlbum[]
}
export function NewReleasesPage() {
const [releases, setReleases] = useState<ReleaseData[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
api.getReleases().then(res => {
if (res.success && res.data) {
setReleases(res.data as ReleaseData[])
}
setLoading(false)
})
}, [])
return (
<div className="px-4 py-4 max-w-6xl mx-auto">
<nav className="flex items-center justify-between mb-6">
<Link to="/account" className="w-10 h-10 rounded-full bg-music-card flex items-center justify-center hover:bg-music-border transition-colors">
<span className="material-icons text-music-muted">person</span>
</Link>
<h1 className="text-xl font-display font-semibold">New Releases</h1>
<div className="w-10" />
</nav>
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-music-border border-t-music-accent" />
</div>
) : releases.length === 0 ? (
<div className="text-center py-20">
<span className="material-icons text-5xl text-music-muted mb-3">fiber_new</span>
<p className="text-music-muted">Add music to your library to see new releases</p>
<Link to="/library" className="text-music-accent text-sm mt-2 inline-block hover:underline">Go to Library</Link>
</div>
) : (
<div className="space-y-6">
{releases.map((release) => (
<div key={release.artist_name} className="p-4 rounded-2xl bg-music-card">
<div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 rounded-lg bg-music-dark flex items-center justify-center flex-shrink-0">
<span className="material-icons text-music-muted">person</span>
</div>
<div>
<h2 className="text-lg font-display font-semibold">{release.artist_name}</h2>
<p className="text-sm text-music-muted">{release.albums.length} new release{release.albums.length !== 1 ? 's' : ''}</p>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3">
{release.albums.map((album) => (
<div key={album.id} className="group cursor-pointer">
<div className="aspect-square rounded-lg bg-music-dark mb-2 overflow-hidden relative">
<div className="w-full h-full flex items-center justify-center">
<span className="material-icons text-music-muted text-xl">album</span>
</div>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<span className="material-icons text-white opacity-0 group-hover:opacity-100 transition-opacity text-2xl">play_arrow</span>
</div>
</div>
<p className="text-xs font-medium truncate">{album.title}</p>
<p className="text-xs text-music-muted">{album.tracks.length} tracks</p>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,116 @@
import { useState, useRef, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { usePlayerStore } from '../store/playerStore'
export function NowPlayingPage() {
const currentSong = usePlayerStore(s => s.currentSong)
const isPlaying = usePlayerStore(s => s.isPlaying)
const progress = usePlayerStore(s => s.progress)
const togglePlay = usePlayerStore(s => s.togglePlay)
const seek = usePlayerStore(s => s.seek)
const toggleShuffle = usePlayerStore(s => s.toggleShuffle)
const next = usePlayerStore(s => s.next)
const previous = usePlayerStore(s => s.previous)
const shuffle = usePlayerStore(s => s.shuffle)
const [isDragging, setIsDragging] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const handleSeek = useCallback((clientX: number) => {
if (!containerRef.current || !currentSong) return
const rect = containerRef.current.getBoundingClientRect()
const x = Math.max(0, Math.min(clientX - rect.left, rect.width))
const ratio = x / rect.width
seek(currentSong.duration * ratio)
}, [currentSong, seek])
const handleMouseDown = (e: React.MouseEvent) => {
setIsDragging(true)
handleSeek(e.clientX)
}
const handleMouseMove = (e: React.MouseEvent) => {
if (isDragging) handleSeek(e.clientX)
}
const handleMouseUp = () => setIsDragging(false)
const progressPercent = currentSong ? (progress / currentSong.duration) * 100 : 0
return (
<div
ref={containerRef}
className="h-full flex flex-col items-center justify-between p-6 relative"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{/* Animated background overlay following progress */}
<motion.div
className="absolute inset-0 pointer-events-none"
style={{
background: `linear-gradient(to right, rgba(245,197,66,0.15) 0%, rgba(245,197,66,0.15) ${progressPercent}%, transparent ${progressPercent}%, transparent 100%)`,
}}
animate={{ opacity: 0.25 }}
transition={{ duration: 0.3 }}
/>
<Link to="/" className="z-10 self-start">
<span className="material-icons text-music-muted">arrow_back</span>
</Link>
{/* Album art with spin animation */}
<motion.div
className="z-10 w-64 h-64 sm:w-80 sm:h-80 rounded-2xl bg-music-vinyl flex items-center justify-center mt-8"
animate={isPlaying ? { rotate: 360 } : { rotate: 0 }}
transition={isPlaying ? { duration: 4, repeat: Infinity, ease: 'linear' } : {}}
whileHover={{ scale: 1.02 }}
>
<span className="material-icons text-6xl text-music-muted">music_note</span>
</motion.div>
<div className="z-10 text-center mt-8">
<h2 className="text-2xl font-display font-semibold">{currentSong?.title || 'No song playing'}</h2>
<p className="text-music-muted mt-1">{currentSong?.artist || ''}</p>
</div>
<div className="z-10 w-full max-w-md mt-6">
<div className="h-1 bg-music-border rounded-full overflow-hidden cursor-pointer">
<div
className="h-full bg-music-accent rounded-full transition-all duration-100"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="flex justify-between mt-2 text-xs text-music-muted">
<span>{formatTime(progress)}</span>
<span>{currentSong ? formatTime(currentSong.duration) : '0:00'}</span>
</div>
</div>
<div className="z-10 flex items-center gap-6 mt-4">
<button onClick={toggleShuffle} className={`material-icons ${shuffle ? 'text-music-accent' : 'text-music-muted'}`}>
shuffle
</button>
<button onClick={previous} className="material-icons text-3xl text-music-text">skip_previous</button>
<button onClick={togglePlay} className="material-icons text-5xl text-music-accent">
{isPlaying ? 'pause_circle' : 'play_circle'}
</button>
<button onClick={next} className="material-icons text-3xl text-music-text">skip_next</button>
<button className="material-icons text-music-muted">add</button>
</div>
<div className="z-10 flex items-center gap-2 mt-4">
<Link to="/shareplay" className="material-icons text-music-muted">branding_watermark</Link>
<span className="text-xs text-music-muted">Speaker</span>
</div>
</div>
)
}
function formatTime(sec: number): string {
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}

View File

@ -0,0 +1,136 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { api } from '@music-app/shared'
import { usePlayerStore } from '../store/playerStore'
interface SongData {
id: string
title: string
artist: string
duration_sec: number
}
interface PlaylistData {
id: string
name: string
description: string
songs: SongData[]
song_count: number
}
export function PlaylistPage() {
const { id } = useParams<{ id: string }>()
const [playlist, setPlaylist] = useState<PlaylistData | null>(null)
const [loading, setLoading] = useState(true)
const isPlaying = usePlayerStore(s => s.isPlaying)
const togglePlay = usePlayerStore(s => s.togglePlay)
const setPlaylistSongs = usePlayerStore(s => s.setPlaylist)
useEffect(() => {
if (!id) return
api.getPlaylist(id).then(res => {
if (res.success && res.data) {
setPlaylist(res.data as PlaylistData)
}
setLoading(false)
})
}, [id])
const handlePlayAll = () => {
if (!playlist) return
const songs = playlist.songs.map(s => ({
id: s.id,
title: s.title,
artist: s.artist,
album: '',
albumArt: null,
duration: s.duration_sec || 180,
}))
setPlaylistSongs(songs, 0)
}
const formatTime = (sec: number) => {
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
return (
<div className="flex h-full">
<div className="w-20 bg-music-dark border-r border-music-border flex flex-col items-center py-4 gap-3 flex-shrink-0">
<Link to="/library" className="material-icons text-music-muted hover:text-music-text transition-colors pb-2">arrow_back</Link>
<div className="flex flex-col gap-2">
{[...Array(5)].map((_, i) => (
<div key={i} className={`w-14 h-14 rounded-lg bg-music-card ${i === 0 ? 'ring-2 ring-music-accent' : ''}`}>
{i === 0 && (
<div className="w-full h-full flex items-center justify-center bg-black/40 rounded-lg">
<span className="material-icons text-white text-sm">play_arrow</span>
</div>
)}
</div>
))}
</div>
<div className="flex-1 w-1 bg-music-border rounded-full relative my-2">
<div className="absolute bottom-0 w-full bg-music-accent rounded-full transition-all" style={{ height: '35%' }} />
</div>
<span className="material-icons text-music-muted text-sm">star</span>
<button onClick={togglePlay} className="material-icons text-music-accent">
{isPlaying ? 'pause_circle' : 'play_circle'}
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-music-border border-t-music-accent" />
</div>
) : playlist ? (
<>
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-display font-semibold">{playlist.name}</h1>
<Link to="/shareplay" className="material-icons text-music-muted hover:text-music-accent transition-colors">branding_watermark</Link>
</div>
<div className="flex items-center gap-6 mb-6">
<div className="w-40 h-40 rounded-xl bg-gradient-to-br from-music-card to-music-dark flex items-center justify-center relative">
<span className="material-icons text-5xl text-music-muted">album</span>
<div className="absolute -right-3 -bottom-3 w-16 h-16 rounded-full vinyl-record border border-music-border" />
</div>
<div>
<p className="text-sm text-music-muted">{playlist.song_count} songs</p>
<button
onClick={handlePlayAll}
className="mt-2 px-6 py-2 rounded-full bg-music-accent text-music-black font-medium text-sm hover:bg-music-accent/90 transition-colors flex items-center gap-2"
>
<span className="material-icons text-sm">play_arrow</span>
Play All
</button>
</div>
</div>
<div className="space-y-1">
{playlist.songs.map((song, i) => (
<div key={song.id} className="flex items-center gap-3 p-3 rounded-lg hover:bg-music-card transition-colors group">
<span className="text-sm text-music-muted w-6 text-right">{i + 1}</span>
<div className="w-8 h-8 rounded-full vinyl-record flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{song.title}</p>
<p className="text-xs text-music-muted truncate">{song.artist}</p>
</div>
<span className="text-xs text-music-muted">{formatTime(song.duration_sec)}</span>
<button className="material-icons text-music-muted opacity-0 group-hover:opacity-100 transition-opacity">more_vert</button>
</div>
))}
</div>
</>
) : (
<div className="text-center py-20">
<span className="material-icons text-5xl text-music-muted mb-3">not_found</span>
<p className="text-music-muted">Playlist not found</p>
<Link to="/library" className="text-music-accent text-sm mt-2 inline-block hover:underline">Back to Library</Link>
</div>
)}
</div>
</div>
)
}

115
web/src/pages/RadioPage.tsx Normal file
View File

@ -0,0 +1,115 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { api } from '@music-app/shared'
interface StationData {
id: string
name: string
stream_url: string
genre: string
country: string
votes: number
}
export function RadioPage() {
const [stations, setStations] = useState<StationData[]>([])
const [nearby, setNearby] = useState<StationData[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
useEffect(() => {
Promise.all([
api.getRadioStations(undefined, undefined, 20),
navigator.geolocation?.getCurrentPosition(
(pos) => api.getNearbyStations(pos.coords.latitude, pos.coords.longitude).then(res => {
if (res.success && res.data) setNearby(res.data as StationData[])
}),
() => {}
),
]).then(async () => {
const res = await api.getRadioStations(undefined, undefined, 20)
if (res.success && res.data) {
setStations(res.data as StationData[])
}
setLoading(false)
})
}, [])
const handlePlayStation = (station: StationData) => {
window.open(station.stream_url, '_blank')
}
return (
<div className="px-4 py-4 max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<Link to="/account" className="w-10 h-10 rounded-full bg-music-card flex items-center justify-center hover:bg-music-border transition-colors">
<span className="material-icons text-music-muted">person</span>
</Link>
<h1 className="text-xl font-display font-semibold">Internet Radio</h1>
<div className="w-10" />
</div>
<div className="relative mb-6">
<input
type="text"
placeholder="What music is calling to you?"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-music-card rounded-full px-4 py-3 pl-12 text-sm outline-none focus:ring-1 focus:ring-music-accent placeholder:text-music-muted"
/>
<span className="material-icons absolute left-4 top-1/2 -translate-y-1/2 text-music-muted">search</span>
</div>
{nearby.length > 0 && (
<div className="mb-6 p-4 rounded-2xl bg-music-card border border-music-border">
<h2 className="text-sm font-semibold mb-3 flex items-center gap-2">
<span className="material-icons text-sm text-music-accent">near_me</span>
Near You
</h2>
<div className="space-y-2">
{nearby.slice(0, 3).map((station) => (
<button
key={station.id}
onClick={() => handlePlayStation(station)}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-music-dark transition-colors text-left"
>
<div className="w-10 h-10 rounded-full bg-music-dark flex items-center justify-center flex-shrink-0">
<span className="material-icons text-music-muted text-sm">radio</span>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{station.name}</p>
<p className="text-xs text-music-muted truncate">{station.genre || 'Radio'} {station.country}</p>
</div>
<span className="material-icons text-music-accent text-sm">play_arrow</span>
</button>
))}
</div>
</div>
)}
<h2 className="text-lg font-display font-semibold mb-3">Stations</h2>
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-music-border border-t-music-accent" />
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{stations.map((station) => (
<button
key={station.id}
onClick={() => handlePlayStation(station)}
className="rounded-xl bg-music-card p-4 aspect-square flex flex-col items-center justify-center text-center hover:bg-music-dark transition-colors group"
>
<div className="w-16 h-16 rounded-full bg-music-dark flex items-center justify-center mb-3 group-hover:ring-2 ring-music-accent/50 transition-all">
<span className="material-icons text-music-muted">radio</span>
</div>
<p className="text-sm font-medium truncate w-full">{station.name}</p>
<p className="text-xs text-music-muted truncate w-full">{station.genre || station.country}</p>
</button>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,89 @@
import { Link } from 'react-router-dom'
import { SEARCH_FEATURES } from '@music-app/shared'
import { usePlayerStore } from '../store/playerStore'
export function SearchPage() {
const currentSong = usePlayerStore(s => s.currentSong)
const isPlaying = usePlayerStore(s => s.isPlaying)
const togglePlay = usePlayerStore(s => s.togglePlay)
return (
<div className="px-4 py-4 max-w-6xl mx-auto pb-32">
<div className="flex items-center justify-between mb-6">
<Link to="/account" className="w-10 h-10 rounded-full bg-music-card flex items-center justify-center">
<span className="material-icons text-music-muted">person</span>
</Link>
<div className="w-10" />
<div className="w-10" />
</div>
<div className="relative mb-6">
<input
type="text"
placeholder="What music is calling to you?"
className="w-full bg-music-card rounded-full px-4 py-3 pl-12 text-sm outline-none focus:ring-1 focus:ring-music-accent"
/>
<span className="material-icons absolute left-4 top-1/2 -translate-y-1/2 text-music-muted">search</span>
</div>
<div className="grid grid-cols-3 sm:grid-cols-5 gap-3 mb-6">
{SEARCH_FEATURES.map((feature) => (
<Link
key={feature.id}
to={feature.route}
className="rounded-xl p-4 aspect-square flex flex-col items-center justify-center text-center text-sm font-medium"
style={{ backgroundColor: `${feature.color}30` }}
>
<span className="material-icons mb-1" style={{ color: feature.color }}>{feature.icon}</span>
{feature.name}
</Link>
))}
</div>
<section className="mb-6">
<h2 className="text-lg font-display font-semibold mb-3">Music Suggestions</h2>
<div className="grid grid-cols-5 gap-3">
{[...Array(10)].map((_, i) => (
<div key={i} className="aspect-square rounded-lg bg-music-card" />
))}
</div>
</section>
<section>
<h2 className="text-lg font-display font-semibold mb-3">Your Library</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="relative">
<div className="flex items-center">
<div className="w-14 h-14 rounded-full vinyl-record absolute -left-2 z-10" />
<div className="w-20 h-20 rounded-lg bg-music-card ml-6" />
</div>
<p className="text-sm mt-2">Playlist {i}</p>
</div>
))}
</div>
</section>
{currentSong && (
<div className="fixed bottom-16 left-0 right-0 bg-music-card border-t border-music-border px-4 py-2 z-40">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full vinyl-record" />
<div>
<p className="text-sm font-medium">{currentSong.title}</p>
<p className="text-xs text-music-muted">{currentSong.artist}</p>
</div>
</div>
<div className="flex items-center gap-3">
<button className="material-icons text-music-text hover:text-music-accent">skip_previous</button>
<button onClick={togglePlay} className="material-icons text-music-accent">
{isPlaying ? 'pause_circle' : 'play_circle'}
</button>
<button className="material-icons text-music-text hover:text-music-accent">skip_next</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,104 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '@music-app/shared'
import { usePlayerStore } from '../store/playerStore'
export function SharePlayPage() {
const [roomId, setRoomId] = useState<string | null>(null)
const [connections, setConnections] = useState(1)
const [creating, setCreating] = useState(false)
const currentSong = usePlayerStore(s => s.currentSong)
const isPlaying = usePlayerStore(s => s.isPlaying)
const togglePlay = usePlayerStore(s => s.togglePlay)
const next = usePlayerStore(s => s.next)
const previous = usePlayerStore(s => s.previous)
const handleCreate = async () => {
setCreating(true)
const res = await api.createSharePlay()
if (res.success && res.data) {
setRoomId((res.data as any).id)
setConnections(1)
}
setCreating(false)
}
return (
<div className="px-4 py-4 max-w-6xl mx-auto">
<nav className="flex items-center justify-between mb-6">
<Link to="/" className="w-10 h-10 rounded-full bg-music-card flex items-center justify-center hover:bg-music-border transition-colors">
<span className="material-icons text-music-muted">arrow_back</span>
</Link>
<h1 className="text-xl font-display font-semibold">SharePlay</h1>
<div className="w-10" />
</nav>
{!roomId ? (
<div className="text-center py-20">
<span className="material-icons text-6xl text-music-muted mb-4">branding_watermark</span>
<h2 className="text-xl font-display font-semibold mb-2">Start a SharePlay</h2>
<p className="text-music-muted mb-6">Watch and listen together with friends</p>
<button
onClick={handleCreate}
disabled={creating}
className="px-8 py-3 rounded-full bg-music-accent text-music-black font-medium disabled:opacity-50 hover:bg-music-accent/90 transition-colors"
>
{creating ? 'Creating...' : 'Create Room'}
</button>
</div>
) : (
<div className="fixed bottom-16 left-0 right-0 px-4 z-50">
<div className="max-w-2xl mx-auto bg-music-card rounded-t-2xl p-4">
<div className="w-12 h-1 bg-music-border rounded-full mx-auto mb-4" />
<div className="flex items-center justify-between mb-3">
<span className="material-icons text-music-accent">branding_watermark</span>
<div className="flex items-center gap-1">
<span className="material-icons text-sm text-music-muted">person</span>
<span className="text-sm text-music-muted">{connections}</span>
</div>
</div>
<p className="text-xs text-music-muted mb-1">Currently Playing</p>
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full vinyl-record flex-shrink-0" />
<div className="min-w-0">
<p className="font-medium text-sm truncate">{currentSong?.title || 'No song'}</p>
<p className="text-xs text-music-muted truncate">{currentSong?.artist}</p>
</div>
</div>
<div className="flex items-center justify-center gap-4 mb-4">
<button onClick={previous} className="material-icons text-music-muted hover:text-music-text transition-colors">skip_previous</button>
<button className="material-icons text-music-muted hover:text-music-text transition-colors">replay_10</button>
<button onClick={togglePlay} className="material-icons text-2xl text-music-accent">
{isPlaying ? 'pause_circle' : 'play_circle'}
</button>
<button className="material-icons text-music-muted hover:text-music-text transition-colors">forward_10</button>
<button onClick={next} className="material-icons text-music-muted hover:text-music-text transition-colors">skip_next</button>
</div>
<div className="mt-3 pt-3 border-t border-music-border">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-music-muted">Up Next</p>
<p className="text-xs text-music-muted">-</p>
</div>
</div>
<button className="w-full mt-3 flex items-center justify-center gap-2 py-2 rounded-full bg-music-dark text-sm hover:bg-music-border transition-colors">
<span className="material-icons text-sm">add</span>
Add Song to Cue
</button>
<button
onClick={() => setRoomId(null)}
className="w-full mt-2 text-xs text-music-muted hover:text-music-text py-1 transition-colors"
>
Leave Room
</button>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,150 @@
import { create } from 'zustand'
import { Howl } from 'howler'
import { api, API_BASE } from '@music-app/shared'
interface SongState {
id: string
title: string
artist: string
album: string
albumArt: string | null
duration: number
}
interface PlayerState {
currentSong: SongState | null
isPlaying: boolean
progress: number
volume: number
shuffle: boolean
repeat: boolean
playlist: SongState[]
currentIndex: number
howl: Howl | null
setSong: (song: SongState) => void
play: () => void
pause: () => void
togglePlay: () => void
seek: (progress: number) => void
setVolume: (volume: number) => void
toggleShuffle: () => void
toggleRepeat: () => void
next: () => void
previous: () => void
setPlaylist: (songs: SongState[], startIndex?: number) => void
cleanup: () => void
}
export const usePlayerStore = create<PlayerState>((set, get) => ({
currentSong: null,
isPlaying: false,
progress: 0,
volume: 0.8,
shuffle: false,
repeat: false,
playlist: [],
currentIndex: -1,
howl: null,
setSong: (song) => {
const { howl } = get()
howl?.stop()
howl?.unload()
const newHowl = new Howl({
src: [`${API_BASE}${song.id ? `/api/songs/${song.id}/stream` : ''}`],
html5: true,
volume: get().volume,
onload: () => set({ currentSong: song, progress: 0 }),
onend: () => get().next(),
onplay: () => set({ isPlaying: true }),
onpause: () => set({ isPlaying: false }),
})
const interval = setInterval(() => {
if (newHowl.playing()) {
set({ progress: newHowl.seek() as number })
}
}, 500)
newHowl.on('end', () => clearInterval(interval))
set({ howl: newHowl, currentSong: song })
},
play: () => {
get().howl?.play()
},
pause: () => {
get().howl?.pause()
},
togglePlay: () => {
if (get().isPlaying) {
get().pause()
} else {
get().play()
}
},
seek: (progress) => {
get().howl?.seek(progress)
set({ progress })
},
setVolume: (volume) => {
get().howl?.volume(volume)
set({ volume })
},
toggleShuffle: () => set({ shuffle: !get().shuffle }),
toggleRepeat: () => set({ repeat: !get().repeat }),
next: () => {
const { playlist, currentIndex, shuffle } = get()
let nextIndex: number
if (shuffle) {
nextIndex = Math.floor(Math.random() * playlist.length)
} else {
nextIndex = currentIndex + 1
if (nextIndex >= playlist.length) {
nextIndex = get().repeat ? 0 : currentIndex
if (!get().repeat) return
}
}
set({ currentIndex: nextIndex })
get().setSong(playlist[nextIndex])
get().play()
},
previous: () => {
const { playlist, currentIndex } = get()
const prevIndex = currentIndex <= 0 ? playlist.length - 1 : currentIndex - 1
set({ currentIndex: prevIndex })
get().setSong(playlist[prevIndex])
get().play()
},
setPlaylist: (songs, startIndex = 0) => {
set({ playlist: songs, currentIndex: startIndex })
if (songs.length > 0) {
get().setSong(songs[startIndex])
get().play()
}
},
cleanup: () => {
const { howl } = get()
howl?.stop()
howl?.unload()
},
}))
export function PlayerProvider({ children }: { children: React.ReactNode }) {
return <>{children}</>
}

102
web/src/styles/globals.css Normal file
View File

@ -0,0 +1,102 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
height: 100%;
width: 100%;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #121212;
}
::-webkit-scrollbar-thumb {
background: #2a2a2a;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #3a3a3a;
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
}
.vinyl-record {
background: repeating-radial-gradient(
circle at center,
#1a1a2e 0px,
#1a1a2e 2px,
#16162a 3px,
#16162a 4px
);
border-radius: 50%;
}
/* Responsive utilities */
@media (max-width: 640px) {
.max-w-6xl {
max-width: 100%;
padding-left: 1rem;
padding-right: 1rem;
}
}
/* Glass morphism */
.glass {
background: rgba(26, 26, 26, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.05);
}
/* Gradient text */
.gradient-text {
background: linear-gradient(135deg, #f5c542, #e63946);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Smooth scroll */
html {
scroll-behavior: smooth;
}
/* Line clamp */
.line-clamp-1 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.line-clamp-2 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}

9
web/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

70
web/tailwind.config.ts Normal file
View File

@ -0,0 +1,70 @@
import type { Config } from 'tailwindcss'
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
'music-black': '#0a0a0a',
'music-dark': '#121212',
'music-card': '#1a1a1a',
'music-border': '#2a2a2a',
'music-muted': '#888888',
'music-text': '#e0e0e0',
'music-text-dim': '#999999',
'music-accent': '#f5c542',
'music-vinyl': '#1a1a2e',
'mood-sad': '#1a2a4a',
'mood-happy': '#f5c542',
'mood-energetic': '#e63946',
'mood-focused': '#2d6a4f',
'mood-chill': '#48957e',
'mood-romantic': '#bc6a7e',
'mood-angry': '#9d0208',
'mood-nostalgic': '#a67c52',
'mood-melancholy': '#5a189c',
'mood-dreamy': '#9b5de5',
},
fontFamily: {
display: ['Space Grotesk', 'system-ui', 'sans-serif'],
body: ['Inter', 'system-ui', 'sans-serif'],
},
animation: {
'vinyl-spin': 'vinyl-spin 3s linear infinite',
'vinyl-spin-slow': 'vinyl-spin 4s linear infinite',
'pulse-glow': 'pulse-glow 2s ease-in-out infinite',
'slide-up': 'slide-up 0.3s ease-out',
'slide-down': 'slide-down 0.3s ease-out',
'fade-in': 'fade-in 0.2s ease-out',
},
keyframes: {
'vinyl-spin': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
'pulse-glow': {
'0%, 100%': { opacity: '0.6' },
'50%': { opacity: '1' },
},
'slide-up': {
'0%': { transform: 'translateY(20px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
'slide-down': {
'0%': { transform: 'translateY(-20px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
},
backgroundImage: {
'radial-glow': 'radial-gradient(circle, rgba(245,197,66,0.15) 0%, transparent 70%)',
'vinyl-groove': 'repeating-radial-gradient(#1a1a2e 0px, #1a1a2e 2px, #16162a 3px, #16162a 4px)',
},
},
},
plugins: [],
} satisfies Config

25
web/tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules"]
}

10
web/tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

20
web/vite.config.ts Normal file
View File

@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': 'http://localhost:8000',
'/ws': 'ws://localhost:8000',
}
}
})