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:
commit
bc40a19c49
21
LICENSE
Normal file
21
LICENSE
Normal 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
36
README.md
Normal 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
130
e2e/features.spec.ts
Normal 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
56
e2e/home.spec.ts
Normal 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
46
e2e/navigation.spec.ts
Normal 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
95
e2e/pages.spec.ts
Normal 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
48
e2e/search.spec.ts
Normal 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
141
e2e/visual.spec.ts
Normal 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
16344
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal 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
24
playwright.config.ts
Normal 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
15
shared/package.json
Normal 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
193
shared/src/api/client.ts
Normal 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 };
|
||||
65
shared/src/constants/api.ts
Normal file
65
shared/src/constants/api.ts
Normal 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;
|
||||
97
shared/src/constants/moods.ts
Normal file
97
shared/src/constants/moods.ts
Normal 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);
|
||||
}
|
||||
29
shared/src/constants/navigation.ts
Normal file
29
shared/src/constants/navigation.ts
Normal 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
16
shared/src/index.ts
Normal 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';
|
||||
16
shared/src/types/account.ts
Normal file
16
shared/src/types/account.ts
Normal 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;
|
||||
}
|
||||
36
shared/src/types/common.ts
Normal file
36
shared/src/types/common.ts
Normal 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;
|
||||
}
|
||||
10
shared/src/types/events.ts
Normal file
10
shared/src/types/events.ts
Normal 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
9
shared/src/types/lofi.ts
Normal 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
47
shared/src/types/mood.ts
Normal 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;
|
||||
}
|
||||
30
shared/src/types/playlist.ts
Normal file
30
shared/src/types/playlist.ts
Normal 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
29
shared/src/types/radio.ts
Normal 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;
|
||||
}
|
||||
26
shared/src/types/releases.ts
Normal file
26
shared/src/types/releases.ts
Normal 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;
|
||||
}
|
||||
22
shared/src/types/search.ts
Normal file
22
shared/src/types/search.ts
Normal 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;
|
||||
}
|
||||
18
shared/src/types/settings.ts
Normal file
18
shared/src/types/settings.ts
Normal 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;
|
||||
}
|
||||
43
shared/src/types/shareplay.ts
Normal file
43
shared/src/types/shareplay.ts
Normal 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
32
shared/src/types/song.ts
Normal 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
21
shared/tsconfig.json
Normal 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
12
web/Dockerfile
Normal 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
17
web/index.html
Normal 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
41
web/package.json
Normal 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
6
web/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
41
web/src/app/App.tsx
Normal file
41
web/src/app/App.tsx
Normal 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
33
web/src/app/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
web/src/components/common/Icon.tsx
Normal file
16
web/src/components/common/Icon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
web/src/components/common/LoadingSpinner.tsx
Normal file
13
web/src/components/common/LoadingSpinner.tsx
Normal 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 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
47
web/src/components/navigation/BottomNavBar.tsx
Normal file
47
web/src/components/navigation/BottomNavBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
web/src/components/navigation/NavHub.tsx
Normal file
30
web/src/components/navigation/NavHub.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
68
web/src/components/navigation/NowPlayingMiniBar.tsx
Normal file
68
web/src/components/navigation/NowPlayingMiniBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
web/src/components/player/PlaybackControls.tsx
Normal file
63
web/src/components/player/PlaybackControls.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
web/src/components/player/ProgressBar.tsx
Normal file
69
web/src/components/player/ProgressBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
web/src/components/vinyl/VinylRecord.tsx
Normal file
51
web/src/components/vinyl/VinylRecord.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
web/src/components/vinyl/VinylSleeve.tsx
Normal file
47
web/src/components/vinyl/VinylSleeve.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
web/src/components/vinyl/VinylStack.tsx
Normal file
36
web/src/components/vinyl/VinylStack.tsx
Normal 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
10
web/src/main.tsx
Normal 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>,
|
||||
)
|
||||
111
web/src/pages/AccountPage.tsx
Normal file
111
web/src/pages/AccountPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
web/src/pages/CreatePage.tsx
Normal file
41
web/src/pages/CreatePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
web/src/pages/HomePage.tsx
Normal file
90
web/src/pages/HomePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
web/src/pages/LibraryPage.tsx
Normal file
71
web/src/pages/LibraryPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
web/src/pages/LofiChannelPage.tsx
Normal file
86
web/src/pages/LofiChannelPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
129
web/src/pages/MoodRadioPage.tsx
Normal file
129
web/src/pages/MoodRadioPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
web/src/pages/NewReleasesPage.tsx
Normal file
93
web/src/pages/NewReleasesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
web/src/pages/NowPlayingPage.tsx
Normal file
116
web/src/pages/NowPlayingPage.tsx
Normal 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')}`
|
||||
}
|
||||
136
web/src/pages/PlaylistPage.tsx
Normal file
136
web/src/pages/PlaylistPage.tsx
Normal 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
115
web/src/pages/RadioPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
89
web/src/pages/SearchPage.tsx
Normal file
89
web/src/pages/SearchPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
104
web/src/pages/SharePlayPage.tsx
Normal file
104
web/src/pages/SharePlayPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
150
web/src/store/playerStore.tsx
Normal file
150
web/src/store/playerStore.tsx
Normal 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
102
web/src/styles/globals.css
Normal 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
9
web/src/vite-env.d.ts
vendored
Normal 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
70
web/tailwind.config.ts
Normal 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
25
web/tsconfig.json
Normal 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
10
web/tsconfig.node.json
Normal 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
20
web/vite.config.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user