refactor: split into music-app (server), music-web, music-mobile
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / docker-build (push) Waiting to run
CI / security (push) Waiting to run
CI / build-result (push) Blocked by required conditions

Carve web client, mobile app, and e2e specs out into dedicated
repositories. This repo keeps the FastAPI backend, shared types,
and docs. Compose trimmed to the backend service.
This commit is contained in:
Jarian Cottingham 2026-08-21 18:29:37 +00:00
parent 64e4ca567d
commit 6b41acbf68
69 changed files with 15 additions and 20274 deletions

View File

@ -1,6 +1,13 @@
# Music App
# Music App — Server
Self-hosted music streaming application with mood radio, lofi channels, SharePlay, and internet radio.
FastAPI backend for a self-hosted music streaming application: mood radio, lofi channels, SharePlay, and internet radio. Handles ffmpeg transcoding (OGG conversion), metadata extraction, Genius lyrics, and a SQLite catalog.
This is the **server** of the Music App project. The front ends live in companion repositories:
| Repo | What it is |
|------|------------|
| [music-web](https://git.jarianc.com/jarianc/music-web) | React web client (Vite + Tailwind) |
| [music-mobile](https://git.jarianc.com/jarianc/music-mobile) | React Native mobile app (Expo) |
## Quick Start
@ -8,6 +15,8 @@ Self-hosted music streaming application with mood radio, lofi channels, SharePla
docker-compose up --build
```
API on `http://localhost:8000`, OpenAPI docs at `/docs`.
## System Dependencies
- **ffmpeg** — required for audio transcoding (OGG conversion) and metadata extraction
@ -31,52 +40,12 @@ Key settings:
## Project Structure
- `backend/` — FastAPI backend (Python)
- `web/` — React web frontend (Vite + Tailwind)
- `mobile/` — React Native mobile app (Expo)
- `shared/` — Shared TypeScript types and API client
- `e2e/` — Playwright end-to-end specs
- `backend/` — FastAPI backend (Python): API routes, transcoding, catalog, SharePlay
- `shared/` — Canonical shared TypeScript types and API client (copied into music-web and music-mobile)
- `docs/` — Implementation plan
## Tests
**Backend** (pytest, 90%+ coverage gate):
```bash
cd backend
pip install -r requirements.txt pytest-cov
pytest
pytest # backend test suite (90% coverage gate)
```
300 tests covering endpoints, routers, services, schemas, and models.
**E2E** (Playwright):
```bash
npm install
npx playwright install
npm run dev:backend & # start backend on :8000
npx playwright test
```
**TypeScript** (web + mobile + shared workspaces):
```bash
npm install
npm run typecheck
npm run lint
```
## Development
npm workspaces scripts at the repo root:
```bash
npm run dev # web + backend together
npm run dev:web # Vite dev server only
npm run build:web # production web build
```
## License
MIT — see [LICENSE](LICENSE).

View File

@ -1,5 +1,3 @@
version: '3.8'
services:
backend:
build:
@ -19,19 +17,6 @@ services:
- CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://0.0.0.0:5173,http://0.0.0.0:3000,*
command: uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
web:
build:
context: ./web
dockerfile: Dockerfile
ports:
- "5173:5173"
volumes:
- ./web:/app
- /app/node_modules
environment:
- VITE_API_URL=http://localhost:8000
command: npm run dev -- --host
volumes:
music_data:
upload_data:

View File

@ -1,130 +0,0 @@
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();
});
});

View File

@ -1,56 +0,0 @@
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/);
});
});

View File

@ -1,46 +0,0 @@
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();
});
});

View File

@ -1,95 +0,0 @@
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();
});
});

View File

@ -1,48 +0,0 @@
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/);
});
});

View File

@ -1,141 +0,0 @@
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);
});
});

View File

@ -1,27 +0,0 @@
{
"expo": {
"name": "Music App",
"slug": "music-app",
"version": "1.0.0",
"orientation": "portrait",
"scheme": "musicapp",
"userInterfaceStyle": "automatic",
"splash": {
"resizeMode": "contain",
"backgroundColor": "#0a0a0a"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.musicapp.mobile"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#0a0a0a"
},
"package": "com.musicapp.mobile"
},
"web": {
"bundler": "metro"
}
}
}

View File

@ -1,24 +0,0 @@
import { Stack } from 'expo-router'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import '../src/styles/global.css'
export default function RootLayout() {
return (
<SafeAreaProvider>
<Stack screenOptions={{ headerShown: false, contentBackgroundColor: '#0a0a0a' }}>
<Stack.Screen name="index" />
<Stack.Screen name="library" />
<Stack.Screen name="create" />
<Stack.Screen name="radio" />
<Stack.Screen name="search" />
<Stack.Screen name="account" />
<Stack.Screen name="now-playing" />
<Stack.Screen name="shareplay" />
<Stack.Screen name="mood" />
<Stack.Screen name="playlist" />
<Stack.Screen name="releases" />
<Stack.Screen name="lofi" />
</Stack>
</SafeAreaProvider>
)
}

View File

@ -1,35 +0,0 @@
import { View, Text, ScrollView, TouchableOpacity } from 'react-native'
import { useRouter } from 'expo-router'
const MENU_ITEMS = [
{ icon: '🔌', label: 'Plugins' },
{ icon: '🖥️', label: 'Servers' },
{ icon: '👤', label: 'About You' },
{ icon: '📻', label: 'Internet Radio' },
{ icon: '🔄', label: 'Updates' },
{ icon: '⚙️', label: 'Settings & Privacy' },
]
export default function AccountScreen() {
const router = useRouter()
return (
<View className="flex-1 bg-music-black">
<ScrollView className="flex-1 px-4 pt-4">
<View className="flex-row items-center gap-4 mb-8">
<View className="w-16 h-16 rounded-full bg-music-card items-center justify-center">
<Text className="text-3xl">👤</Text>
</View>
<Text className="text-2xl font-semibold text-music-text">Welcome, User</Text>
</View>
{MENU_ITEMS.map((item) => (
<TouchableOpacity key={item.label} className="flex-row items-center gap-4 py-4">
<Text className="text-xl">{item.icon}</Text>
<Text className="text-music-text font-medium">{item.label}</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
)
}

View File

@ -1,47 +0,0 @@
import { View, Text, TouchableOpacity, ScrollView } from 'react-native'
import { useRouter } from 'expo-router'
import { BottomNavBar } from '../src/components/BottomNavBar'
import { useState } from 'react'
const TABS = [
{ id: 'playlist', label: 'Playlist' },
{ id: 'mood-playlist', label: 'Mood Playlist' },
{ id: 'radio', label: 'Radio' },
{ id: 'collab', label: 'Collab' },
]
export default function CreateScreen() {
const [activeTab, setActiveTab] = useState(TABS[0].id)
return (
<View className="flex-1 bg-music-black">
<ScrollView className="flex-1 px-4 pt-4">
<View className="flex-row items-center justify-between mb-6">
<View className="w-10" />
<Text className="text-xl font-semibold text-music-text">Create</Text>
<View className="w-10" />
</View>
<View className="flex-row gap-2 mb-6">
{TABS.map((tab) => (
<TouchableOpacity
key={tab.id}
onPress={() => setActiveTab(tab.id)}
className={`px-4 py-2 rounded-full ${activeTab === tab.id ? 'bg-music-accent' : 'bg-music-card'}`}
>
<Text className={`text-sm ${activeTab === tab.id ? 'text-music-black font-medium' : 'text-music-muted'}`}>
{tab.label}
</Text>
</TouchableOpacity>
))}
</View>
<View className="p-8 rounded-2xl bg-music-card items-center">
<Text className="text-4xl mb-3"></Text>
<Text className="text-lg font-semibold text-music-text">{TABS.find(t => t.id === activeTab)?.label}</Text>
</View>
</ScrollView>
<BottomNavBar />
</View>
)
}

View File

@ -1,71 +0,0 @@
import { View, Text, ScrollView, TouchableOpacity } from 'react-native'
import { useRouter } from 'expo-router'
import { BottomNavBar } from '../src/components/BottomNavBar'
export default function HomeScreen() {
const router = useRouter()
return (
<View className="flex-1 bg-music-black">
<ScrollView className="flex-1 px-4 pt-4">
<View className="flex-row items-center justify-between mb-6">
<TouchableOpacity onPress={() => router.push('/account')} className="w-10 h-10 rounded-full bg-music-card items-center justify-center">
<Text className="text-music-muted text-xl">👤</Text>
</TouchableOpacity>
<View className="flex-row gap-2">
{['Music', 'New Music', 'Mood', 'LoFi'].map((tab) => (
<TouchableOpacity
key={tab}
onPress={() => router.push({
pathname: tab === 'Music' ? '/library' : tab === 'New Music' ? '/releases' : tab === 'Mood' ? '/mood' : '/lofi'
} as any)}
className="px-4 py-2 rounded-full bg-music-card"
>
<Text className="text-sm text-music-text">{tab}</Text>
</TouchableOpacity>
))}
</View>
</View>
<View className="mb-6 p-4 rounded-2xl bg-music-card">
<Text className="text-xs text-music-muted uppercase tracking-wider mb-3">Now Playing</Text>
<View className="flex-row items-center gap-4">
<View className="w-16 h-16 rounded-lg bg-music-vinyl items-center justify-center">
<Text>🎵</Text>
</View>
<View>
<Text className="font-semibold text-music-text">No song playing</Text>
<Text className="text-sm text-music-muted">Select a track</Text>
</View>
</View>
</View>
<Text className="text-lg font-semibold text-music-text mb-3">Quick Access</Text>
<View className="flex-row gap-3 mb-6">
<TouchableOpacity onPress={() => router.push('/library' as any)} className="flex-1 p-4 rounded-xl bg-music-card">
<Text className="text-music-accent mb-2">📚</Text>
<Text className="font-medium text-music-text">My Music</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => router.push('/releases' as any)} className="flex-1 p-4 rounded-xl bg-music-card">
<Text className="text-mood-happy mb-2"></Text>
<Text className="font-medium text-music-text">New Releases</Text>
</TouchableOpacity>
</View>
<Text className="text-lg font-semibold text-music-text mb-3">Mood Radio</Text>
<View className="flex-wrap flex-row gap-3">
{['Sad', 'Happy', 'Energetic', 'Focused', 'Chill', 'Romantic', 'Angry', 'Nostalgic', 'Melancholy', 'Dreamy'].map((mood) => (
<TouchableOpacity
key={mood}
onPress={() => router.push('/mood' as any)}
className="px-4 py-3 rounded-xl bg-music-card"
>
<Text className="text-sm text-music-text">{mood}</Text>
</TouchableOpacity>
))}
</View>
</ScrollView>
<BottomNavBar />
</View>
)
}

View File

@ -1,68 +0,0 @@
import { View, Text, ScrollView, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native'
import { useRouter } from 'expo-router'
import { BottomNavBar } from '../src/components/BottomNavBar'
import { useState, useEffect } from 'react'
export default function LibraryScreen() {
const router = useRouter()
const [search, setSearch] = useState('')
const [playlists, setPlaylists] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchPlaylists()
}, [])
const fetchPlaylists = async () => {
try {
const res = await fetch('/api/playlists')
const data = await res.json()
setPlaylists(data.items || data || [])
} catch {
setPlaylists([])
} finally {
setLoading(false)
}
}
const filtered = playlists.filter(p => p.name?.toLowerCase().includes(search.toLowerCase()))
return (
<View className="flex-1 bg-music-black">
<ScrollView className="flex-1 px-4 pt-4">
<View className="flex-row items-center justify-between mb-6">
<TouchableOpacity onPress={() => router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center">
<Text className="text-music-muted">👤</Text>
</TouchableOpacity>
<Text className="text-xl font-semibold text-music-text">Library</Text>
<View className="flex-row items-center bg-music-card rounded-full px-4 py-2">
<Text className="text-music-muted">🔍</Text>
<TextInput
placeholder="Search playlists..."
placeholderTextColor="#888"
value={search}
onChangeText={setSearch}
className="text-sm text-music-text ml-2 w-48"
/>
</View>
</View>
{loading ? (
<View className="items-center py-8">
<ActivityIndicator size="large" color="#e94560" />
</View>
) : (
<View className="flex-wrap flex-row gap-6 justify-center">
{filtered.map((playlist: any) => (
<TouchableOpacity key={playlist.id} onPress={() => router.push(`/playlist?id=${playlist.id}` as any)} className="items-center">
<View className="w-24 h-24 rounded-full bg-music-vinyl mb-2" />
<Text className="text-sm text-music-text">{playlist.name}</Text>
</TouchableOpacity>
))}
</View>
)}
</ScrollView>
<BottomNavBar />
</View>
)
}

View File

@ -1,68 +0,0 @@
import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator } from 'react-native'
import { useRouter } from 'expo-router'
import { BottomNavBar } from '../src/components/BottomNavBar'
import { usePlayerStore } from '../src/store/playerStore'
import { useState, useEffect } from 'react'
export default function LofiScreen() {
const router = useRouter()
const [channels, setChannels] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchChannels()
}, [])
const fetchChannels = async () => {
try {
const res = await fetch('/api/lofi/channels')
const data = await res.json()
setChannels(data || [])
} catch {
setChannels([])
} finally {
setLoading(false)
}
}
return (
<View className="flex-1 bg-music-black">
<ScrollView className="flex-1 px-4 pt-4">
<View className="flex-row items-center justify-between mb-6">
<TouchableOpacity onPress={() => router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center">
<Text className="text-music-muted">👤</Text>
</TouchableOpacity>
<Text className="text-xl font-semibold text-music-text">LoFi Channels</Text>
<View className="w-10" />
</View>
{loading ? (
<View className="items-center py-8">
<ActivityIndicator size="large" color="#e94560" />
</View>
) : (
channels.map((ch: any) => (
<TouchableOpacity key={ch.id} className="mb-4 rounded-2xl bg-music-card overflow-hidden">
<View className="h-40 justify-center items-center bg-music-vinyl relative">
<Text className="text-5xl">🎵</Text>
<View className="p-4 bg-black/80 absolute bottom-0 left-0 right-0 rounded-b-2xl">
<Text className="text-xs text-music-accent">LoFi</Text>
<Text className="text-sm font-medium text-music-text">{ch.name}</Text>
<Text className="text-xs text-music-muted mt-1">{ch.description}</Text>
</View>
</View>
</TouchableOpacity>
))
)}
{channels.length === 0 && !loading && (
<View className="items-center py-12">
<Text className="text-4xl mb-2">🌙</Text>
<Text className="text-music-muted">No channels available</Text>
</View>
)}
</ScrollView>
<BottomNavBar />
</View>
)
}

View File

@ -1,51 +0,0 @@
import { View, Text, TouchableOpacity, ScrollView } from 'react-native'
import { useRouter } from 'expo-router'
import { useState } from 'react'
import { usePlayerStore } from '../src/store/playerStore'
const MOODS = [
{ id: 'sad', name: 'Sad', color: '#1a2a4a' },
{ id: 'happy', name: 'Happy', color: '#f5c542' },
{ id: 'energetic', name: 'Energetic', color: '#e63946' },
{ id: 'focused', name: 'Focused', color: '#2d6a4f' },
{ id: 'chill', name: 'Chill', color: '#48957e' },
{ id: 'romantic', name: 'Romantic', color: '#bc6a7e' },
{ id: 'angry', name: 'Angry', color: '#9d0208' },
{ id: 'nostalgic', name: 'Nostalgic', color: '#a67c52' },
{ id: 'melancholy', name: 'Melancholy', color: '#5a189c' },
{ id: 'dreamy', name: 'Dreamy', color: '#9b5de5' },
]
export default function MoodScreen() {
const [activeMood, setActiveMood] = useState(MOODS[0])
const isPlaying = usePlayerStore(s => s.isPlaying)
const togglePlay = usePlayerStore(s => s.togglePlay)
return (
<View className="flex-1 justify-between py-8 px-6" style={{ backgroundColor: activeMood.color + '20' }}>
<View className="flex-row items-center justify-between">
<View className="w-10" />
<Text className="text-xl font-semibold text-music-text">Mood Radio</Text>
<View className="w-10" />
</View>
<View className="items-center">
<View className="w-48 h-48 rounded-full items-center justify-center" style={{ backgroundColor: activeMood.color + '40' }}>
<Text className="text-6xl">🎵</Text>
</View>
<Text className="text-2xl font-semibold text-music-text mt-6">{activeMood.name}</Text>
</View>
<View className="items-center gap-3">
<Text className="text-xs text-music-muted uppercase tracking-wider">Currently Playing</Text>
<Text className="text-music-muted text-xl"></Text>
<TouchableOpacity
onPress={() => setActiveMood(MOODS[Math.floor(Math.random() * MOODS.length)])}
className="px-8 py-3 rounded-full bg-music-card"
>
<Text className="text-sm font-medium text-music-text">Set the Mood</Text>
</TouchableOpacity>
</View>
</View>
)
}

View File

@ -1,86 +0,0 @@
import { View, Text, TouchableOpacity, PanResponder } from 'react-native'
import { useRouter } from 'expo-router'
import { useState, useCallback } from 'react'
import { usePlayerStore } from '../src/store/playerStore'
export default function NowPlayingScreen() {
const router = useRouter()
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 toggleShuffle = usePlayerStore(s => s.toggleShuffle)
const next = usePlayerStore(s => s.next)
const previous = usePlayerStore(s => s.previous)
const seek = usePlayerStore(s => s.seek)
const shuffle = usePlayerStore(s => s.shuffle)
const progressPercent = currentSong ? (progress / currentSong.duration) * 100 : 0
const panResponder = PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderMove: (evt) => {
if (!currentSong) return
const x = evt.nativeEvent.locationX
const ratio = Math.max(0, Math.min(x / 375, 1))
seek(currentSong.duration * ratio)
},
})
return (
<View className="flex-1 bg-music-black justify-between py-8 px-6" {...panResponder.panHandlers}>
<TouchableOpacity onPress={() => router.back()} className="self-start">
<Text className="text-music-muted text-2xl"></Text>
</TouchableOpacity>
<View className="w-64 h-64 rounded-2xl bg-music-vinyl items-center justify-center self-center">
<Text className="text-6xl">🎵</Text>
</View>
<View className="items-center">
<Text className="text-2xl font-semibold text-music-text">{currentSong?.title || 'No song'}</Text>
<Text className="text-music-muted mt-1">{currentSong?.artist}</Text>
</View>
<View className="w-full">
<View className="h-1 bg-music-border rounded-full">
<View className="h-full bg-music-accent rounded-full" style={{ width: `${progressPercent}%` }} />
</View>
<View className="flex-row justify-between mt-2">
<Text className="text-xs text-music-muted">{formatTime(progress)}</Text>
<Text className="text-xs text-music-muted">{currentSong ? formatTime(currentSong.duration) : '0:00'}</Text>
</View>
</View>
<View className="flex-row items-center justify-center gap-6">
<TouchableOpacity onPress={toggleShuffle}>
<Text className={`${shuffle ? 'text-music-accent' : 'text-music-muted'} text-2xl`}>🔀</Text>
</TouchableOpacity>
<TouchableOpacity onPress={previous}>
<Text className="text-3xl"></Text>
</TouchableOpacity>
<TouchableOpacity onPress={togglePlay}>
<Text className="text-5xl text-music-accent">{isPlaying ? '⏸' : '▶️'}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={next}>
<Text className="text-3xl"></Text>
</TouchableOpacity>
<Text className="text-music-muted text-2xl"></Text>
</View>
<View className="flex-row items-center justify-center gap-2">
<TouchableOpacity onPress={() => router.push('/shareplay' as any)}>
<Text className="text-music-muted">📢</Text>
</TouchableOpacity>
<Text className="text-xs text-music-muted">Speaker</Text>
</View>
</View>
)
}
function formatTime(sec: number): string {
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}

View File

@ -1,85 +0,0 @@
import { View, Text, ScrollView, TouchableOpacity, FlatList, ActivityIndicator } from 'react-native'
import { useRouter } from 'expo-router'
import { usePlayerStore } from '../src/store/playerStore'
import { BottomNavBar } from '../src/components/BottomNavBar'
import { useState, useEffect } from 'react'
export default function PlaylistScreen() {
const router = useRouter()
const isPlaying = usePlayerStore(s => s.isPlaying)
const togglePlay = usePlayerStore(s => s.togglePlay)
const [songs, setSongs] = useState([])
const [playlists, setPlaylists] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchData()
}, [])
const fetchData = async () => {
try {
const [plRes, songRes] = await Promise.all([
fetch('/api/playlists'),
fetch('/api/songs?page=1&per_page=50'),
])
const plData = await plRes.json()
const songData = await songRes.json()
setPlaylists(plData.items || plData || [])
setSongs(songData.items || [])
} catch {
setPlaylists([])
setSongs([])
} finally {
setLoading(false)
}
}
return (
<View className="flex-1 bg-music-black flex-row">
<View className="w-20 bg-music-dark border-r border-music-border items-center py-4 gap-3">
<TouchableOpacity onPress={() => router.push('/library' as any)}>
<Text className="text-music-muted text-xl"></Text>
</TouchableOpacity>
{playlists.map((pl: any, i: number) => (
<TouchableOpacity key={pl.id || i} className={`w-14 h-14 rounded-lg bg-music-card ${i === 0 ? 'border-2 border-music-accent' : ''}`} />
))}
<View className="flex-1 w-1 bg-music-border rounded-full relative">
<View className="absolute bottom-0 w-full bg-music-accent rounded-full" style={{ height: '35%' }} />
</View>
<Text className="text-music-muted text-sm"></Text>
<TouchableOpacity onPress={togglePlay}>
<Text className="text-music-accent text-xl">{isPlaying ? '⏸' : '▶️'}</Text>
</TouchableOpacity>
</View>
<ScrollView className="flex-1 px-6 py-4">
<View className="flex-row items-center justify-between mb-4">
<Text className="text-2xl font-semibold text-music-text">{playlists[0]?.name || 'Playlist'}</Text>
<Text className="text-music-muted">📢</Text>
</View>
<View className="w-48 h-48 rounded-xl bg-music-card mb-6" />
{loading ? (
<ActivityIndicator size="large" color="#e94560" />
) : (
songs.map((song: any) => (
<TouchableOpacity
key={song.id}
className="flex-row items-center gap-3 py-2"
onPress={() => usePlayerStore.getState().setSong(song)}
>
<View className="w-8 h-8 rounded-full bg-music-vinyl items-center justify-center">
<Text>🎵</Text>
</View>
<Text className="flex-1 text-sm text-music-text">{song.title}</Text>
<Text className="text-xs text-music-muted">{song.artist}</Text>
<Text className="text-xs text-music-muted">
{song.duration_sec ? `${Math.floor(song.duration_sec / 60)}:${(song.duration_sec % 60).toString().padStart(2, '0')}` : '--:--'}
</Text>
</TouchableOpacity>
))
)}
</ScrollView>
</View>
)
}

View File

@ -1,92 +0,0 @@
import { View, Text, ScrollView, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native'
import { useRouter } from 'expo-router'
import { BottomNavBar } from '../src/components/BottomNavBar'
import { useState, useEffect } from 'react'
export default function RadioScreen() {
const router = useRouter()
const [stations, setStations] = useState([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
useEffect(() => {
fetchStations()
}, [])
const fetchStations = async () => {
try {
const res = await fetch('/api/radio/stations?limit=20')
const data = await res.json()
setStations(data.items || data || [])
} catch {
setStations([])
} finally {
setLoading(false)
}
}
const filtered = stations.filter(s => s.name?.toLowerCase().includes(search.toLowerCase()))
return (
<View className="flex-1 bg-music-black">
<ScrollView className="flex-1 px-4 pt-4">
<View className="flex-row items-center justify-between mb-6">
<TouchableOpacity onPress={() => router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center">
<Text className="text-music-muted">👤</Text>
</TouchableOpacity>
<Text className="text-xl font-semibold text-music-text">Internet Radio</Text>
<View className="w-10" />
</View>
<View className="flex-row items-center bg-music-card rounded-full px-4 py-3 mb-6">
<Text className="text-music-muted">🔍</Text>
<TextInput
placeholder="Search stations..."
placeholderTextColor="#888"
value={search}
onChangeText={setSearch}
className="text-sm text-music-text ml-2 flex-1"
/>
</View>
{loading ? (
<View className="items-center py-8">
<ActivityIndicator size="large" color="#e94560" />
<Text className="text-music-muted mt-2">Loading stations...</Text>
</View>
) : (
<>
<View className="mb-6 p-4 rounded-2xl bg-music-card">
<Text className="text-sm font-semibold text-music-text mb-3">Browse Stations</Text>
<View className="flex-wrap flex-row gap-4">
{filtered.map((station: any) => (
<TouchableOpacity
key={station.id}
className="w-32 p-3 rounded-xl bg-music-dark items-center"
>
<View className="w-16 h-16 rounded-full bg-music-vinyl mb-2 items-center justify-center">
<Text>📻</Text>
</View>
<Text className="text-xs text-music-text text-center" numberOfLines={2}>
{station.name || 'Station'}
</Text>
<Text className="text-xs text-music-muted mt-1">
{station.country_code || 'Local'}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{filtered.length === 0 && (
<View className="items-center py-8">
<Text className="text-3xl mb-2">📻</Text>
<Text className="text-music-muted">No stations found</Text>
</View>
)}
</>
)}
</ScrollView>
<BottomNavBar />
</View>
)
}

View File

@ -1,69 +0,0 @@
import { View, Text, ScrollView, TouchableOpacity, FlatList, ActivityIndicator } from 'react-native'
import { useRouter } from 'expo-router'
import { BottomNavBar } from '../src/components/BottomNavBar'
import { usePlayerStore } from '../src/store/playerStore'
import { useState, useEffect } from 'react'
export default function ReleasesScreen() {
const router = useRouter()
const [releases, setReleases] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchReleases()
}, [])
const fetchReleases = async () => {
try {
const res = await fetch('/api/releases?limit=20')
const data = await res.json()
setReleases(data.items || [])
} catch {
setReleases([])
} finally {
setLoading(false)
}
}
return (
<View className="flex-1 bg-music-black">
<ScrollView className="flex-1 px-4 pt-4">
<View className="flex-row items-center justify-between mb-6">
<View className="w-10" />
<Text className="text-xl font-semibold text-music-text">New Releases</Text>
<View className="w-10" />
</View>
{loading ? (
<View className="items-center py-12">
<ActivityIndicator size="large" color="#e94560" />
<Text className="text-music-muted mt-2">Loading releases...</Text>
</View>
) : releases.length > 0 ? (
releases.map((release: any, i: number) => (
<View key={release.id || i} className="flex-row gap-4 p-4 rounded-2xl bg-music-card mb-4">
<View className="items-center">
<View className="w-16 h-16 rounded-lg bg-music-dark mb-2" />
<Text className="text-xs text-music-muted">{release.artist || 'Artist'}</Text>
<TouchableOpacity className="flex-row items-center px-3 py-1 rounded-full bg-music-dark mt-2">
<Text className="text-xs text-music-text"> Add</Text>
</TouchableOpacity>
</View>
<View className="flex-wrap flex-row gap-2 flex-1">
{[...Array(Math.min(6, release.tracks || 3))].map((_, j) => (
<TouchableOpacity key={j} className="w-[15%] aspect-square rounded-lg bg-music-dark" />
))}
</View>
</View>
))
) : (
<View className="items-center py-12">
<Text className="text-4xl mb-2">🎵</Text>
<Text className="text-music-muted">No new releases found</Text>
</View>
)}
</ScrollView>
<BottomNavBar />
</View>
)
}

View File

@ -1,124 +0,0 @@
import { View, Text, ScrollView, TouchableOpacity, FlatList, ActivityIndicator } from 'react-native'
import { useRouter } from 'expo-router'
import { BottomNavBar } from '../src/components/BottomNavBar'
import { usePlayerStore } from '../src/store/playerStore'
import { useState, useEffect } from 'react'
export default function SearchScreen() {
const router = useRouter()
const currentSong = usePlayerStore(s => s.currentSong)
const isPlaying = usePlayerStore(s => s.isPlaying)
const togglePlay = usePlayerStore(s => s.togglePlay)
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const features = [
{ id: 'music', name: 'Music', icon: '🎵', route: '/library' },
{ id: 'new', name: 'New Music', icon: '✨', route: '/releases' },
{ id: 'events', name: 'Live Events', icon: '🎪', route: '/' },
{ id: 'radio', name: 'Internet Radio', icon: '📻', route: '/radio' },
{ id: 'mood', name: 'Mood Radio', icon: '😊', route: '/mood' },
{ id: 'lofi', name: 'LoFi', icon: '🌙', route: '/lofi' },
{ id: 'shareplay', name: 'SharePlay', icon: '📢', route: '/shareplay' },
]
const handleSearch = async (text: string) => {
setQuery(text)
if (!text) {
setResults([])
return
}
setLoading(true)
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(text)}`)
const data = await res.json()
setResults(data.items || [])
} catch {
setResults([])
} finally {
setLoading(false)
}
}
return (
<View className="flex-1 bg-music-black">
<ScrollView className="flex-1 px-4 pt-4">
<View className="flex-row items-center justify-between mb-6">
<TouchableOpacity onPress={() => router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center">
<Text className="text-music-muted">👤</Text>
</TouchableOpacity>
<Text className="text-xl font-semibold text-music-text">Search</Text>
<View className="w-10" />
</View>
<View className="flex-row items-center bg-music-card rounded-full px-4 py-3 mb-6">
<Text className="text-music-muted">🔍</Text>
<Text
onPress={() => handleSearch(query)}
className="text-sm text-music-text ml-2 flex-1"
numberOfLines={1}
>
{query || 'What music is calling to you?'}
</Text>
</View>
{loading && (
<View className="items-center py-8">
<ActivityIndicator size="large" color="#e94560" />
</View>
)}
{!loading && results.length > 0 && (
<View className="mb-6">
<Text className="text-lg font-semibold text-music-text mb-3">Search Results</Text>
{results.map((item: any, i: number) => (
<TouchableOpacity
key={i}
className="flex-row items-center gap-3 py-3 bg-music-card rounded-xl mb-2 px-3"
>
<View className="w-10 h-10 rounded-full bg-music-vinyl items-center justify-center">
<Text>🎵</Text>
</View>
<View className="flex-1">
<Text className="text-sm font-medium text-music-text">{item.title || item.name}</Text>
<Text className="text-xs text-music-muted">{item.artist || 'Unknown'}</Text>
</View>
</TouchableOpacity>
))}
</View>
)}
{(!query || results.length === 0) && (
<>
<Text className="text-lg font-semibold text-music-text mb-3">Browse</Text>
<View className="flex-wrap flex-row gap-3 mb-6">
{features.map((f) => (
<TouchableOpacity key={f.id} onPress={() => router.push(f.route as any)} className="flex-1 min-w-[30%] p-4 rounded-xl bg-music-card items-center">
<Text className="text-2xl mb-1">{f.icon}</Text>
<Text className="text-xs text-music-text">{f.name}</Text>
</TouchableOpacity>
))}
</View>
</>
)}
</ScrollView>
{currentSong && (
<View className="bg-music-card border-t border-music-border px-4 py-2 flex-row items-center justify-between">
<View className="flex-row items-center gap-3">
<View className="w-10 h-10 rounded-full bg-music-vinyl" />
<View>
<Text className="text-sm font-medium text-music-text">{currentSong.title}</Text>
<Text className="text-xs text-music-muted">{currentSong.artist}</Text>
</View>
</View>
<TouchableOpacity onPress={togglePlay}>
<Text className="text-music-accent text-2xl">{isPlaying ? '⏸' : '▶️'}</Text>
</TouchableOpacity>
</View>
)}
<BottomNavBar />
</View>
)
}

View File

@ -1,60 +0,0 @@
import { View, Text, TouchableOpacity, ScrollView } from 'react-native'
import { useRouter } from 'expo-router'
import { usePlayerStore } from '../src/store/playerStore'
import { BottomNavBar } from '../src/components/BottomNavBar'
export default function SharePlayScreen() {
const router = useRouter()
const currentSong = usePlayerStore(s => s.currentSong)
const isPlaying = usePlayerStore(s => s.isPlaying)
const togglePlay = usePlayerStore(s => s.togglePlay)
return (
<View className="flex-1 bg-music-black">
<ScrollView className="flex-1 px-4 pt-4">
<View className="flex-row items-center justify-between mb-6">
<TouchableOpacity onPress={() => router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center">
<Text className="text-music-muted">👤</Text>
</TouchableOpacity>
<Text className="text-xl font-semibold text-music-text">SharePlay</Text>
<View className="w-10" />
</View>
</ScrollView>
<View className="bg-music-card rounded-t-2xl p-4 mx-4">
<View className="w-12 h-1 bg-music-border rounded-full mx-auto mb-4" />
<View className="flex-row items-center justify-between mb-3">
<Text className="text-music-accent">📢</Text>
<View className="flex-row items-center gap-1">
<Text className="text-music-muted">👤</Text>
<Text className="text-sm text-music-muted">1</Text>
</View>
</View>
<Text className="text-xs text-music-muted mb-1">Currently Playing</Text>
<View className="flex-row items-center gap-3 mb-3">
<View className="w-12 h-12 rounded-full bg-music-vinyl" />
<View>
<Text className="font-medium text-sm text-music-text">{currentSong?.title || 'No song'}</Text>
<Text className="text-xs text-music-muted">{currentSong?.artist}</Text>
</View>
</View>
<View className="flex-row items-center justify-center gap-4">
<Text className="text-music-muted"></Text>
<Text className="text-music-muted"></Text>
<TouchableOpacity onPress={togglePlay}>
<Text className="text-music-accent text-2xl">{isPlaying ? '⏸' : '▶️'}</Text>
</TouchableOpacity>
<Text className="text-music-muted"></Text>
<Text className="text-music-muted"></Text>
</View>
<View className="mt-3 pt-3 border-t border-music-border">
<Text className="text-xs text-music-muted">Up Next</Text>
</View>
<TouchableOpacity className="mt-3 py-2 rounded-full bg-music-dark items-center">
<Text className="text-music-text text-sm flex-row"> Add Song to Cue</Text>
</TouchableOpacity>
</View>
<BottomNavBar />
</View>
)
}

View File

@ -1,7 +0,0 @@
module.exports = function(api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['nativewind/babel', 'react-native-reanimated/plugin'],
};
};

View File

@ -1,17 +0,0 @@
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../');
const config = getDefaultConfig(projectRoot);
config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
];
config.resolver.disableHierarchicalLookup = true;
module.exports = config;

View File

@ -1,39 +0,0 @@
{
"name": "@music-app/mobile",
"private": true,
"version": "0.1.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"build": "expo build",
"typecheck": "tsc --noEmit",
"lint": "eslint src/"
},
"dependencies": {
"expo": "~50.0.0",
"expo-router": "~3.4.0",
"expo-status-bar": "~1.11.1",
"react": "18.2.0",
"react-native": "0.73.4",
"react-native-web": "^0.19.6",
"react-native-safe-area-context": "4.8.2",
"react-native-screens": "~3.29.0",
"react-native-track-player": "^4.1.1",
"zustand": "^4.4.7",
"nativewind": "^2.0.11",
"tailwindcss": "^3.3.2",
"@music-app/shared": "0.1.0",
"expo-av": "~13.10.0",
"expo-file-system": "~16.0.0",
"expo-media-library": "~15.8.0",
"react-native-gesture-handler": "~2.14.0",
"react-native-reanimated": "~3.6.1"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@babel/core": "^7.20.0",
"typescript": "^5.3.3"
}
}

View File

@ -1,36 +0,0 @@
import React from 'react'
import { View, Text, TouchableOpacity } from 'react-native'
import { useRouter, usePathname } from 'expo-router'
const NAV_ITEMS = [
{ id: 'index', label: 'Home', icon: '🏠', route: '/' },
{ id: 'playlist', label: 'Playlist', icon: '📀', route: '/playlist' },
{ id: 'search', label: 'Search', icon: '🔍', route: '/search' },
{ id: 'radio', label: 'Radio', icon: '📻', route: '/radio' },
{ id: 'create', label: 'Create', icon: '', route: '/create' },
]
export function BottomNavBar() {
const router = useRouter()
const pathname = usePathname()
return (
<View className="flex-row bg-music-dark border-t border-music-border pb-2">
{NAV_ITEMS.map((item) => {
const isActive = pathname === item.route || (item.route !== '/' && pathname.startsWith(item.route))
return (
<TouchableOpacity
key={item.id}
onPress={() => router.push(item.route as any)}
className="flex-1 items-center py-2"
>
<Text className="text-xl">{item.icon}</Text>
<Text className={`text-xs mt-0.5 ${isActive ? 'text-music-accent' : 'text-music-muted'}`}>
{item.label}
</Text>
</TouchableOpacity>
)
})}
</View>
)
}

View File

@ -1,78 +0,0 @@
import { create } from 'zustand'
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
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
}
export const usePlayerStore = create<PlayerState>((set, get) => ({
currentSong: null,
isPlaying: false,
progress: 0,
volume: 0.8,
shuffle: false,
repeat: false,
playlist: [],
currentIndex: -1,
setSong: (song) => set({ currentSong: song }),
play: () => set({ isPlaying: true }),
pause: () => set({ isPlaying: false }),
togglePlay: () => set((s) => ({ isPlaying: !s.isPlaying })),
seek: (progress) => set({ progress }),
setVolume: (volume) => set({ volume }),
toggleShuffle: () => set((s) => ({ shuffle: !s.shuffle })),
toggleRepeat: () => set((s) => ({ repeat: !s.repeat })),
next: () => {
const { playlist, currentIndex, repeat } = get()
const nextIndex = currentIndex + 1
if (nextIndex >= playlist.length) {
if (repeat) {
set({ currentIndex: 0 })
set({ currentSong: playlist[0] })
}
return
}
set({ currentIndex: nextIndex })
set({ currentSong: playlist[nextIndex] })
},
previous: () => {
const { playlist, currentIndex } = get()
const prevIndex = currentIndex <= 0 ? playlist.length - 1 : currentIndex - 1
set({ currentIndex: prevIndex })
set({ currentSong: playlist[prevIndex] })
},
setPlaylist: (songs, startIndex = 0) => {
set({ playlist: songs, currentIndex: startIndex })
if (songs.length > 0) {
set({ currentSong: songs[startIndex] })
}
},
}))

View File

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -1,31 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,jsx,ts,tsx}', './app/**/*.{js,jsx,ts,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',
},
},
},
plugins: [],
}

View File

@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020"],
"jsx": "react-native-jsx",
"strict": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*", "app/**/*"],
"extends": "expo/tsconfig.base"
}

16344
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +0,0 @@
{
"name": "music-app",
"private": true,
"workspaces": [
"shared",
"web",
"mobile"
],
"scripts": {
"dev:web": "npm run dev --workspace=web -- --host 0.0.0.0",
"dev:mobile": "npm run start --workspace=mobile",
"dev:backend": "cd backend && python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000",
"dev": "concurrently \"npm run dev:web\" \"npm run dev:backend\"",
"build:web": "npm run build --workspace=web",
"build:mobile": "npm run build --workspace=mobile",
"lint": "npm run lint --workspaces --if-present",
"typecheck": "npm run typecheck --workspaces --if-present"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"concurrently": "^8.2.2"
}
}

View File

@ -1,24 +0,0 @@
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' },
},
],
}

View File

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

View File

@ -1,17 +0,0 @@
<!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>

View File

@ -1,41 +0,0 @@
{
"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"
}
}

View File

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

View File

@ -1,41 +0,0 @@
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>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,36 +0,0 @@
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>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,136 +0,0 @@
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>
)
}

View File

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

View File

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

View File

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

View File

@ -1,150 +0,0 @@
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}</>
}

View File

@ -1,102 +0,0 @@
@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;
}

View File

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

View File

@ -1,70 +0,0 @@
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

View File

@ -1,25 +0,0 @@
{
"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"]
}

View File

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

View File

@ -1,20 +0,0 @@
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',
}
}
})