Initial commit - SoundWave v1.0

- Full PWA support with offline capabilities
- Comprehensive search across songs, playlists, and channels
- Offline playlist manager with download tracking
- Pre-built frontend for zero-build deployment
- Docker-based deployment with docker compose
- Material-UI dark theme interface
- YouTube audio download and management
- Multi-user authentication support
This commit is contained in:
Iulian 2025-12-16 23:43:07 +00:00
commit 51679d1943
254 changed files with 37281 additions and 0 deletions

112
frontend/README.md Normal file
View file

@ -0,0 +1,112 @@
# SoundWave Frontend
React + TypeScript + Material-UI frontend for SoundWave audio archiving platform.
## 🎨 Features
- **Pixel-perfect Login Page** - Matches the design specification exactly
- **Dark Theme** - Deep blue/purple color scheme
- **Material Design** - Using Material-UI components and icons
- **Responsive Audio Player** - Full playback controls
- **Modern Architecture** - React 18 + TypeScript + Vite
## 🚀 Development
### Install Dependencies
```bash
npm install
```
### Run Development Server
```bash
npm run dev
```
The app will be available at `http://localhost:3000`
### Build for Production
```bash
npm run build
```
The build output will be in the `dist/` directory.
## 📁 Project Structure
```
src/
├── api/ # API client and endpoints
├── components/ # Reusable components
│ ├── Sidebar.tsx
│ ├── TopBar.tsx
│ └── Player.tsx
├── pages/ # Page components
│ ├── LoginPage.tsx # Pixel-perfect login page
│ ├── HomePage.tsx
│ ├── LibraryPage.tsx
│ ├── SearchPage.tsx
│ ├── FavoritesPage.tsx
│ └── SettingsPage.tsx
├── theme/ # Material-UI theme
├── types/ # TypeScript definitions
├── App.tsx # Main app component
└── main.tsx # Entry point
```
## 🎨 Design System
### Colors
- **Primary**: `#5C6BC0` (Indigo)
- **Secondary**: `#7E57C2` (Deep Purple)
- **Accent**: `#4ECDC4` (Cyan - for login page)
- **Background**: `#0A0E27` (Very Dark Blue)
- **Paper**: `#151932` (Dark Blue-Gray)
### Login Page Colors
- **Left Side**: `#A8DADC` (Light Cyan)
- **Right Side**: `#F1F3F4` (Light Gray)
- **Logo**: Dark Navy `#1D3557` + Cyan `#4ECDC4`
- **Button**: `#2D3748` (Dark Gray)
## 🔌 API Integration
The frontend connects to the Django backend API at:
- Development: `http://localhost:8000/api/`
- Production: Configured via proxy
## 📱 Responsive Design
The application is optimized for:
- Desktop (1920x1080 and above)
- Tablet (768px - 1024px)
- Mobile (coming soon)
## 🛠️ Technologies
- **React 18** - UI library
- **TypeScript** - Type safety
- **Material-UI (MUI)** - Component library
- **Vite** - Build tool
- **React Router** - Navigation
- **Axios** - HTTP client
## 🔐 Authentication
The login page integrates with the Django backend authentication system. Upon successful login, a token is stored in localStorage and used for subsequent API requests.
## 🎵 Audio Player
The built-in audio player features:
- Play/Pause controls
- Progress bar with seek
- Volume control
- Previous/Next track
- Shuffle and Repeat
- Now playing information
Enjoy building with SoundWave! 🎧

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

179
frontend/dist/assets/mui-DW1KyNMb.js vendored Normal file

File diff suppressed because one or more lines are too long

59
frontend/dist/assets/vendor-Bv7lQTk9.js vendored Normal file

File diff suppressed because one or more lines are too long

11
frontend/dist/avatars/preset_1.svg vendored Normal file
View file

@ -0,0 +1,11 @@
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Background circle -->
<circle cx="100" cy="100" r="100" fill="#6366F1"/>
<!-- Music note -->
<g fill="white">
<circle cx="90" cy="140" r="15"/>
<rect x="102" y="90" width="8" height="65" rx="4"/>
<path d="M 110 90 Q 130 85 135 75 Q 138 68 135 65 Q 132 62 125 65 L 110 72 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 406 B

11
frontend/dist/avatars/preset_2.svg vendored Normal file
View file

@ -0,0 +1,11 @@
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Background circle -->
<circle cx="100" cy="100" r="100" fill="#EC4899"/>
<!-- Headphones -->
<g fill="white" stroke="white" stroke-width="4">
<path d="M 60 100 Q 60 50 100 50 Q 140 50 140 100" fill="none"/>
<rect x="50" y="95" width="20" height="35" rx="5"/>
<rect x="130" y="95" width="20" height="35" rx="5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 442 B

12
frontend/dist/avatars/preset_3.svg vendored Normal file
View file

@ -0,0 +1,12 @@
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Background circle -->
<circle cx="100" cy="100" r="100" fill="#10B981"/>
<!-- Microphone -->
<g fill="white">
<rect x="85" y="60" width="30" height="50" rx="15"/>
<path d="M 70 100 Q 70 130 100 130 Q 130 130 130 100" fill="none" stroke="white" stroke-width="6"/>
<rect x="95" y="130" width="10" height="30"/>
<rect x="75" y="155" width="50" height="8" rx="4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 495 B

12
frontend/dist/avatars/preset_4.svg vendored Normal file
View file

@ -0,0 +1,12 @@
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Background circle -->
<circle cx="100" cy="100" r="100" fill="#F59E0B"/>
<!-- Vinyl record -->
<g>
<circle cx="100" cy="100" r="60" fill="white"/>
<circle cx="100" cy="100" r="50" fill="#1F2937"/>
<circle cx="100" cy="100" r="15" fill="white"/>
<circle cx="100" cy="100" r="8" fill="#1F2937"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 428 B

15
frontend/dist/avatars/preset_5.svg vendored Normal file
View file

@ -0,0 +1,15 @@
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Background circle -->
<circle cx="100" cy="100" r="100" fill="#8B5CF6"/>
<!-- Waveform -->
<g fill="white">
<rect x="50" y="90" width="8" height="20" rx="4"/>
<rect x="65" y="70" width="8" height="60" rx="4"/>
<rect x="80" y="50" width="8" height="100" rx="4"/>
<rect x="95" y="80" width="8" height="40" rx="4"/>
<rect x="110" y="60" width="8" height="80" rx="4"/>
<rect x="125" y="75" width="8" height="50" rx="4"/>
<rect x="140" y="85" width="8" height="30" rx="4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 615 B

BIN
frontend/dist/favicon.ico vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

271
frontend/dist/icon-preview.html vendored Normal file
View file

@ -0,0 +1,271 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SoundWave PWA Icons Preview</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 20px;
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 20px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
h1 {
text-align: center;
color: #333;
margin-bottom: 10px;
font-size: 2.5em;
}
.subtitle {
text-align: center;
color: #666;
margin-bottom: 40px;
font-size: 1.1em;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 30px;
margin-bottom: 50px;
}
.icon-card {
text-align: center;
padding: 20px;
border: 2px solid #e0e0e0;
border-radius: 15px;
transition: all 0.3s ease;
background: #f9f9f9;
}
.icon-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
border-color: #1976d2;
}
.icon-wrapper {
background: white;
padding: 20px;
border-radius: 10px;
margin-bottom: 15px;
display: inline-block;
}
.icon-card img {
display: block;
margin: 0 auto;
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
.icon-label {
font-weight: 600;
color: #333;
margin-bottom: 5px;
font-size: 1.1em;
}
.icon-size {
color: #666;
font-size: 0.9em;
}
.section-title {
font-size: 1.8em;
color: #333;
margin: 40px 0 20px;
padding-bottom: 10px;
border-bottom: 3px solid #1976d2;
}
.special-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 30px;
}
.badge {
display: inline-block;
background: #4caf50;
color: white;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.75em;
margin-top: 5px;
font-weight: 600;
}
.maskable {
background: #ff9800;
}
.apple {
background: #000;
}
.stats {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 15px;
margin-top: 40px;
text-align: center;
}
.stats h3 {
margin-bottom: 20px;
font-size: 1.5em;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 20px;
}
.stat-item {
background: rgba(255,255,255,0.2);
padding: 20px;
border-radius: 10px;
}
.stat-value {
font-size: 2em;
font-weight: bold;
margin-bottom: 5px;
}
.stat-label {
font-size: 0.9em;
opacity: 0.9;
}
</style>
</head>
<body>
<div class="container">
<h1>🎵 SoundWave</h1>
<p class="subtitle">PWA Icon Set - All Sizes Generated</p>
<h2 class="section-title">Standard Icons (Any Purpose)</h2>
<div class="grid">
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/icon-72x72.png" alt="72x72" width="72" height="72">
</div>
<div class="icon-label">Small</div>
<div class="icon-size">72×72 pixels</div>
</div>
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/icon-96x96.png" alt="96x96" width="96" height="96">
</div>
<div class="icon-label">Medium</div>
<div class="icon-size">96×96 pixels</div>
</div>
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/icon-128x128.png" alt="128x128" width="128" height="128">
</div>
<div class="icon-label">Large</div>
<div class="icon-size">128×128 pixels</div>
</div>
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/icon-144x144.png" alt="144x144" width="144" height="144">
</div>
<div class="icon-label">X-Large</div>
<div class="icon-size">144×144 pixels</div>
</div>
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/icon-152x152.png" alt="152x152" width="152" height="152">
</div>
<div class="icon-label">iOS</div>
<div class="icon-size">152×152 pixels</div>
</div>
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/icon-192x192.png" alt="192x192" width="192" height="192">
</div>
<div class="icon-label">Standard HD</div>
<div class="icon-size">192×192 pixels</div>
</div>
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/icon-384x384.png" alt="384x384" width="192" height="192">
</div>
<div class="icon-label">Retina</div>
<div class="icon-size">384×384 pixels</div>
</div>
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/icon-512x512.png" alt="512x512" width="192" height="192">
</div>
<div class="icon-label">Ultra HD</div>
<div class="icon-size">512×512 pixels</div>
</div>
</div>
<h2 class="section-title">Platform-Specific Icons</h2>
<div class="special-grid">
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/icon-192x192-maskable.png" alt="192x192 Maskable" width="192" height="192">
</div>
<div class="icon-label">Android Maskable</div>
<div class="icon-size">192×192 pixels</div>
<span class="badge maskable">Maskable</span>
</div>
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/icon-512x512-maskable.png" alt="512x512 Maskable" width="192" height="192">
</div>
<div class="icon-label">Android HD Maskable</div>
<div class="icon-size">512×512 pixels</div>
<span class="badge maskable">Maskable</span>
</div>
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/apple-touch-icon.png" alt="Apple Touch Icon" width="180" height="180">
</div>
<div class="icon-label">Apple Touch Icon</div>
<div class="icon-size">180×180 pixels</div>
<span class="badge apple">iOS</span>
</div>
</div>
<div class="stats">
<h3>📊 Icon Set Statistics</h3>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value">11</div>
<div class="stat-label">Total Icons</div>
</div>
<div class="stat-item">
<div class="stat-value">8</div>
<div class="stat-label">Standard Sizes</div>
</div>
<div class="stat-item">
<div class="stat-value">2</div>
<div class="stat-label">Maskable Icons</div>
</div>
<div class="stat-item">
<div class="stat-value">1</div>
<div class="stat-label">Apple Icon</div>
</div>
<div class="stat-item">
<div class="stat-value">~500KB</div>
<div class="stat-label">Total Size</div>
</div>
<div class="stat-item">
<div class="stat-value"></div>
<div class="stat-label">PWA Ready</div>
</div>
</div>
</div>
<div style="text-align: center; margin-top: 40px; color: #666;">
<p>✨ All icons generated from official SoundWave logo</p>
<p style="margin-top: 10px;">🎯 Optimized for all platforms: Android, iOS, Desktop</p>
</div>
</div>
</body>
</html>

23
frontend/dist/img/GENERATE_ICONS.md vendored Normal file
View file

@ -0,0 +1,23 @@
# PWA Icon Generation Script
# This creates placeholder icons - replace with actual app icons
# For production, generate proper icons using:
# https://www.pwabuilder.com/imageGenerator
# or
# https://realfavicongenerator.net/
echo "To generate PWA icons, use one of these tools:"
echo "1. PWA Builder Image Generator: https://www.pwabuilder.com/imageGenerator"
echo "2. Real Favicon Generator: https://realfavicongenerator.net/"
echo ""
echo "Required icon sizes:"
echo "- 72x72"
echo "- 96x96"
echo "- 128x128"
echo "- 144x144"
echo "- 152x152"
echo "- 192x192"
echo "- 384x384"
echo "- 512x512"
echo ""
echo "Place generated icons in: frontend/public/img/"

BIN
frontend/dist/img/favicon.ico vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
frontend/dist/img/icons/icon-128x128.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
frontend/dist/img/icons/icon-144x144.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
frontend/dist/img/icons/icon-152x152.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
frontend/dist/img/icons/icon-192x192.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
frontend/dist/img/icons/icon-384x384.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

BIN
frontend/dist/img/icons/icon-512x512.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
frontend/dist/img/icons/icon-72x72.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

BIN
frontend/dist/img/icons/icon-96x96.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

17
frontend/dist/img/icons/logo-source.svg vendored Normal file
View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768">
<rect width="768" height="768" fill="#A8D5D8"/>
<circle cx="384" cy="330" r="135" fill="none" stroke="#0F4C75" stroke-width="12"/>
<circle cx="384" cy="330" r="110" fill="none" stroke="#0F4C75" stroke-width="12"/>
<path d="M 385 230 Q 405 240 385 250 Q 405 255 385 270 Q 410 275 385 290" fill="none" stroke="#00C8C8" stroke-width="8" stroke-linecap="round"/>
<circle cx="384" cy="330" r="80" fill="none" stroke="#00C8C8" stroke-width="10"/>
<path fill="#0F4C75" d="M 350 305 L 350 355 L 410 330 Z"/>
<rect x="220" y="310" width="12" height="40" rx="6" fill="#00C8C8"/>
<rect x="245" y="295" width="12" height="70" rx="6" fill="#00C8C8"/>
<rect x="270" y="285" width="12" height="90" rx="6" fill="#0F4C75"/>
<rect x="510" y="310" width="12" height="40" rx="6" fill="#00C8C8"/>
<rect x="535" y="295" width="12" height="70" rx="6" fill="#00C8C8"/>
<rect x="560" y="285" width="12" height="90" rx="6" fill="#0F4C75"/>
<text x="384" y="550" font-family="Arial, sans-serif" font-size="80" font-weight="bold" text-anchor="middle" fill="#0F4C75">sound</text>
<text x="384" y="550" font-family="Arial, sans-serif" font-size="80" font-weight="bold" text-anchor="middle" fill="#00C8C8" dx="160">wave</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

39
frontend/dist/img/logo-app.svg vendored Normal file
View file

@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" width="768" height="768">
<!-- Background circle -->
<circle cx="384" cy="384" r="384" fill="#A8DADC"/>
<!-- Sound wave lines - left -->
<rect x="190" y="340" width="12" height="60" rx="6" fill="#4ECDC4"/>
<rect x="210" y="355" width="12" height="30" rx="6" fill="#4ECDC4"/>
<rect x="230" y="345" width="12" height="50" rx="6" fill="#4ECDC4"/>
<!-- Outer circle - cyan -->
<circle cx="384" cy="320" r="110" fill="none" stroke="#4ECDC4" stroke-width="16"/>
<!-- Middle circle - dark blue -->
<circle cx="384" cy="320" r="85" fill="none" stroke="#1D3557" stroke-width="12"/>
<!-- Inner circle - cyan -->
<circle cx="384" cy="320" r="65" fill="#A8DADC" stroke="#4ECDC4" stroke-width="10"/>
<!-- Play button -->
<path d="M 370 300 L 370 340 L 405 320 Z" fill="#1D3557"/>
<!-- Sound wave lines - right -->
<rect x="526" y="345" width="12" height="50" rx="6" fill="#4ECDC4"/>
<rect x="546" y="355" width="12" height="30" rx="6" fill="#4ECDC4"/>
<rect x="566" y="340" width="12" height="60" rx="6" fill="#4ECDC4"/>
<!-- Text "soundwave" curved -->
<path id="curve" d="M 180 420 Q 384 500 588 420" fill="none"/>
<text font-family="Arial, sans-serif" font-size="80" font-weight="700" fill="#1D3557">
<textPath href="#curve" startOffset="12%">
sound
</textPath>
</text>
<text font-family="Arial, sans-serif" font-size="80" font-weight="700" fill="#4ECDC4">
<textPath href="#curve" startOffset="50%">
wave
</textPath>
</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
frontend/dist/img/logo-new.png vendored Normal file
View file

@ -0,0 +1 @@
# This will be replaced with the actual image file

BIN
frontend/dist/img/logo-temp.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
frontend/dist/img/logo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

39
frontend/dist/img/logo.svg vendored Normal file
View file

@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" width="768" height="768">
<!-- Background circle -->
<circle cx="384" cy="384" r="384" fill="#A8DADC"/>
<!-- Sound wave lines - left -->
<rect x="190" y="340" width="12" height="60" rx="6" fill="#4ECDC4"/>
<rect x="210" y="355" width="12" height="30" rx="6" fill="#4ECDC4"/>
<rect x="230" y="345" width="12" height="50" rx="6" fill="#4ECDC4"/>
<!-- Outer circle - cyan -->
<circle cx="384" cy="320" r="110" fill="none" stroke="#4ECDC4" stroke-width="16"/>
<!-- Middle circle - dark blue -->
<circle cx="384" cy="320" r="85" fill="none" stroke="#1D3557" stroke-width="12"/>
<!-- Inner circle - cyan -->
<circle cx="384" cy="320" r="65" fill="#A8DADC" stroke="#4ECDC4" stroke-width="10"/>
<!-- Play button -->
<path d="M 370 300 L 370 340 L 405 320 Z" fill="#1D3557"/>
<!-- Sound wave lines - right -->
<rect x="526" y="345" width="12" height="50" rx="6" fill="#4ECDC4"/>
<rect x="546" y="355" width="12" height="30" rx="6" fill="#4ECDC4"/>
<rect x="566" y="340" width="12" height="60" rx="6" fill="#4ECDC4"/>
<!-- Text "soundwave" curved -->
<path id="curve" d="M 180 420 Q 384 500 588 420" fill="none"/>
<text font-family="Arial, sans-serif" font-size="80" font-weight="700" fill="#1D3557">
<textPath href="#curve" startOffset="12%">
sound
</textPath>
</text>
<text font-family="Arial, sans-serif" font-size="80" font-weight="700" fill="#4ECDC4">
<textPath href="#curve" startOffset="50%">
wave
</textPath>
</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

71
frontend/dist/index.html vendored Normal file
View file

@ -0,0 +1,71 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- Primary Meta Tags -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no" />
<meta name="title" content="SoundWave - Music Streaming & YouTube Archive" />
<meta name="description" content="Multi-tenant YouTube music streaming platform with local file support, offline playback, and advanced audio features" />
<meta name="keywords" content="music, streaming, youtube, audio, offline, pwa, soundwave" />
<meta name="author" content="SoundWave" />
<!-- PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Soundwave" />
<meta name="application-name" content="Soundwave" />
<meta name="theme-color" content="#1976d2" />
<meta name="msapplication-TileColor" content="#1976d2" />
<meta name="msapplication-tap-highlight" content="no" />
<!-- Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- ID3 Tag Reader -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsmediatags/3.9.5/jsmediatags.min.js"></script>
<!-- Icons -->
<link rel="icon" type="image/x-icon" href="/img/favicon.ico" />
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/img/icons/icon-72x72.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/img/icons/icon-192x192.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/img/icons/apple-touch-icon.png" />
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/img/icon-192x192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/img/icon-512x512.png" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://soundwave.app/" />
<meta property="og:title" content="SoundWave - Music Streaming & YouTube Archive" />
<meta property="og:description" content="Multi-tenant YouTube music streaming platform with local file support, offline playback, and advanced audio features" />
<meta property="og:image" content="/img/icon-512x512.png" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://soundwave.app/" />
<meta property="twitter:title" content="SoundWave - Music Streaming & YouTube Archive" />
<meta property="twitter:description" content="Multi-tenant YouTube music streaming platform with local file support, offline playback, and advanced audio features" />
<meta property="twitter:image" content="/img/icon-512x512.png" />
<title>SoundWave - Music Streaming & YouTube Archive</title>
<!-- Preconnect to API -->
<link rel="preconnect" href="/api" />
<script type="module" crossorigin src="/assets/index-ChIfYXgy.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-Bv7lQTk9.js">
<link rel="modulepreload" crossorigin href="/assets/mui-DW1KyNMb.js">
<link rel="stylesheet" crossorigin href="/assets/index-BeXoqz9j.css">
</head>
<body>
<noscript>
<div style="text-align: center; padding: 50px; font-family: Arial, sans-serif;">
<h1>JavaScript Required</h1>
<p>SoundWave requires JavaScript to be enabled. Please enable JavaScript in your browser settings.</p>
</div>
</noscript>
<div id="root"></div>
</body>
</html>

135
frontend/dist/manifest.json vendored Normal file
View file

@ -0,0 +1,135 @@
{
"name": "SoundWave",
"short_name": "SoundWave",
"description": "Music streaming platform with local file support and offline playback",
"start_url": "/",
"display": "standalone",
"background_color": "#121212",
"theme_color": "#1976d2",
"orientation": "portrait-primary",
"scope": "/",
"icons": [
{
"src": "/img/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any"
},
{
"src": "/img/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any"
},
{
"src": "/img/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any"
},
{
"src": "/img/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any"
},
{
"src": "/img/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any"
},
{
"src": "/img/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/img/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any"
},
{
"src": "/img/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/img/icons/icon-192x192-maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/img/icons/icon-512x512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots": [
{
"src": "/img/screenshot-wide.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/img/screenshot-narrow.png",
"sizes": "540x720",
"type": "image/png",
"form_factor": "narrow"
}
],
"categories": ["music", "entertainment"],
"shortcuts": [
{
"name": "Home",
"short_name": "Home",
"description": "Open home page",
"url": "/",
"icons": [{ "src": "/img/icons/icon-192x192.png", "sizes": "192x192" }]
},
{
"name": "Search",
"short_name": "Search",
"description": "Search for music",
"url": "/search",
"icons": [{ "src": "/img/icons/icon-192x192.png", "sizes": "192x192" }]
},
{
"name": "Library",
"short_name": "Library",
"description": "View your library",
"url": "/library",
"icons": [{ "src": "/img/icons/icon-192x192.png", "sizes": "192x192" }]
},
{
"name": "Local Files",
"short_name": "Local Files",
"description": "View local files",
"url": "/local-files",
"icons": [{ "src": "/img/icons/icon-192x192.png", "sizes": "192x192" }]
}
],
"share_target": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "audio",
"accept": ["audio/*"]
}
]
}
},
"prefer_related_applications": false
}

9
frontend/dist/robots.txt vendored Normal file
View file

@ -0,0 +1,9 @@
User-agent: *
Allow: /
# Disallow admin and API endpoints
Disallow: /api/
Disallow: /admin/
# Sitemap
Sitemap: https://soundwave.app/sitemap.xml

377
frontend/dist/service-worker.js vendored Normal file
View file

@ -0,0 +1,377 @@
/* eslint-disable no-restricted-globals */
const CACHE_NAME = 'soundwave-v1';
const API_CACHE_NAME = 'soundwave-api-v1';
const AUDIO_CACHE_NAME = 'soundwave-audio-v1';
const IMAGE_CACHE_NAME = 'soundwave-images-v1';
// Assets to cache on install
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/favicon.ico',
];
// Install event - cache static assets
self.addEventListener('install', (event) => {
console.log('[Service Worker] Installing...');
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('[Service Worker] Caching static assets');
return cache.addAll(STATIC_ASSETS).catch((err) => {
console.error('[Service Worker] Failed to cache static assets:', err);
});
}).then(() => {
console.log('[Service Worker] Installed');
return self.skipWaiting(); // Activate immediately
})
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
console.log('[Service Worker] Activating...');
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (
cacheName !== CACHE_NAME &&
cacheName !== API_CACHE_NAME &&
cacheName !== AUDIO_CACHE_NAME &&
cacheName !== IMAGE_CACHE_NAME
) {
console.log('[Service Worker] Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
}).then(() => {
console.log('[Service Worker] Activated');
return self.clients.claim(); // Take control immediately
})
);
});
// Fetch event - implement caching strategies
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip chrome extensions and non-http(s) requests
if (!url.protocol.startsWith('http')) {
return;
}
// API requests - Network first, fallback to cache
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirstStrategy(request, API_CACHE_NAME));
return;
}
// Audio files - Cache first, fallback to network
if (
url.pathname.includes('/audio/') ||
url.pathname.includes('/media/local_audio/') ||
request.destination === 'audio'
) {
event.respondWith(cacheFirstStrategy(request, AUDIO_CACHE_NAME));
return;
}
// Images - Cache first, fallback to network
if (
url.pathname.includes('/img/') ||
url.pathname.includes('/media/') ||
url.pathname.includes('thumbnail') ||
url.pathname.includes('cover') ||
request.destination === 'image'
) {
event.respondWith(cacheFirstStrategy(request, IMAGE_CACHE_NAME));
return;
}
// Static assets (JS, CSS) - Stale while revalidate
if (
request.destination === 'script' ||
request.destination === 'style' ||
url.pathname.endsWith('.js') ||
url.pathname.endsWith('.css')
) {
event.respondWith(staleWhileRevalidateStrategy(request, CACHE_NAME));
return;
}
// HTML pages - Network first, fallback to cache
if (request.mode === 'navigate' || request.destination === 'document') {
event.respondWith(networkFirstStrategy(request, CACHE_NAME));
return;
}
// Default - Network first
event.respondWith(networkFirstStrategy(request, CACHE_NAME));
});
// Network first strategy - try network, fallback to cache
async function networkFirstStrategy(request, cacheName) {
try {
const networkResponse = await fetch(request);
// Cache successful responses
if (networkResponse && networkResponse.status === 200) {
const cache = await caches.open(cacheName);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.log('[Service Worker] Network request failed, trying cache:', request.url);
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// Return offline page for navigation requests
if (request.mode === 'navigate') {
const cache = await caches.open(CACHE_NAME);
const offlinePage = await cache.match('/index.html');
if (offlinePage) {
return offlinePage;
}
}
throw error;
}
}
// Cache first strategy - try cache, fallback to network
async function cacheFirstStrategy(request, cacheName) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
// Cache successful responses
if (networkResponse && networkResponse.status === 200) {
const cache = await caches.open(cacheName);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.error('[Service Worker] Cache and network failed:', error);
throw error;
}
}
// Stale while revalidate - return cache immediately, update in background
async function staleWhileRevalidateStrategy(request, cacheName) {
const cachedResponse = await caches.match(request);
const fetchPromise = fetch(request).then((networkResponse) => {
if (networkResponse && networkResponse.status === 200) {
const cache = caches.open(cacheName);
cache.then((c) => c.put(request, networkResponse.clone()));
}
return networkResponse;
}).catch((error) => {
console.log('[Service Worker] Background fetch failed:', error);
});
return cachedResponse || fetchPromise;
}
// Background sync for offline uploads
self.addEventListener('sync', (event) => {
console.log('[Service Worker] Background sync:', event.tag);
if (event.tag === 'sync-audio-uploads') {
event.waitUntil(syncAudioUploads());
}
if (event.tag === 'sync-favorites') {
event.waitUntil(syncFavorites());
}
});
async function syncAudioUploads() {
console.log('[Service Worker] Syncing audio uploads...');
// Implementation for syncing pending uploads when back online
// This would read from IndexedDB and upload pending files
}
async function syncFavorites() {
console.log('[Service Worker] Syncing favorites...');
// Implementation for syncing favorite changes when back online
}
// Push notifications
self.addEventListener('push', (event) => {
console.log('[Service Worker] Push notification received');
const data = event.data ? event.data.json() : {};
const title = data.title || 'SoundWave';
const options = {
body: data.body || 'New content available',
icon: '/img/icon-192x192.png',
badge: '/img/icon-72x72.png',
vibrate: [200, 100, 200],
data: data.url || '/',
actions: [
{ action: 'open', title: 'Open' },
{ action: 'close', title: 'Close' },
],
};
event.waitUntil(
self.registration.showNotification(title, options)
);
});
// Notification click
self.addEventListener('notificationclick', (event) => {
console.log('[Service Worker] Notification clicked');
event.notification.close();
if (event.action === 'open' || !event.action) {
const urlToOpen = event.notification.data || '/';
event.waitUntil(
clients.openWindow(urlToOpen)
);
}
});
// Message handling for cache management
self.addEventListener('message', (event) => {
console.log('[Service Worker] Message received:', event.data);
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
if (event.data && event.data.type === 'CLEAR_CACHE') {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => caches.delete(cacheName))
);
}).then(() => {
event.ports[0].postMessage({ success: true });
})
);
}
if (event.data && event.data.type === 'CACHE_AUDIO') {
const { url } = event.data;
event.waitUntil(
caches.open(AUDIO_CACHE_NAME).then((cache) => {
return cache.add(url);
}).then(() => {
event.ports[0].postMessage({ success: true });
}).catch((error) => {
event.ports[0].postMessage({ success: false, error: error.message });
})
);
}
// Cache playlist for offline access with authentication
if (event.data && event.data.type === 'CACHE_PLAYLIST') {
const { playlistId, audioUrls } = event.data;
event.waitUntil(
(async () => {
try {
console.log('[Service Worker] Caching playlist:', playlistId, 'with', audioUrls.length, 'tracks');
const results = {
metadata: false,
audioFiles: [],
failed: []
};
// Cache playlist metadata API response (includes items)
try {
const apiCache = await caches.open(API_CACHE_NAME);
const metadataUrl = `/api/playlist/${playlistId}/?include_items=true`;
await apiCache.add(metadataUrl);
results.metadata = true;
console.log('[Service Worker] Cached playlist metadata');
} catch (err) {
console.warn('[Service Worker] Failed to cache playlist metadata:', err);
}
// Cache all audio files in playlist with authentication
const audioCache = await caches.open(AUDIO_CACHE_NAME);
for (const url of audioUrls) {
try {
// Create authenticated request
const authRequest = new Request(url, {
credentials: 'include',
headers: {
'Accept': 'audio/*',
}
});
const response = await fetch(authRequest);
if (response.ok) {
// Clone and cache the response
await audioCache.put(url, response.clone());
results.audioFiles.push(url);
console.log('[Service Worker] Cached audio:', url);
} else {
results.failed.push(url);
console.warn('[Service Worker] Failed to cache audio (status ' + response.status + '):', url);
}
} catch (err) {
results.failed.push(url);
console.warn('[Service Worker] Failed to cache audio:', url, err);
}
}
console.log('[Service Worker] Playlist caching complete:', results);
event.ports[0].postMessage({
success: results.audioFiles.length > 0,
metadata: results.metadata,
cached: results.audioFiles.length,
failed: results.failed.length,
details: results
});
} catch (error) {
console.error('[Service Worker] Playlist caching error:', error);
event.ports[0].postMessage({ success: false, error: error.message });
}
})()
);
}
// Remove cached playlist
if (event.data && event.data.type === 'REMOVE_PLAYLIST_CACHE') {
const { playlistId, audioUrls } = event.data;
event.waitUntil(
Promise.all([
// Remove playlist metadata from cache
caches.open(API_CACHE_NAME).then((cache) => {
return cache.delete(`/api/playlist/${playlistId}/`);
}),
// Remove audio files from cache (only if not used by other playlists)
caches.open(AUDIO_CACHE_NAME).then((cache) => {
return Promise.all(
audioUrls.map(url => cache.delete(url))
);
})
]).then(() => {
event.ports[0].postMessage({ success: true });
}).catch((error) => {
event.ports[0].postMessage({ success: false, error: error.message });
})
);
}
});
console.log('[Service Worker] Loaded');

33
frontend/dist/sitemap.xml vendored Normal file
View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://soundwave.app/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://soundwave.app/search</loc>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://soundwave.app/library</loc>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://soundwave.app/favorites</loc>
<changefreq>daily</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://soundwave.app/local-files</loc>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://soundwave.app/settings</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
</urlset>

68
frontend/index.html Normal file
View file

@ -0,0 +1,68 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- Primary Meta Tags -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no" />
<meta name="title" content="SoundWave - Music Streaming & YouTube Archive" />
<meta name="description" content="Multi-tenant YouTube music streaming platform with local file support, offline playback, and advanced audio features" />
<meta name="keywords" content="music, streaming, youtube, audio, offline, pwa, soundwave" />
<meta name="author" content="SoundWave" />
<!-- PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Soundwave" />
<meta name="application-name" content="Soundwave" />
<meta name="theme-color" content="#1976d2" />
<meta name="msapplication-TileColor" content="#1976d2" />
<meta name="msapplication-tap-highlight" content="no" />
<!-- Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- ID3 Tag Reader -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsmediatags/3.9.5/jsmediatags.min.js"></script>
<!-- Icons -->
<link rel="icon" type="image/x-icon" href="/img/favicon.ico" />
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/img/icons/icon-72x72.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/img/icons/icon-192x192.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/img/icons/apple-touch-icon.png" />
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/img/icon-192x192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/img/icon-512x512.png" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://soundwave.app/" />
<meta property="og:title" content="SoundWave - Music Streaming & YouTube Archive" />
<meta property="og:description" content="Multi-tenant YouTube music streaming platform with local file support, offline playback, and advanced audio features" />
<meta property="og:image" content="/img/icon-512x512.png" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://soundwave.app/" />
<meta property="twitter:title" content="SoundWave - Music Streaming & YouTube Archive" />
<meta property="twitter:description" content="Multi-tenant YouTube music streaming platform with local file support, offline playback, and advanced audio features" />
<meta property="twitter:image" content="/img/icon-512x512.png" />
<title>SoundWave - Music Streaming & YouTube Archive</title>
<!-- Preconnect to API -->
<link rel="preconnect" href="/api" />
</head>
<body>
<noscript>
<div style="text-align: center; padding: 50px; font-family: Arial, sans-serif;">
<h1>JavaScript Required</h1>
<p>SoundWave requires JavaScript to be enabled. Please enable JavaScript in your browser settings.</p>
</div>
</noscript>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4401
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

34
frontend/package.json Normal file
View file

@ -0,0 +1,34 @@
{
"name": "soundwave-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"@mui/material": "^5.14.0",
"@mui/icons-material": "^5.14.0",
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"axios": "^1.6.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react": "^4.2.0",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.0",
"typescript": "^5.3.0",
"vite": "^5.0.0"
}
}

View file

@ -0,0 +1,11 @@
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Background circle -->
<circle cx="100" cy="100" r="100" fill="#6366F1"/>
<!-- Music note -->
<g fill="white">
<circle cx="90" cy="140" r="15"/>
<rect x="102" y="90" width="8" height="65" rx="4"/>
<path d="M 110 90 Q 130 85 135 75 Q 138 68 135 65 Q 132 62 125 65 L 110 72 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 406 B

View file

@ -0,0 +1,11 @@
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Background circle -->
<circle cx="100" cy="100" r="100" fill="#EC4899"/>
<!-- Headphones -->
<g fill="white" stroke="white" stroke-width="4">
<path d="M 60 100 Q 60 50 100 50 Q 140 50 140 100" fill="none"/>
<rect x="50" y="95" width="20" height="35" rx="5"/>
<rect x="130" y="95" width="20" height="35" rx="5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 442 B

View file

@ -0,0 +1,12 @@
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Background circle -->
<circle cx="100" cy="100" r="100" fill="#10B981"/>
<!-- Microphone -->
<g fill="white">
<rect x="85" y="60" width="30" height="50" rx="15"/>
<path d="M 70 100 Q 70 130 100 130 Q 130 130 130 100" fill="none" stroke="white" stroke-width="6"/>
<rect x="95" y="130" width="10" height="30"/>
<rect x="75" y="155" width="50" height="8" rx="4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 495 B

View file

@ -0,0 +1,12 @@
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Background circle -->
<circle cx="100" cy="100" r="100" fill="#F59E0B"/>
<!-- Vinyl record -->
<g>
<circle cx="100" cy="100" r="60" fill="white"/>
<circle cx="100" cy="100" r="50" fill="#1F2937"/>
<circle cx="100" cy="100" r="15" fill="white"/>
<circle cx="100" cy="100" r="8" fill="#1F2937"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 428 B

View file

@ -0,0 +1,15 @@
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Background circle -->
<circle cx="100" cy="100" r="100" fill="#8B5CF6"/>
<!-- Waveform -->
<g fill="white">
<rect x="50" y="90" width="8" height="20" rx="4"/>
<rect x="65" y="70" width="8" height="60" rx="4"/>
<rect x="80" y="50" width="8" height="100" rx="4"/>
<rect x="95" y="80" width="8" height="40" rx="4"/>
<rect x="110" y="60" width="8" height="80" rx="4"/>
<rect x="125" y="75" width="8" height="50" rx="4"/>
<rect x="140" y="85" width="8" height="30" rx="4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 615 B

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,271 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SoundWave PWA Icons Preview</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 20px;
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 20px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
h1 {
text-align: center;
color: #333;
margin-bottom: 10px;
font-size: 2.5em;
}
.subtitle {
text-align: center;
color: #666;
margin-bottom: 40px;
font-size: 1.1em;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 30px;
margin-bottom: 50px;
}
.icon-card {
text-align: center;
padding: 20px;
border: 2px solid #e0e0e0;
border-radius: 15px;
transition: all 0.3s ease;
background: #f9f9f9;
}
.icon-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
border-color: #1976d2;
}
.icon-wrapper {
background: white;
padding: 20px;
border-radius: 10px;
margin-bottom: 15px;
display: inline-block;
}
.icon-card img {
display: block;
margin: 0 auto;
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
.icon-label {
font-weight: 600;
color: #333;
margin-bottom: 5px;
font-size: 1.1em;
}
.icon-size {
color: #666;
font-size: 0.9em;
}
.section-title {
font-size: 1.8em;
color: #333;
margin: 40px 0 20px;
padding-bottom: 10px;
border-bottom: 3px solid #1976d2;
}
.special-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 30px;
}
.badge {
display: inline-block;
background: #4caf50;
color: white;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.75em;
margin-top: 5px;
font-weight: 600;
}
.maskable {
background: #ff9800;
}
.apple {
background: #000;
}
.stats {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 15px;
margin-top: 40px;
text-align: center;
}
.stats h3 {
margin-bottom: 20px;
font-size: 1.5em;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 20px;
}
.stat-item {
background: rgba(255,255,255,0.2);
padding: 20px;
border-radius: 10px;
}
.stat-value {
font-size: 2em;
font-weight: bold;
margin-bottom: 5px;
}
.stat-label {
font-size: 0.9em;
opacity: 0.9;
}
</style>
</head>
<body>
<div class="container">
<h1>🎵 SoundWave</h1>
<p class="subtitle">PWA Icon Set - All Sizes Generated</p>
<h2 class="section-title">Standard Icons (Any Purpose)</h2>
<div class="grid">
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/icon-72x72.png" alt="72x72" width="72" height="72">
</div>
<div class="icon-label">Small</div>
<div class="icon-size">72×72 pixels</div>
</div>
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/icon-96x96.png" alt="96x96" width="96" height="96">
</div>
<div class="icon-label">Medium</div>
<div class="icon-size">96×96 pixels</div>
</div>
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/icon-128x128.png" alt="128x128" width="128" height="128">
</div>
<div class="icon-label">Large</div>
<div class="icon-size">128×128 pixels</div>
</div>
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/icon-144x144.png" alt="144x144" width="144" height="144">
</div>
<div class="icon-label">X-Large</div>
<div class="icon-size">144×144 pixels</div>
</div>
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/icon-152x152.png" alt="152x152" width="152" height="152">
</div>
<div class="icon-label">iOS</div>
<div class="icon-size">152×152 pixels</div>
</div>
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/icon-192x192.png" alt="192x192" width="192" height="192">
</div>
<div class="icon-label">Standard HD</div>
<div class="icon-size">192×192 pixels</div>
</div>
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/icon-384x384.png" alt="384x384" width="192" height="192">
</div>
<div class="icon-label">Retina</div>
<div class="icon-size">384×384 pixels</div>
</div>
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/icon-512x512.png" alt="512x512" width="192" height="192">
</div>
<div class="icon-label">Ultra HD</div>
<div class="icon-size">512×512 pixels</div>
</div>
</div>
<h2 class="section-title">Platform-Specific Icons</h2>
<div class="special-grid">
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/icon-192x192-maskable.png" alt="192x192 Maskable" width="192" height="192">
</div>
<div class="icon-label">Android Maskable</div>
<div class="icon-size">192×192 pixels</div>
<span class="badge maskable">Maskable</span>
</div>
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/icon-512x512-maskable.png" alt="512x512 Maskable" width="192" height="192">
</div>
<div class="icon-label">Android HD Maskable</div>
<div class="icon-size">512×512 pixels</div>
<span class="badge maskable">Maskable</span>
</div>
<div class="icon-card">
<div class="icon-wrapper">
<img src="/img/icons/apple-touch-icon.png" alt="Apple Touch Icon" width="180" height="180">
</div>
<div class="icon-label">Apple Touch Icon</div>
<div class="icon-size">180×180 pixels</div>
<span class="badge apple">iOS</span>
</div>
</div>
<div class="stats">
<h3>📊 Icon Set Statistics</h3>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value">11</div>
<div class="stat-label">Total Icons</div>
</div>
<div class="stat-item">
<div class="stat-value">8</div>
<div class="stat-label">Standard Sizes</div>
</div>
<div class="stat-item">
<div class="stat-value">2</div>
<div class="stat-label">Maskable Icons</div>
</div>
<div class="stat-item">
<div class="stat-value">1</div>
<div class="stat-label">Apple Icon</div>
</div>
<div class="stat-item">
<div class="stat-value">~500KB</div>
<div class="stat-label">Total Size</div>
</div>
<div class="stat-item">
<div class="stat-value"></div>
<div class="stat-label">PWA Ready</div>
</div>
</div>
</div>
<div style="text-align: center; margin-top: 40px; color: #666;">
<p>✨ All icons generated from official SoundWave logo</p>
<p style="margin-top: 10px;">🎯 Optimized for all platforms: Android, iOS, Desktop</p>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,23 @@
# PWA Icon Generation Script
# This creates placeholder icons - replace with actual app icons
# For production, generate proper icons using:
# https://www.pwabuilder.com/imageGenerator
# or
# https://realfavicongenerator.net/
echo "To generate PWA icons, use one of these tools:"
echo "1. PWA Builder Image Generator: https://www.pwabuilder.com/imageGenerator"
echo "2. Real Favicon Generator: https://realfavicongenerator.net/"
echo ""
echo "Required icon sizes:"
echo "- 72x72"
echo "- 96x96"
echo "- 128x128"
echo "- 144x144"
echo "- 152x152"
echo "- 192x192"
echo "- 384x384"
echo "- 512x512"
echo ""
echo "Place generated icons in: frontend/public/img/"

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768">
<rect width="768" height="768" fill="#A8D5D8"/>
<circle cx="384" cy="330" r="135" fill="none" stroke="#0F4C75" stroke-width="12"/>
<circle cx="384" cy="330" r="110" fill="none" stroke="#0F4C75" stroke-width="12"/>
<path d="M 385 230 Q 405 240 385 250 Q 405 255 385 270 Q 410 275 385 290" fill="none" stroke="#00C8C8" stroke-width="8" stroke-linecap="round"/>
<circle cx="384" cy="330" r="80" fill="none" stroke="#00C8C8" stroke-width="10"/>
<path fill="#0F4C75" d="M 350 305 L 350 355 L 410 330 Z"/>
<rect x="220" y="310" width="12" height="40" rx="6" fill="#00C8C8"/>
<rect x="245" y="295" width="12" height="70" rx="6" fill="#00C8C8"/>
<rect x="270" y="285" width="12" height="90" rx="6" fill="#0F4C75"/>
<rect x="510" y="310" width="12" height="40" rx="6" fill="#00C8C8"/>
<rect x="535" y="295" width="12" height="70" rx="6" fill="#00C8C8"/>
<rect x="560" y="285" width="12" height="90" rx="6" fill="#0F4C75"/>
<text x="384" y="550" font-family="Arial, sans-serif" font-size="80" font-weight="bold" text-anchor="middle" fill="#0F4C75">sound</text>
<text x="384" y="550" font-family="Arial, sans-serif" font-size="80" font-weight="bold" text-anchor="middle" fill="#00C8C8" dx="160">wave</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" width="768" height="768">
<!-- Background circle -->
<circle cx="384" cy="384" r="384" fill="#A8DADC"/>
<!-- Sound wave lines - left -->
<rect x="190" y="340" width="12" height="60" rx="6" fill="#4ECDC4"/>
<rect x="210" y="355" width="12" height="30" rx="6" fill="#4ECDC4"/>
<rect x="230" y="345" width="12" height="50" rx="6" fill="#4ECDC4"/>
<!-- Outer circle - cyan -->
<circle cx="384" cy="320" r="110" fill="none" stroke="#4ECDC4" stroke-width="16"/>
<!-- Middle circle - dark blue -->
<circle cx="384" cy="320" r="85" fill="none" stroke="#1D3557" stroke-width="12"/>
<!-- Inner circle - cyan -->
<circle cx="384" cy="320" r="65" fill="#A8DADC" stroke="#4ECDC4" stroke-width="10"/>
<!-- Play button -->
<path d="M 370 300 L 370 340 L 405 320 Z" fill="#1D3557"/>
<!-- Sound wave lines - right -->
<rect x="526" y="345" width="12" height="50" rx="6" fill="#4ECDC4"/>
<rect x="546" y="355" width="12" height="30" rx="6" fill="#4ECDC4"/>
<rect x="566" y="340" width="12" height="60" rx="6" fill="#4ECDC4"/>
<!-- Text "soundwave" curved -->
<path id="curve" d="M 180 420 Q 384 500 588 420" fill="none"/>
<text font-family="Arial, sans-serif" font-size="80" font-weight="700" fill="#1D3557">
<textPath href="#curve" startOffset="12%">
sound
</textPath>
</text>
<text font-family="Arial, sans-serif" font-size="80" font-weight="700" fill="#4ECDC4">
<textPath href="#curve" startOffset="50%">
wave
</textPath>
</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1 @@
# This will be replaced with the actual image file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View file

@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" width="768" height="768">
<!-- Background circle -->
<circle cx="384" cy="384" r="384" fill="#A8DADC"/>
<!-- Sound wave lines - left -->
<rect x="190" y="340" width="12" height="60" rx="6" fill="#4ECDC4"/>
<rect x="210" y="355" width="12" height="30" rx="6" fill="#4ECDC4"/>
<rect x="230" y="345" width="12" height="50" rx="6" fill="#4ECDC4"/>
<!-- Outer circle - cyan -->
<circle cx="384" cy="320" r="110" fill="none" stroke="#4ECDC4" stroke-width="16"/>
<!-- Middle circle - dark blue -->
<circle cx="384" cy="320" r="85" fill="none" stroke="#1D3557" stroke-width="12"/>
<!-- Inner circle - cyan -->
<circle cx="384" cy="320" r="65" fill="#A8DADC" stroke="#4ECDC4" stroke-width="10"/>
<!-- Play button -->
<path d="M 370 300 L 370 340 L 405 320 Z" fill="#1D3557"/>
<!-- Sound wave lines - right -->
<rect x="526" y="345" width="12" height="50" rx="6" fill="#4ECDC4"/>
<rect x="546" y="355" width="12" height="30" rx="6" fill="#4ECDC4"/>
<rect x="566" y="340" width="12" height="60" rx="6" fill="#4ECDC4"/>
<!-- Text "soundwave" curved -->
<path id="curve" d="M 180 420 Q 384 500 588 420" fill="none"/>
<text font-family="Arial, sans-serif" font-size="80" font-weight="700" fill="#1D3557">
<textPath href="#curve" startOffset="12%">
sound
</textPath>
</text>
<text font-family="Arial, sans-serif" font-size="80" font-weight="700" fill="#4ECDC4">
<textPath href="#curve" startOffset="50%">
wave
</textPath>
</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,135 @@
{
"name": "SoundWave",
"short_name": "SoundWave",
"description": "Music streaming platform with local file support and offline playback",
"start_url": "/",
"display": "standalone",
"background_color": "#121212",
"theme_color": "#1976d2",
"orientation": "portrait-primary",
"scope": "/",
"icons": [
{
"src": "/img/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any"
},
{
"src": "/img/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any"
},
{
"src": "/img/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any"
},
{
"src": "/img/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any"
},
{
"src": "/img/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any"
},
{
"src": "/img/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/img/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any"
},
{
"src": "/img/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/img/icons/icon-192x192-maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/img/icons/icon-512x512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots": [
{
"src": "/img/screenshot-wide.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/img/screenshot-narrow.png",
"sizes": "540x720",
"type": "image/png",
"form_factor": "narrow"
}
],
"categories": ["music", "entertainment"],
"shortcuts": [
{
"name": "Home",
"short_name": "Home",
"description": "Open home page",
"url": "/",
"icons": [{ "src": "/img/icons/icon-192x192.png", "sizes": "192x192" }]
},
{
"name": "Search",
"short_name": "Search",
"description": "Search for music",
"url": "/search",
"icons": [{ "src": "/img/icons/icon-192x192.png", "sizes": "192x192" }]
},
{
"name": "Library",
"short_name": "Library",
"description": "View your library",
"url": "/library",
"icons": [{ "src": "/img/icons/icon-192x192.png", "sizes": "192x192" }]
},
{
"name": "Local Files",
"short_name": "Local Files",
"description": "View local files",
"url": "/local-files",
"icons": [{ "src": "/img/icons/icon-192x192.png", "sizes": "192x192" }]
}
],
"share_target": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "audio",
"accept": ["audio/*"]
}
]
}
},
"prefer_related_applications": false
}

View file

@ -0,0 +1,9 @@
User-agent: *
Allow: /
# Disallow admin and API endpoints
Disallow: /api/
Disallow: /admin/
# Sitemap
Sitemap: https://soundwave.app/sitemap.xml

View file

@ -0,0 +1,377 @@
/* eslint-disable no-restricted-globals */
const CACHE_NAME = 'soundwave-v1';
const API_CACHE_NAME = 'soundwave-api-v1';
const AUDIO_CACHE_NAME = 'soundwave-audio-v1';
const IMAGE_CACHE_NAME = 'soundwave-images-v1';
// Assets to cache on install
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/favicon.ico',
];
// Install event - cache static assets
self.addEventListener('install', (event) => {
console.log('[Service Worker] Installing...');
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('[Service Worker] Caching static assets');
return cache.addAll(STATIC_ASSETS).catch((err) => {
console.error('[Service Worker] Failed to cache static assets:', err);
});
}).then(() => {
console.log('[Service Worker] Installed');
return self.skipWaiting(); // Activate immediately
})
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
console.log('[Service Worker] Activating...');
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (
cacheName !== CACHE_NAME &&
cacheName !== API_CACHE_NAME &&
cacheName !== AUDIO_CACHE_NAME &&
cacheName !== IMAGE_CACHE_NAME
) {
console.log('[Service Worker] Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
}).then(() => {
console.log('[Service Worker] Activated');
return self.clients.claim(); // Take control immediately
})
);
});
// Fetch event - implement caching strategies
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip chrome extensions and non-http(s) requests
if (!url.protocol.startsWith('http')) {
return;
}
// API requests - Network first, fallback to cache
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirstStrategy(request, API_CACHE_NAME));
return;
}
// Audio files - Cache first, fallback to network
if (
url.pathname.includes('/audio/') ||
url.pathname.includes('/media/local_audio/') ||
request.destination === 'audio'
) {
event.respondWith(cacheFirstStrategy(request, AUDIO_CACHE_NAME));
return;
}
// Images - Cache first, fallback to network
if (
url.pathname.includes('/img/') ||
url.pathname.includes('/media/') ||
url.pathname.includes('thumbnail') ||
url.pathname.includes('cover') ||
request.destination === 'image'
) {
event.respondWith(cacheFirstStrategy(request, IMAGE_CACHE_NAME));
return;
}
// Static assets (JS, CSS) - Stale while revalidate
if (
request.destination === 'script' ||
request.destination === 'style' ||
url.pathname.endsWith('.js') ||
url.pathname.endsWith('.css')
) {
event.respondWith(staleWhileRevalidateStrategy(request, CACHE_NAME));
return;
}
// HTML pages - Network first, fallback to cache
if (request.mode === 'navigate' || request.destination === 'document') {
event.respondWith(networkFirstStrategy(request, CACHE_NAME));
return;
}
// Default - Network first
event.respondWith(networkFirstStrategy(request, CACHE_NAME));
});
// Network first strategy - try network, fallback to cache
async function networkFirstStrategy(request, cacheName) {
try {
const networkResponse = await fetch(request);
// Cache successful responses
if (networkResponse && networkResponse.status === 200) {
const cache = await caches.open(cacheName);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.log('[Service Worker] Network request failed, trying cache:', request.url);
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// Return offline page for navigation requests
if (request.mode === 'navigate') {
const cache = await caches.open(CACHE_NAME);
const offlinePage = await cache.match('/index.html');
if (offlinePage) {
return offlinePage;
}
}
throw error;
}
}
// Cache first strategy - try cache, fallback to network
async function cacheFirstStrategy(request, cacheName) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
// Cache successful responses
if (networkResponse && networkResponse.status === 200) {
const cache = await caches.open(cacheName);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.error('[Service Worker] Cache and network failed:', error);
throw error;
}
}
// Stale while revalidate - return cache immediately, update in background
async function staleWhileRevalidateStrategy(request, cacheName) {
const cachedResponse = await caches.match(request);
const fetchPromise = fetch(request).then((networkResponse) => {
if (networkResponse && networkResponse.status === 200) {
const cache = caches.open(cacheName);
cache.then((c) => c.put(request, networkResponse.clone()));
}
return networkResponse;
}).catch((error) => {
console.log('[Service Worker] Background fetch failed:', error);
});
return cachedResponse || fetchPromise;
}
// Background sync for offline uploads
self.addEventListener('sync', (event) => {
console.log('[Service Worker] Background sync:', event.tag);
if (event.tag === 'sync-audio-uploads') {
event.waitUntil(syncAudioUploads());
}
if (event.tag === 'sync-favorites') {
event.waitUntil(syncFavorites());
}
});
async function syncAudioUploads() {
console.log('[Service Worker] Syncing audio uploads...');
// Implementation for syncing pending uploads when back online
// This would read from IndexedDB and upload pending files
}
async function syncFavorites() {
console.log('[Service Worker] Syncing favorites...');
// Implementation for syncing favorite changes when back online
}
// Push notifications
self.addEventListener('push', (event) => {
console.log('[Service Worker] Push notification received');
const data = event.data ? event.data.json() : {};
const title = data.title || 'SoundWave';
const options = {
body: data.body || 'New content available',
icon: '/img/icon-192x192.png',
badge: '/img/icon-72x72.png',
vibrate: [200, 100, 200],
data: data.url || '/',
actions: [
{ action: 'open', title: 'Open' },
{ action: 'close', title: 'Close' },
],
};
event.waitUntil(
self.registration.showNotification(title, options)
);
});
// Notification click
self.addEventListener('notificationclick', (event) => {
console.log('[Service Worker] Notification clicked');
event.notification.close();
if (event.action === 'open' || !event.action) {
const urlToOpen = event.notification.data || '/';
event.waitUntil(
clients.openWindow(urlToOpen)
);
}
});
// Message handling for cache management
self.addEventListener('message', (event) => {
console.log('[Service Worker] Message received:', event.data);
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
if (event.data && event.data.type === 'CLEAR_CACHE') {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => caches.delete(cacheName))
);
}).then(() => {
event.ports[0].postMessage({ success: true });
})
);
}
if (event.data && event.data.type === 'CACHE_AUDIO') {
const { url } = event.data;
event.waitUntil(
caches.open(AUDIO_CACHE_NAME).then((cache) => {
return cache.add(url);
}).then(() => {
event.ports[0].postMessage({ success: true });
}).catch((error) => {
event.ports[0].postMessage({ success: false, error: error.message });
})
);
}
// Cache playlist for offline access with authentication
if (event.data && event.data.type === 'CACHE_PLAYLIST') {
const { playlistId, audioUrls } = event.data;
event.waitUntil(
(async () => {
try {
console.log('[Service Worker] Caching playlist:', playlistId, 'with', audioUrls.length, 'tracks');
const results = {
metadata: false,
audioFiles: [],
failed: []
};
// Cache playlist metadata API response (includes items)
try {
const apiCache = await caches.open(API_CACHE_NAME);
const metadataUrl = `/api/playlist/${playlistId}/?include_items=true`;
await apiCache.add(metadataUrl);
results.metadata = true;
console.log('[Service Worker] Cached playlist metadata');
} catch (err) {
console.warn('[Service Worker] Failed to cache playlist metadata:', err);
}
// Cache all audio files in playlist with authentication
const audioCache = await caches.open(AUDIO_CACHE_NAME);
for (const url of audioUrls) {
try {
// Create authenticated request
const authRequest = new Request(url, {
credentials: 'include',
headers: {
'Accept': 'audio/*',
}
});
const response = await fetch(authRequest);
if (response.ok) {
// Clone and cache the response
await audioCache.put(url, response.clone());
results.audioFiles.push(url);
console.log('[Service Worker] Cached audio:', url);
} else {
results.failed.push(url);
console.warn('[Service Worker] Failed to cache audio (status ' + response.status + '):', url);
}
} catch (err) {
results.failed.push(url);
console.warn('[Service Worker] Failed to cache audio:', url, err);
}
}
console.log('[Service Worker] Playlist caching complete:', results);
event.ports[0].postMessage({
success: results.audioFiles.length > 0,
metadata: results.metadata,
cached: results.audioFiles.length,
failed: results.failed.length,
details: results
});
} catch (error) {
console.error('[Service Worker] Playlist caching error:', error);
event.ports[0].postMessage({ success: false, error: error.message });
}
})()
);
}
// Remove cached playlist
if (event.data && event.data.type === 'REMOVE_PLAYLIST_CACHE') {
const { playlistId, audioUrls } = event.data;
event.waitUntil(
Promise.all([
// Remove playlist metadata from cache
caches.open(API_CACHE_NAME).then((cache) => {
return cache.delete(`/api/playlist/${playlistId}/`);
}),
// Remove audio files from cache (only if not used by other playlists)
caches.open(AUDIO_CACHE_NAME).then((cache) => {
return Promise.all(
audioUrls.map(url => cache.delete(url))
);
})
]).then(() => {
event.ports[0].postMessage({ success: true });
}).catch((error) => {
event.ports[0].postMessage({ success: false, error: error.message });
})
);
}
});
console.log('[Service Worker] Loaded');

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://soundwave.app/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://soundwave.app/search</loc>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://soundwave.app/library</loc>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://soundwave.app/favorites</loc>
<changefreq>daily</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://soundwave.app/local-files</loc>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://soundwave.app/settings</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
</urlset>

345
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,345 @@
import { useState, useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Box, IconButton, Typography, useMediaQuery, useTheme } from '@mui/material';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import PauseIcon from '@mui/icons-material/Pause';
import LoginPage from './pages/LoginPage';
import HomePage from './pages/HomePage';
import LibraryPage from './pages/LibraryPage';
import SearchPage from './pages/SearchPage';
import FavoritesPage from './pages/FavoritesPage';
import ChannelsPage from './pages/ChannelsPage';
import PlaylistsPage from './pages/PlaylistsPage';
import PlaylistDetailPage from './pages/PlaylistDetailPage';
import SettingsPage from './pages/SettingsPage';
import LocalFilesPage from './pages/LocalFilesPageNew';
import AdminUsersPage from './pages/AdminUsersPage';
import OfflineManagerPage from './pages/OfflineManagerPage';
import AdminRoute from './components/AdminRoute';
import Sidebar from './components/Sidebar';
import TopBar from './components/TopBar';
import Player from './components/Player';
import PWAPrompts from './components/PWAPrompts';
import type { Audio } from './types';
function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [currentAudio, setCurrentAudio] = useState<Audio | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [playerMinimized, setPlayerMinimized] = useState(false);
const [queue, setQueue] = useState<Audio[]>([]);
const [currentQueueIndex, setCurrentQueueIndex] = useState(0);
const theme = useTheme();
const isDesktop = useMediaQuery(theme.breakpoints.up('lg'));
useEffect(() => {
// Check if user is already logged in
const token = localStorage.getItem('token');
if (token) {
setIsAuthenticated(true);
}
}, []);
// Auto-play when new audio is set
useEffect(() => {
if (currentAudio) {
setIsPlaying(true);
}
}, [currentAudio]);
const handleLoginSuccess = () => {
setIsAuthenticated(true);
};
const handleLogout = async () => {
try {
// Call logout endpoint to delete token on server
await fetch('/api/user/logout/', {
method: 'POST',
headers: {
'Authorization': `Token ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
});
} catch (error) {
console.error('Logout error:', error);
} finally {
// Always clear local storage and redirect to login
localStorage.removeItem('token');
setIsAuthenticated(false);
setCurrentAudio(null);
setQueue([]);
setCurrentQueueIndex(0);
}
};
const toggleMobileDrawer = () => {
setMobileDrawerOpen(!mobileDrawerOpen);
};
const playNext = () => {
if (queue.length > 0 && currentQueueIndex < queue.length - 1) {
const nextIndex = currentQueueIndex + 1;
setCurrentQueueIndex(nextIndex);
setCurrentAudio(queue[nextIndex]);
}
};
const playPrevious = () => {
if (queue.length > 0 && currentQueueIndex > 0) {
const prevIndex = currentQueueIndex - 1;
setCurrentQueueIndex(prevIndex);
setCurrentAudio(queue[prevIndex]);
}
};
const setAudioWithQueue = (audio: Audio, audioQueue?: Audio[]) => {
setCurrentAudio(audio);
if (audioQueue && audioQueue.length > 0) {
setQueue(audioQueue);
const index = audioQueue.findIndex(a => a.id === audio.id);
setCurrentQueueIndex(index >= 0 ? index : 0);
} else {
// Single audio, no queue
setQueue([audio]);
setCurrentQueueIndex(0);
}
};
if (!isAuthenticated) {
return <LoginPage onLoginSuccess={handleLoginSuccess} />;
}
return (
<Box sx={{ display: 'flex', height: '100vh', backgroundColor: 'background.default' }}>
{/* Sidebar - Desktop Permanent, Mobile Drawer */}
<Sidebar mobileOpen={mobileDrawerOpen} onMobileClose={() => setMobileDrawerOpen(false)} />
{/* Main Content */}
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'relative' }}>
{/* Top Gradient Fade */}
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '96px',
background: (theme) =>
`linear-gradient(to bottom, ${theme.palette.background.default} 0%, transparent 100%)`,
zIndex: 10,
pointerEvents: 'none',
}}
/>
{/* Top Bar */}
<TopBar onLogout={handleLogout} onMenuClick={toggleMobileDrawer} />
{/* Page Content */}
<Box
sx={{
flex: 1,
overflow: 'auto',
px: 4,
py: 2,
pb: 3,
'&::-webkit-scrollbar': {
display: 'none',
},
msOverflowStyle: 'none',
scrollbarWidth: 'none',
}}
>
<Routes>
<Route path="/" element={<HomePage setCurrentAudio={setCurrentAudio} />} />
<Route path="/search" element={<SearchPage setCurrentAudio={setCurrentAudio} />} />
<Route path="/library" element={<LibraryPage setCurrentAudio={setAudioWithQueue} />} />
<Route path="/favorites" element={<FavoritesPage setCurrentAudio={setCurrentAudio} />} />
<Route path="/channels" element={<ChannelsPage />} />
<Route path="/playlists" element={<PlaylistsPage />} />
<Route path="/playlists/:playlistId" element={<PlaylistDetailPage setCurrentAudio={setAudioWithQueue} />} />
<Route path="/local-files" element={<LocalFilesPage setCurrentAudio={setCurrentAudio} />} />
<Route path="/offline" element={<OfflineManagerPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/admin/users" element={<AdminRoute><AdminUsersPage /></AdminRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Box>
</Box>
{/* Right Side Player - Desktop Only (Like Reference Design) */}
{currentAudio && isDesktop && (
<Box
sx={{
width: 380,
display: 'flex',
flexDirection: 'column',
bgcolor: 'background.paper',
borderLeft: '1px solid',
borderColor: 'rgba(255, 255, 255, 0.05)',
position: 'relative',
overflow: 'hidden',
}}
>
<Player
key={currentAudio.id}
audio={currentAudio}
isPlaying={isPlaying}
setIsPlaying={setIsPlaying}
onClose={() => setCurrentAudio(null)}
onNext={playNext}
onPrevious={playPrevious}
hasNext={currentQueueIndex < queue.length - 1}
hasPrevious={currentQueueIndex > 0}
/>
</Box>
)}
{/* Bottom Player - Mobile/Tablet Only */}
{currentAudio && !isDesktop && (
<>
{/* Backdrop - Click outside to minimize */}
{!playerMinimized && (
<Box
onClick={() => setPlayerMinimized(true)}
sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
bgcolor: 'rgba(0, 0, 0, 0.5)',
zIndex: 999,
backdropFilter: 'blur(4px)',
}}
/>
)}
{/* Full Player - Hidden when minimized but still mounted */}
<Box
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 1000,
maxHeight: '85vh',
overflowY: 'auto',
transform: playerMinimized ? 'translateY(100%)' : 'translateY(0)',
transition: 'transform 0.3s ease-in-out',
visibility: playerMinimized ? 'hidden' : 'visible',
}}
>
<Player
key={currentAudio.id}
audio={currentAudio}
isPlaying={isPlaying}
setIsPlaying={setIsPlaying}
onClose={() => setCurrentAudio(null)}
onNext={playNext}
onPrevious={playPrevious}
hasNext={currentQueueIndex < queue.length - 1}
hasPrevious={currentQueueIndex > 0}
/>
</Box>
{/* Minimized Player Bar */}
{playerMinimized && (
<Box
onClick={() => setPlayerMinimized(false)}
sx={{
display: { xs: 'flex', lg: 'none' },
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: 72,
bgcolor: 'background.paper',
borderTop: '1px solid',
borderColor: 'rgba(255, 255, 255, 0.1)',
alignItems: 'center',
px: 2,
gap: 2,
zIndex: 1000,
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:active': {
bgcolor: 'rgba(255, 255, 255, 0.05)',
},
}}
>
{/* Album Art */}
<Box
component="img"
src={currentAudio.cover_art_url || '/img/icons/icon-192x192.png'}
alt={currentAudio.title}
sx={{
width: 48,
height: 48,
borderRadius: 1,
objectFit: 'cover',
flexShrink: 0,
}}
/>
{/* Track Info */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: 'text.primary',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{currentAudio.title}
</Typography>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}
>
{currentAudio.artist || 'Unknown Artist'}
</Typography>
</Box>
{/* Play/Pause Button */}
<IconButton
onClick={(e) => {
e.stopPropagation();
setIsPlaying(!isPlaying);
}}
sx={{
bgcolor: 'primary.main',
color: 'background.dark',
width: 40,
height: 40,
flexShrink: 0,
'&:hover': {
bgcolor: 'primary.main',
transform: 'scale(1.05)',
},
}}
>
{isPlaying ? <PauseIcon /> : <PlayArrowIcon />}
</IconButton>
</Box>
)}
</>
)}
{/* PWA Prompts */}
<PWAPrompts />
</Box>
);
}
export default App;

View file

@ -0,0 +1,37 @@
import { useState, useEffect, createContext, useContext } from 'react';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import App from './App';
import { ThemeMode, getTheme, getThemePreference, saveThemePreference } from './theme/theme';
interface ThemeContextType {
themeMode: ThemeMode;
setThemeMode: (mode: ThemeMode) => void;
}
const ThemeContext = createContext<ThemeContextType>({
themeMode: 'dark',
setThemeMode: () => {},
});
export const useThemeContext = () => useContext(ThemeContext);
export default function AppWithTheme() {
const [themeMode, setThemeModeState] = useState<ThemeMode>(getThemePreference());
const setThemeMode = (mode: ThemeMode) => {
setThemeModeState(mode);
saveThemePreference(mode);
};
const theme = getTheme(themeMode);
return (
<ThemeContext.Provider value={{ themeMode, setThemeMode }}>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</ThemeContext.Provider>
);
}

121
frontend/src/api/client.ts Normal file
View file

@ -0,0 +1,121 @@
import axios from 'axios';
// Get CSRF token from cookie
function getCookie(name: string): string | null {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
return null;
}
const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
withCredentials: true, // Send cookies with requests
});
// Add auth token and CSRF token to requests
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Token ${token}`;
}
// Add CSRF token for unsafe methods
if (config.method && !['get', 'head', 'options'].includes(config.method.toLowerCase())) {
const csrfToken = getCookie('csrftoken');
if (csrfToken) {
config.headers['X-CSRFToken'] = csrfToken;
}
}
return config;
});
export default api;
// Audio API
export const audioAPI = {
list: (params?: any) => api.get('/audio/list/', { params }),
get: (youtubeId: string) => api.get(`/audio/${youtubeId}/`),
delete: (youtubeId: string) => api.delete(`/audio/${youtubeId}/`),
download: (youtubeId: string) => api.post(`/audio/${youtubeId}/`, { action: 'download' }),
downloadFile: async (youtubeId: string) => {
const token = localStorage.getItem('token');
const response = await fetch(`/api/audio/${youtubeId}/download/`, {
headers: {
'Authorization': `Token ${token}`,
},
});
if (!response.ok) throw new Error('Download failed');
const blob = await response.blob();
return blob;
},
getPlayer: (youtubeId: string) => api.get(`/audio/${youtubeId}/player/`),
updateProgress: (youtubeId: string, data: any) => api.post(`/audio/${youtubeId}/progress/`, data),
// Lyrics endpoints
getLyrics: (youtubeId: string) => api.get(`/audio/${youtubeId}/lyrics/`),
fetchLyrics: (youtubeId: string, force?: boolean) => api.post(`/audio/${youtubeId}/lyrics/fetch/`, { force }),
updateLyrics: (youtubeId: string, data: any) => api.put(`/audio/${youtubeId}/lyrics/`, data),
deleteLyrics: (youtubeId: string) => api.delete(`/audio/${youtubeId}/lyrics/`),
fetchBatchLyrics: (youtubeIds: string[]) => api.post('/audio/lyrics/fetch_batch/', { youtube_ids: youtubeIds }),
fetchAllMissingLyrics: (limit?: number) => api.post('/audio/lyrics/fetch_all_missing/', { limit }),
getLyricsStats: () => api.get('/audio/lyrics/stats/'),
};
// Channel API
export const channelAPI = {
list: () => api.get('/channel/'),
get: (channelId: string) => api.get(`/channel/${channelId}/`),
subscribe: (data: any) => api.post('/channel/', data),
unsubscribe: (channelId: string) => api.delete(`/channel/${channelId}/`),
};
// Playlist API
export const playlistAPI = {
list: () => api.get('/playlist/'),
get: (playlistId: string) => api.get(`/playlist/${playlistId}/`),
getWithItems: (playlistId: string) => api.get(`/playlist/${playlistId}/`, { params: { include_items: 'true' } }),
create: (data: any) => api.post('/playlist/', data),
delete: (playlistId: string) => api.delete(`/playlist/${playlistId}/`),
download: (playlistId: string) => api.post(`/playlist/${playlistId}/`, { action: 'download' }),
};
// Download API
export const downloadAPI = {
list: (filter?: string) => api.get('/download/', { params: { filter } }),
add: (data: any) => api.post('/download/', data),
clear: (filter?: string) => api.delete('/download/', { params: { filter } }),
};
// Stats API
export const statsAPI = {
audio: () => api.get('/stats/audio/'),
channel: () => api.get('/stats/channel/'),
download: () => api.get('/stats/download/'),
};
// User API
export const userAPI = {
login: (data: any) => api.post('/user/login/', data),
logout: () => api.post('/user/logout/'),
account: () => api.get('/user/account/'),
config: () => api.get('/user/config/'),
updateConfig: (data: any) => api.post('/user/config/', data),
// Two-Factor Authentication
twoFactorStatus: () => api.get('/user/2fa/status/'),
twoFactorSetup: () => api.post('/user/2fa/setup/'),
twoFactorVerify: (data: { code: string }) => api.post('/user/2fa/verify/', data),
twoFactorDisable: (data: { code: string }) => api.post('/user/2fa/disable/', data),
twoFactorRegenerateCodes: () => api.post('/user/2fa/regenerate-codes/'),
twoFactorDownloadCodes: () => api.get('/user/2fa/download-codes/', { responseType: 'blob' }),
};
// App Settings API
export const settingsAPI = {
config: () => api.get('/appsettings/config/'),
backup: () => api.get('/appsettings/backup/'),
createBackup: () => api.post('/appsettings/backup/'),
};

View file

@ -0,0 +1,55 @@
import { useEffect, useState } from 'react';
import { Navigate } from 'react-router-dom';
import { Box, CircularProgress } from '@mui/material';
import api from '../api/client';
interface AdminRouteProps {
children: React.ReactNode;
}
/**
* Protected route component that only allows admin users
* Redirects non-admin users to home page
*/
export default function AdminRoute({ children }: AdminRouteProps) {
const [isChecking, setIsChecking] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
useEffect(() => {
const checkAdminStatus = async () => {
try {
const response = await api.get('/user/account/');
const user = response.data;
setIsAdmin(user.is_admin || user.is_superuser || user.is_staff);
} catch (error) {
console.error('Error checking admin status:', error);
setIsAdmin(false);
} finally {
setIsChecking(false);
}
};
checkAdminStatus();
}, []);
if (isChecking) {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
}}
>
<CircularProgress />
</Box>
);
}
if (!isAdmin) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}

View file

@ -0,0 +1,266 @@
import { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Avatar,
Typography,
IconButton,
Grid,
Alert,
CircularProgress,
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import DeleteIcon from '@mui/icons-material/Delete';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
interface AvatarDialogProps {
open: boolean;
onClose: () => void;
currentAvatar: string | null;
onAvatarChange: (avatarUrl: string | null) => void;
}
export default function AvatarDialog({ open, onClose, currentAvatar, onAvatarChange }: AvatarDialogProps) {
const [selectedPreset, setSelectedPreset] = useState<number | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const presets = [1, 2, 3, 4, 5];
const handlePresetSelect = async (preset: number) => {
setError(null);
setSuccess(null);
setSelectedPreset(preset);
try {
const response = await fetch('/api/user/avatar/preset/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Token ${localStorage.getItem('token')}`,
},
body: JSON.stringify({ preset }),
});
if (!response.ok) {
throw new Error('Failed to set preset avatar');
}
const data = await response.json();
setSuccess('Avatar updated successfully!');
onAvatarChange(`/avatars/preset_${preset}.svg`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to set preset avatar');
}
};
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file size (20MB)
if (file.size > 20 * 1024 * 1024) {
setError('File too large. Maximum size is 20MB');
return;
}
// Validate file type
if (!['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(file.type)) {
setError('Invalid file type. Please upload JPEG, PNG, GIF, or WebP');
return;
}
setError(null);
setSuccess(null);
setUploading(true);
setSelectedPreset(null);
const formData = new FormData();
formData.append('avatar', file);
try {
const response = await fetch('/api/user/avatar/upload/', {
method: 'POST',
headers: {
'Authorization': `Token ${localStorage.getItem('token')}`,
},
body: formData,
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to upload avatar');
}
const data = await response.json();
setSuccess('Avatar uploaded successfully!');
// Construct the avatar URL
const filename = data.avatar.split('/').pop();
onAvatarChange(`/api/user/avatar/file/${filename}/`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to upload avatar');
} finally {
setUploading(false);
}
};
const handleRemoveAvatar = async () => {
setError(null);
setSuccess(null);
try {
const response = await fetch('/api/user/avatar/upload/', {
method: 'DELETE',
headers: {
'Authorization': `Token ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error('Failed to remove avatar');
}
setSuccess('Avatar removed successfully!');
setSelectedPreset(null);
onAvatarChange(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove avatar');
}
};
const isCurrentPreset = (preset: number) => {
return currentAvatar?.includes(`preset_${preset}`);
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Typography variant="h6">Choose Your Avatar</Typography>
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>
{success}
</Alert>
)}
{/* Preset Avatars */}
<Typography variant="subtitle2" gutterBottom sx={{ mt: 1 }}>
Preset Avatars
</Typography>
<Grid container spacing={2} sx={{ mb: 3 }}>
{presets.map((preset) => (
<Grid item xs={4} sm={2.4} key={preset}>
<Box
sx={{
position: 'relative',
cursor: 'pointer',
'&:hover': {
opacity: 0.8,
},
}}
onClick={() => handlePresetSelect(preset)}
>
<Avatar
src={`/avatars/preset_${preset}.svg`}
sx={{
width: 80,
height: 80,
border: isCurrentPreset(preset) ? '3px solid' : '2px solid',
borderColor: isCurrentPreset(preset) ? 'primary.main' : 'rgba(255, 255, 255, 0.1)',
}}
/>
{isCurrentPreset(preset) && (
<CheckCircleIcon
sx={{
position: 'absolute',
top: -8,
right: -8,
color: 'primary.main',
bgcolor: 'background.paper',
borderRadius: '50%',
}}
/>
)}
</Box>
</Grid>
))}
</Grid>
{/* Upload Custom Avatar */}
<Typography variant="subtitle2" gutterBottom>
Custom Avatar
</Typography>
<Box
sx={{
border: '2px dashed',
borderColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: 2,
p: 3,
textAlign: 'center',
bgcolor: 'rgba(255, 255, 255, 0.02)',
}}
>
{uploading ? (
<CircularProgress size={40} />
) : (
<>
<input
accept="image/jpeg,image/png,image/gif,image/webp"
style={{ display: 'none' }}
id="avatar-upload"
type="file"
onChange={handleFileUpload}
/>
<label htmlFor="avatar-upload">
<Button
variant="outlined"
component="span"
startIcon={<CloudUploadIcon />}
sx={{ mb: 1 }}
>
Upload Image
</Button>
</label>
<Typography variant="caption" display="block" color="text.secondary">
Max 20MB JPEG, PNG, GIF, WebP
</Typography>
</>
)}
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
{currentAvatar && (
<Button
onClick={handleRemoveAvatar}
startIcon={<DeleteIcon />}
color="error"
sx={{ mr: 'auto' }}
>
Remove Avatar
</Button>
)}
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,362 @@
import { useState, useEffect, useRef } from 'react';
import {
Box,
Card,
CardContent,
Typography,
IconButton,
CircularProgress,
Alert,
Button,
Switch,
FormControlLabel,
Tabs,
Tab,
} from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import EditIcon from '@mui/icons-material/Edit';
import CloseIcon from '@mui/icons-material/Close';
import MusicNoteIcon from '@mui/icons-material/MusicNote';
import api from '../api/client';
interface LyricsData {
audio_id: string;
audio_title: string;
synced_lyrics: string;
plain_lyrics: string;
is_instrumental: boolean;
source: string;
language: string;
has_lyrics: boolean;
is_synced: boolean;
display_lyrics: string;
fetch_attempted: boolean;
fetch_attempts: number;
last_error: string;
}
interface LyricsLine {
time: number;
text: string;
}
interface LyricsPlayerProps {
youtubeId: string;
currentTime: number;
onClose?: () => void;
embedded?: boolean;
}
export default function LyricsPlayer({ youtubeId, currentTime, onClose, embedded = false }: LyricsPlayerProps) {
const [lyrics, setLyrics] = useState<LyricsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [parsedLyrics, setParsedLyrics] = useState<LyricsLine[]>([]);
const [currentLineIndex, setCurrentLineIndex] = useState(-1);
const [autoScroll, setAutoScroll] = useState(true);
const [tabValue, setTabValue] = useState(0);
const lyricsContainerRef = useRef<HTMLDivElement>(null);
const currentLineRef = useRef<HTMLDivElement>(null);
useEffect(() => {
loadLyrics();
}, [youtubeId]);
useEffect(() => {
if (lyrics?.is_synced && parsedLyrics.length > 0) {
updateCurrentLine();
}
}, [currentTime, parsedLyrics]);
useEffect(() => {
if (autoScroll && currentLineRef.current && lyricsContainerRef.current) {
const container = lyricsContainerRef.current;
const line = currentLineRef.current;
const containerHeight = container.clientHeight;
const lineTop = line.offsetTop;
container.scrollTo({
top: lineTop - containerHeight / 2 + line.clientHeight / 2,
behavior: 'smooth',
});
}
}, [currentLineIndex, autoScroll]);
const loadLyrics = async () => {
try {
setLoading(true);
setError('');
const response = await api.get(`/audio/${youtubeId}/lyrics/`);
setLyrics(response.data);
if (response.data.is_synced) {
const parsed = parseSyncedLyrics(response.data.synced_lyrics);
setParsedLyrics(parsed);
}
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to load lyrics');
} finally {
setLoading(false);
}
};
const fetchLyrics = async () => {
try {
setLoading(true);
setError('');
const response = await api.post(`/audio/${youtubeId}/lyrics/fetch/`, {
force: true,
});
setLyrics(response.data);
if (response.data.is_synced) {
const parsed = parseSyncedLyrics(response.data.synced_lyrics);
setParsedLyrics(parsed);
}
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to fetch lyrics');
} finally {
setLoading(false);
}
};
const parseSyncedLyrics = (syncedText: string): LyricsLine[] => {
const lines: LyricsLine[] = [];
const lrcLines = syncedText.split('\n');
for (const line of lrcLines) {
// Match timestamp format [mm:ss.xx]
const match = line.match(/\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)/);
if (match) {
const minutes = parseInt(match[1]);
const seconds = parseInt(match[2]);
const centiseconds = parseInt(match[3].padEnd(2, '0').substring(0, 2));
const time = minutes * 60 + seconds + centiseconds / 100;
const text = match[4].trim();
if (text) {
lines.push({ time, text });
}
}
}
return lines.sort((a, b) => a.time - b.time);
};
const updateCurrentLine = () => {
let index = -1;
for (let i = parsedLyrics.length - 1; i >= 0; i--) {
if (currentTime >= parsedLyrics[i].time) {
index = i;
break;
}
}
setCurrentLineIndex(index);
};
if (loading) {
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{onClose && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', p: 1 }}>
<IconButton size="small" onClick={onClose}>
<CloseIcon />
</IconButton>
</Box>
)}
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<CircularProgress />
</Box>
</Box>
);
}
if (error) {
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{onClose && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', p: 1 }}>
<IconButton size="small" onClick={onClose}>
<CloseIcon />
</IconButton>
</Box>
)}
<Box sx={{ flex: 1, p: 2 }}>
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
<Button variant="contained" onClick={fetchLyrics} startIcon={<RefreshIcon />}>
Try Fetch Lyrics
</Button>
</Box>
</Box>
);
}
if (!lyrics) {
return null;
}
if (lyrics.is_instrumental) {
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{onClose && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', p: 1 }}>
<IconButton size="small" onClick={onClose}>
<CloseIcon />
</IconButton>
</Box>
)}
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', p: 4 }}>
<MusicNoteIcon sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
Instrumental Track
</Typography>
</Box>
</Box>
);
}
if (!lyrics.has_lyrics) {
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{onClose && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', p: 1 }}>
<IconButton size="small" onClick={onClose}>
<CloseIcon />
</IconButton>
</Box>
)}
<Box sx={{ flex: 1, p: 2 }}>
<Alert severity="info" sx={{ mb: 2 }}>
No lyrics available for this track
{lyrics.fetch_attempted && ` (Attempted ${lyrics.fetch_attempts} times)`}
</Alert>
<Button variant="contained" onClick={fetchLyrics} startIcon={<RefreshIcon />}>
Fetch Lyrics
</Button>
</Box>
</Box>
);
}
return (
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', bgcolor: 'background.paper' }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider', display: 'flex', alignItems: 'center', px: 2, pt: 1 }}>
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Lyrics
</Typography>
<IconButton size="small" onClick={fetchLyrics} sx={{ mr: 1 }}>
<RefreshIcon />
</IconButton>
{onClose && (
<IconButton size="small" onClick={onClose}>
<CloseIcon />
</IconButton>
)}
</Box>
{lyrics.is_synced && (
<Box sx={{ px: 2, py: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="caption" color="text.secondary">
Source: {lyrics.source} {lyrics.language && `${lyrics.language}`}
</Typography>
<FormControlLabel
control={
<Switch
size="small"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
/>
}
label={<Typography variant="caption">Auto-scroll</Typography>}
/>
</Box>
)}
{lyrics.is_synced && lyrics.plain_lyrics && (
<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} sx={{ px: 2, minHeight: 40 }}>
<Tab label="Synced" sx={{ minHeight: 40, py: 0.5 }} />
<Tab label="Plain Text" sx={{ minHeight: 40, py: 0.5 }} />
</Tabs>
)}
<CardContent
ref={lyricsContainerRef}
sx={{
flexGrow: 1,
overflow: 'auto',
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
backgroundColor: 'rgba(0,0,0,0.1)',
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: 'rgba(0,0,0,0.3)',
borderRadius: '4px',
},
}}
>
{tabValue === 0 && lyrics.is_synced ? (
// Synced lyrics display
<Box>
{parsedLyrics.map((line, index) => (
<Box
key={index}
ref={index === currentLineIndex ? currentLineRef : null}
sx={{
py: 1.5,
px: 2,
borderRadius: 1,
transition: 'all 0.3s ease',
backgroundColor: index === currentLineIndex ? 'primary.main' : 'transparent',
color: index === currentLineIndex ? 'primary.contrastText' : 'text.primary',
opacity: index === currentLineIndex ? 1 : 0.5,
transform: index === currentLineIndex ? 'scale(1.02)' : 'scale(1)',
fontWeight: index === currentLineIndex ? 600 : 400,
}}
>
<Typography
variant="body1"
sx={{
fontSize: index === currentLineIndex ? '1.1rem' : '1rem',
lineHeight: 1.6,
}}
>
{line.text}
</Typography>
</Box>
))}
</Box>
) : (
// Plain text lyrics display
<Box sx={{ px: 1 }}>
{lyrics.plain_lyrics.split('\n').map((line, index) => (
<Typography
key={index}
variant="body1"
sx={{
py: 0.5,
lineHeight: 1.8,
color: 'text.primary',
}}
>
{line || '\u00A0'}
</Typography>
))}
</Box>
)}
</CardContent>
{lyrics.last_error && (
<Alert severity="warning" sx={{ m: 2, mt: 0 }}>
Last fetch error: {lyrics.last_error}
</Alert>
)}
</Card>
);
}

View file

@ -0,0 +1,230 @@
import React from 'react';
import {
Snackbar,
Alert,
Button,
Box,
IconButton,
Typography,
Chip,
} from '@mui/material';
import {
Close as CloseIcon,
CloudOff as OfflineIcon,
CloudDone as OnlineIcon,
GetApp as InstallIcon,
Update as UpdateIcon,
} from '@mui/icons-material';
import { usePWA } from '../context/PWAContext';
const PWAPrompts: React.FC = () => {
const {
isOnline,
canInstall,
isUpdateAvailable,
showInstallPrompt,
updateApp,
} = usePWA();
const [showOfflineAlert, setShowOfflineAlert] = React.useState(false);
const [showOnlineAlert, setShowOnlineAlert] = React.useState(false);
const [showInstallAlert, setShowInstallAlert] = React.useState(false);
const [showUpdateAlert, setShowUpdateAlert] = React.useState(false);
const [wasOffline, setWasOffline] = React.useState(false);
// Handle online/offline status
React.useEffect(() => {
if (!isOnline) {
setShowOfflineAlert(true);
setWasOffline(true);
} else if (wasOffline) {
setShowOfflineAlert(false);
setShowOnlineAlert(true);
setWasOffline(false);
}
}, [isOnline, wasOffline]);
// Show install prompt after delay
React.useEffect(() => {
if (canInstall) {
const timer = setTimeout(() => {
setShowInstallAlert(true);
}, 3000); // Wait 3 seconds before showing
return () => clearTimeout(timer);
}
}, [canInstall]);
// Show update prompt
React.useEffect(() => {
if (isUpdateAvailable) {
setShowUpdateAlert(true);
}
}, [isUpdateAvailable]);
const handleInstall = async () => {
const installed = await showInstallPrompt();
if (installed) {
setShowInstallAlert(false);
}
};
const handleUpdate = async () => {
await updateApp();
setShowUpdateAlert(false);
};
return (
<>
{/* Offline Alert - Persistent */}
<Snackbar
open={showOfflineAlert}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
sx={{ bottom: { xs: 80, sm: 24 } }}
>
<Alert
severity="warning"
icon={<OfflineIcon />}
sx={{ width: '100%' }}
>
<Typography variant="body2" fontWeight={600}>
You're offline
</Typography>
<Typography variant="caption">
Cached content is still available
</Typography>
</Alert>
</Snackbar>
{/* Back Online Alert */}
<Snackbar
open={showOnlineAlert}
autoHideDuration={4000}
onClose={() => setShowOnlineAlert(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
sx={{ bottom: { xs: 80, sm: 24 } }}
>
<Alert
severity="success"
icon={<OnlineIcon />}
onClose={() => setShowOnlineAlert(false)}
sx={{ width: '100%' }}
>
You're back online!
</Alert>
</Snackbar>
{/* Install App Prompt */}
<Snackbar
open={showInstallAlert}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
sx={{ bottom: { xs: 80, sm: 24 } }}
>
<Alert
severity="info"
icon={<InstallIcon />}
action={
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Button
color="inherit"
size="small"
variant="outlined"
onClick={handleInstall}
sx={{ fontWeight: 600 }}
>
Install
</Button>
<IconButton
size="small"
color="inherit"
onClick={() => setShowInstallAlert(false)}
>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
}
sx={{ width: '100%', alignItems: 'center' }}
>
<Box>
<Typography variant="body2" fontWeight={600} gutterBottom>
Install SoundWave
</Typography>
<Typography variant="caption">
Install the app for faster access and offline playback
</Typography>
</Box>
</Alert>
</Snackbar>
{/* Update Available Prompt */}
<Snackbar
open={showUpdateAlert}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
sx={{ bottom: { xs: 80, sm: 24 } }}
>
<Alert
severity="info"
icon={<UpdateIcon />}
action={
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Button
color="inherit"
size="small"
variant="outlined"
onClick={handleUpdate}
sx={{ fontWeight: 600 }}
>
Update
</Button>
<IconButton
size="small"
color="inherit"
onClick={() => setShowUpdateAlert(false)}
>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
}
sx={{ width: '100%', alignItems: 'center' }}
>
<Box>
<Typography variant="body2" fontWeight={600} gutterBottom>
Update Available
</Typography>
<Typography variant="caption">
A new version of SoundWave is ready to install
</Typography>
</Box>
</Alert>
</Snackbar>
{/* Online Status Indicator (Top Bar) */}
{!isOnline && (
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bgcolor: 'warning.main',
color: 'warning.contrastText',
py: 0.5,
px: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
zIndex: 9999,
}}
>
<OfflineIcon fontSize="small" />
<Typography variant="caption" fontWeight={600}>
Offline Mode
</Typography>
</Box>
)}
</>
);
};
export default PWAPrompts;

View file

@ -0,0 +1,316 @@
import React from 'react';
import {
Box,
Card,
CardContent,
Typography,
Button,
Switch,
FormControlLabel,
Divider,
Alert,
LinearProgress,
IconButton,
Tooltip,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
Chip,
} from '@mui/material';
import {
GetApp as InstallIcon,
Update as UpdateIcon,
DeleteSweep as ClearCacheIcon,
Notifications as NotificationsIcon,
CloudDownload as CloudDownloadIcon,
CloudOff as OfflineIcon,
CloudDone as OnlineIcon,
Info as InfoIcon,
} from '@mui/icons-material';
import { usePWA } from '../context/PWAContext';
const PWASettingsCard: React.FC = () => {
const {
isOnline,
canInstall,
isInstalled,
isUpdateAvailable,
cacheSize,
showInstallPrompt,
updateApp,
clearCache,
requestNotifications,
} = usePWA();
const [notificationsEnabled, setNotificationsEnabled] = React.useState(
'Notification' in window && Notification.permission === 'granted'
);
const [clearing, setClearing] = React.useState(false);
const [alert, setAlert] = React.useState<{ message: string; severity: 'success' | 'error' | 'info' } | null>(null);
const handleInstall = async () => {
const installed = await showInstallPrompt();
if (installed) {
setAlert({ message: 'App installed successfully!', severity: 'success' });
} else {
setAlert({ message: 'Installation cancelled or not available', severity: 'info' });
}
};
const handleUpdate = async () => {
await updateApp();
};
const handleClearCache = async () => {
setClearing(true);
try {
const success = await clearCache();
if (success) {
setAlert({ message: 'Cache cleared successfully', severity: 'success' });
} else {
setAlert({ message: 'Failed to clear cache', severity: 'error' });
}
} catch (error) {
setAlert({ message: 'Error clearing cache', severity: 'error' });
} finally {
setClearing(false);
}
};
const handleNotifications = async () => {
if (!notificationsEnabled) {
const permission = await requestNotifications();
setNotificationsEnabled(permission === 'granted');
if (permission === 'granted') {
setAlert({ message: 'Notifications enabled', severity: 'success' });
} else {
setAlert({ message: 'Notifications permission denied', severity: 'error' });
}
}
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
const cacheUsagePercent = cacheSize
? Math.round((cacheSize.usage / cacheSize.quota) * 100)
: 0;
return (
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<CloudDownloadIcon color="primary" sx={{ fontSize: '1.25rem' }} />
<Typography variant="subtitle1" fontWeight={600}>Progressive Web App (PWA)</Typography>
</Box>
{alert && (
<Alert severity={alert.severity} onClose={() => setAlert(null)} sx={{ mb: 1.5 }}>
{alert.message}
</Alert>
)}
{/* Online Status */}
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.75 }}>
<Typography variant="subtitle2" color="textSecondary">
Connection Status
</Typography>
<Chip
icon={isOnline ? <OnlineIcon /> : <OfflineIcon />}
label={isOnline ? 'Online' : 'Offline'}
color={isOnline ? 'success' : 'warning'}
size="small"
/>
</Box>
{!isOnline && (
<Alert severity="info" icon={<InfoIcon />} sx={{ mt: 1 }}>
You're working offline. Cached content is still available.
</Alert>
)}
</Box>
<Divider sx={{ my: 2 }} />
{/* Installation Status */}
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="textSecondary" gutterBottom display="block" fontWeight={600}>
Installation
</Typography>
{isInstalled ? (
<Alert severity="success" icon={<InstallIcon />}>
App is installed and ready to use
</Alert>
) : canInstall ? (
<Box>
<Typography variant="caption" color="textSecondary" sx={{ mb: 0.75, display: 'block' }}>
Install SoundWave for:
</Typography>
<List dense sx={{ py: 0 }}>
<ListItem sx={{ py: 0.25 }}>
<ListItemText primary={<Typography variant="caption"> Faster startup and better performance</Typography>} />
</ListItem>
<ListItem sx={{ py: 0.25 }}>
<ListItemText primary={<Typography variant="caption"> Offline access to cached content</Typography>} />
</ListItem>
<ListItem sx={{ py: 0.25 }}>
<ListItemText primary={<Typography variant="caption"> Native app-like experience</Typography>} />
</ListItem>
<ListItem sx={{ py: 0.25 }}>
<ListItemText primary={<Typography variant="caption"> Desktop shortcut access</Typography>} />
</ListItem>
</List>
<Button
variant="contained"
size="small"
startIcon={<InstallIcon />}
onClick={handleInstall}
fullWidth
>
Install App
</Button>
</Box>
) : (
<Alert severity="info">
Installation not available. You may already be using the installed app or your browser doesn't support PWA installation.
</Alert>
)}
</Box>
<Divider sx={{ my: 2 }} />
{/* Update Status */}
{isUpdateAvailable && (
<>
<Box sx={{ mb: 3 }}>
<Alert
severity="info"
icon={<UpdateIcon />}
action={
<Button color="inherit" size="small" onClick={handleUpdate}>
Update
</Button>
}
>
New version available! Update now to get the latest features.
</Alert>
</Box>
<Divider sx={{ my: 2 }} />
</>
)}
{/* Cache Management */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
Cache Storage
</Typography>
{cacheSize && (
<>
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" color="textSecondary">
{formatBytes(cacheSize.usage)} / {formatBytes(cacheSize.quota)}
</Typography>
<Typography variant="body2" color="textSecondary">
{cacheUsagePercent}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={Math.min(cacheUsagePercent, 100)}
sx={{ height: 8, borderRadius: 1 }}
/>
</Box>
<Typography variant="caption" color="textSecondary" display="block" sx={{ mb: 2 }}>
Cached data allows offline access to previously viewed content and improves loading times.
</Typography>
</>
)}
<Button
variant="outlined"
startIcon={<ClearCacheIcon />}
onClick={handleClearCache}
disabled={clearing}
fullWidth
>
{clearing ? 'Clearing Cache...' : 'Clear Cache'}
</Button>
</Box>
<Divider sx={{ my: 2 }} />
{/* Notifications */}
<Box>
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
Notifications
</Typography>
<FormControlLabel
control={
<Switch
checked={notificationsEnabled}
onChange={handleNotifications}
disabled={notificationsEnabled}
/>
}
label={
<Box>
<Typography variant="body2">
Enable push notifications
</Typography>
<Typography variant="caption" color="textSecondary">
Get notified about downloads, updates, and more
</Typography>
</Box>
}
/>
</Box>
<Divider sx={{ my: 2 }} />
{/* PWA Features Info */}
<Box>
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
PWA Features
</Typography>
<List dense>
<ListItem>
<ListItemText
primary="Offline Mode"
secondary="Access cached content without internet"
/>
<ListItemSecondaryAction>
<Chip label="Active" color="success" size="small" />
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemText
primary="Background Sync"
secondary="Sync data when connection is restored"
/>
<ListItemSecondaryAction>
<Chip label="Active" color="success" size="small" />
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemText
primary="Audio Caching"
secondary="Cache audio files for offline playback"
/>
<ListItemSecondaryAction>
<Chip label="Active" color="success" size="small" />
</ListItemSecondaryAction>
</ListItem>
</List>
</Box>
</CardContent>
</Card>
);
};
export default PWASettingsCard;

View file

@ -0,0 +1,607 @@
import { Box, IconButton, Slider, Typography, LinearProgress, Fade } from '@mui/material';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import PauseIcon from '@mui/icons-material/Pause';
import SkipPreviousIcon from '@mui/icons-material/SkipPrevious';
import SkipNextIcon from '@mui/icons-material/SkipNext';
import ShuffleIcon from '@mui/icons-material/Shuffle';
import RepeatIcon from '@mui/icons-material/Repeat';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import VolumeOffIcon from '@mui/icons-material/VolumeOff';
import { useState, useRef, useEffect } from 'react';
import type { Audio } from '../types';
import LyricsPlayer from './LyricsPlayer';
import {
setMediaMetadata,
setMediaActionHandlers,
setPlaybackState,
setPositionState,
clearMediaSession,
} from '../utils/mediaSession';
interface PlayerProps {
audio: Audio;
isPlaying: boolean;
setIsPlaying: (playing: boolean) => void;
onClose?: () => void;
onNext?: () => void;
onPrevious?: () => void;
hasNext?: boolean;
hasPrevious?: boolean;
}
export default function Player({ audio, isPlaying, setIsPlaying, onClose, onNext, onPrevious, hasNext = false, hasPrevious = false }: PlayerProps) {
const [currentTime, setCurrentTime] = useState(0);
const [volume, setVolume] = useState(80);
const [isMuted, setIsMuted] = useState(false);
const [showLyrics, setShowLyrics] = useState(false);
const [streamUrl, setStreamUrl] = useState<string>('');
const [loadingStream, setLoadingStream] = useState(true);
const audioRef = useRef<HTMLAudioElement>(null);
const isPlayingRef = useRef(isPlaying);
const isSeeking = useRef(false);
const currentAudioId = useRef(audio.id);
// Reset stream when audio changes
if (currentAudioId.current !== audio.id) {
currentAudioId.current = audio.id;
setStreamUrl('');
setLoadingStream(true);
}
// Fetch stream URL when audio changes
useEffect(() => {
const fetchStreamUrl = async () => {
if (audio.media_url) {
setStreamUrl(audio.media_url);
setLoadingStream(false);
return;
}
if (audio.youtube_id) {
try {
setLoadingStream(true);
const response = await fetch(`/api/audio/${audio.youtube_id}/player/`, {
headers: {
'Authorization': `Token ${localStorage.getItem('token')}`,
},
});
const data = await response.json();
setStreamUrl(data.stream_url);
setLoadingStream(false);
} catch (error) {
console.error('Failed to fetch stream URL:', error);
setLoadingStream(false);
}
}
};
fetchStreamUrl();
// Use audio.id as single dependency to prevent double fetching
}, [audio.id]);
// Initialize Media Session API
useEffect(() => {
// Set metadata
setMediaMetadata({
title: audio.title,
artist: audio.artist || 'Unknown Artist',
album: audio.album,
artwork: audio.cover_art_url
? [
{ src: audio.cover_art_url, sizes: '96x96', type: 'image/png' },
{ src: audio.cover_art_url, sizes: '128x128', type: 'image/png' },
{ src: audio.cover_art_url, sizes: '192x192', type: 'image/png' },
{ src: audio.cover_art_url, sizes: '256x256', type: 'image/png' },
{ src: audio.cover_art_url, sizes: '384x384', type: 'image/png' },
{ src: audio.cover_art_url, sizes: '512x512', type: 'image/png' },
]
: undefined,
});
// Set action handlers
setMediaActionHandlers({
play: () => {
if (!isPlayingRef.current) {
setIsPlaying(true);
}
},
pause: () => {
if (isPlayingRef.current) {
setIsPlaying(false);
}
},
previoustrack: () => {
if (hasPrevious && onPrevious) {
onPrevious();
}
},
nexttrack: () => {
if (hasNext && onNext) {
onNext();
}
},
seekbackward: () => {
if (audioRef.current) {
isSeeking.current = true;
audioRef.current.currentTime = Math.max(0, audioRef.current.currentTime - 10);
setTimeout(() => {
isSeeking.current = false;
}, 100);
}
},
seekforward: () => {
if (audioRef.current) {
isSeeking.current = true;
audioRef.current.currentTime = Math.min(
audio.duration,
audioRef.current.currentTime + 10
);
setTimeout(() => {
isSeeking.current = false;
}, 100);
}
},
seekto: (details) => {
if (audioRef.current && details.seekTime !== undefined) {
isSeeking.current = true;
audioRef.current.currentTime = details.seekTime;
setTimeout(() => {
isSeeking.current = false;
}, 100);
}
},
});
// Cleanup on unmount
return () => {
clearMediaSession();
};
}, [audio, hasNext, hasPrevious, onNext, onPrevious, setIsPlaying]);
useEffect(() => {
if (audioRef.current) {
audioRef.current.volume = volume / 100;
}
}, [volume]);
// Handle audio source changes
useEffect(() => {
if (audioRef.current && streamUrl) {
// Pause current playback before loading new source
audioRef.current.pause();
// Reset current time when audio changes
setCurrentTime(0);
// Load new audio source
audioRef.current.load();
// Reset playing ref
isPlayingRef.current = false;
}
}, [streamUrl]);
// Handle play/pause state
useEffect(() => {
// Skip if we're currently seeking or loading
if (isSeeking.current || loadingStream) {
return;
}
if (audioRef.current && streamUrl) {
// Check if the playing state actually changed
if (isPlaying !== isPlayingRef.current) {
isPlayingRef.current = isPlaying;
if (isPlaying) {
// Only play if audio is not already playing
if (audioRef.current.paused) {
const playPromise = audioRef.current.play();
if (playPromise !== undefined) {
playPromise
.then(() => {
setPlaybackState('playing');
})
.catch(err => {
console.error('Playback failed:', err);
setIsPlaying(false);
isPlayingRef.current = false;
});
}
}
} else {
// Only pause if audio is actually playing
if (!audioRef.current.paused) {
audioRef.current.pause();
setPlaybackState('paused');
}
}
}
}
}, [isPlaying, setIsPlaying, loadingStream, streamUrl]);
const handleTimeUpdate = () => {
// Don't update time display while user is seeking
if (audioRef.current && !isSeeking.current) {
setCurrentTime(audioRef.current.currentTime);
// Update Media Session position state
setPositionState({
duration: audio.duration,
playbackRate: audioRef.current.playbackRate,
position: audioRef.current.currentTime,
});
}
};
const handleSeekChange = (_: Event, value: number | number[]) => {
// Block time updates during drag
isSeeking.current = true;
// Update visual position while dragging (no actual seek yet)
const time = value as number;
setCurrentTime(time);
};
const handleSeekCommitted = (_: Event | React.SyntheticEvent, value: number | number[]) => {
// Actually seek when user releases slider
const time = value as number;
if (audioRef.current && !loadingStream) {
// Ensure flag is still set
isSeeking.current = true;
// Pause before seeking to prevent race conditions
const wasPlaying = !audioRef.current.paused;
audioRef.current.pause();
// Only seek if audio is ready (has duration)
if (audioRef.current.readyState >= 2) { // HAVE_CURRENT_DATA or better
audioRef.current.currentTime = time;
// Resume playback after seek completes
if (wasPlaying) {
// The 'seeked' event will resume playback
audioRef.current.play();
}
// Update Media Session position
setPositionState({
duration: audio.duration,
playbackRate: audioRef.current.playbackRate,
position: time,
});
} else {
// If not ready, wait for it and then seek
const handleCanPlay = () => {
if (audioRef.current) {
audioRef.current.currentTime = time;
if (wasPlaying) {
audioRef.current.play();
}
audioRef.current.removeEventListener('canplay', handleCanPlay);
}
};
audioRef.current.addEventListener('canplay', handleCanPlay);
}
}
};
const handleSeeked = () => {
// Called when the audio element completes seeking - now it's safe to update from time events
isSeeking.current = false;
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const toggleMute = () => {
setIsMuted(!isMuted);
if (audioRef.current) {
audioRef.current.muted = !isMuted;
}
};
return (
<Box
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Background Blur Image */}
<Box
sx={{
position: 'absolute',
inset: 0,
zIndex: 0,
}}
>
<Box
sx={{
position: 'absolute',
inset: 0,
background: (theme) =>
`linear-gradient(to bottom, ${theme.palette.background.paper} 0%, ${theme.palette.background.paper}e6 50%, ${theme.palette.background.default} 100%)`,
zIndex: 10,
}}
/>
<Box
sx={{
width: '100%',
height: '100%',
backgroundImage: audio.cover_art_url || audio.thumbnail_url
? `url(${audio.cover_art_url || audio.thumbnail_url})`
: 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
filter: 'blur(60px)',
opacity: 0.3,
transform: 'scale(1.5)',
}}
/>
</Box>
{/* Content */}
<Box
sx={{
position: 'relative',
zIndex: 20,
display: 'flex',
flexDirection: 'column',
height: '100%',
p: 3,
justifyContent: 'space-between',
}}
>
{streamUrl ? (
<audio
ref={audioRef}
src={streamUrl}
onTimeUpdate={handleTimeUpdate}
onSeeked={handleSeeked}
onEnded={() => {
if (hasNext && onNext) {
onNext();
} else {
setIsPlaying(false);
}
}}
onError={(e) => {
console.error('Audio playback error:', e);
console.error('Stream URL:', streamUrl);
console.error('Audio element:', audioRef.current);
}}
/>
) : (
<audio ref={audioRef} />
)}
{/* Top: Visualizer */}
<Box sx={{ height: 96, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', gap: 0.75, width: '100%' }}>
{[40, 60, 100, 100, 80, 50, 70, 100, 100, 60, 40, 20].map((height, i) => (
<Box
key={i}
sx={{
width: 6,
bgcolor: i % 3 === 0 ? 'primary.main' : i % 2 === 0 ? 'rgba(19, 236, 106, 0.6)' : 'rgba(19, 236, 106, 0.4)',
borderRadius: '9999px',
animation: isPlaying ? 'visualizer-bounce 1.2s infinite ease-in-out' : 'none',
animationDelay: `${i * 0.1}s`,
height: isPlaying ? undefined : '20%',
transition: 'height 0.3s ease',
'@keyframes visualizer-bounce': {
'0%, 100%': { height: '20%' },
'50%': { height: `${height}%` },
},
}}
/>
))}
</Box>
{/* Middle: Album Art & Song Info */}
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 4 }}>
{(audio.cover_art_url || audio.thumbnail_url) && (
<Box
onClick={() => audio.youtube_id && setShowLyrics(!showLyrics)}
sx={{
width: 200,
height: 200,
borderRadius: 3,
backgroundImage: `url(${audio.cover_art_url || audio.thumbnail_url})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
mb: 3,
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4)',
cursor: audio.youtube_id ? 'pointer' : 'default',
transition: 'transform 0.2s ease',
'&:hover': audio.youtube_id ? {
transform: 'scale(1.05)',
} : {},
}}
title={audio.youtube_id ? 'Click to toggle lyrics' : 'Lyrics not available for local files'}
/>
)}
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5, textAlign: 'center', px: 2 }}>
{audio.title}
</Typography>
<Typography variant="body1" color="primary.main" sx={{ fontWeight: 500 }}>
{audio.channel_name}
</Typography>
</Box>
{/* Bottom: Player Controls */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Progress Bar */}
<Box>
<Slider
value={currentTime}
max={audio.duration}
onChange={handleSeekChange}
onChangeCommitted={handleSeekCommitted}
sx={{
color: 'primary.main',
height: 6,
padding: '13px 0',
'& .MuiSlider-thumb': {
width: 12,
height: 12,
backgroundColor: '#fff',
boxShadow: '0 0 10px rgba(255, 255, 255, 0.5)',
'&:hover, &.Mui-focusVisible': {
boxShadow: '0 0 0 8px rgba(19, 236, 106, 0.16)',
},
},
'& .MuiSlider-track': {
border: 'none',
height: 6,
},
'& .MuiSlider-rail': {
opacity: 0.3,
backgroundColor: '#fff',
height: 6,
},
}}
/>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1 }}>
<Typography variant="caption" sx={{ color: 'rgba(255, 255, 255, 0.4)', fontWeight: 500, fontSize: '0.75rem' }}>
{formatTime(currentTime)}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255, 255, 255, 0.4)', fontWeight: 500, fontSize: '0.75rem' }}>
{formatTime(audio.duration)}
</Typography>
</Box>
</Box>
{/* Buttons */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 1 }}>
<IconButton
size="small"
sx={{
color: 'rgba(255, 255, 255, 0.4)',
'&:hover': { color: 'white' },
}}
>
<ShuffleIcon />
</IconButton>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<IconButton
onClick={onPrevious}
disabled={!hasPrevious}
sx={{
color: 'white',
'&:hover': { color: 'primary.main' },
'&:disabled': { color: 'rgba(255, 255, 255, 0.2)' },
}}
>
<SkipPreviousIcon sx={{ fontSize: 30 }} />
</IconButton>
<IconButton
onClick={() => setIsPlaying(!isPlaying)}
sx={{
width: 64,
height: 64,
bgcolor: 'primary.main',
color: 'background.dark',
boxShadow: '0 0 20px rgba(19, 236, 106, 0.4)',
'&:hover': {
bgcolor: 'primary.main',
transform: 'scale(1.05)',
},
transition: 'all 0.3s ease',
}}
>
{isPlaying ? <PauseIcon sx={{ fontSize: 36 }} /> : <PlayArrowIcon sx={{ fontSize: 36 }} />}
</IconButton>
<IconButton
onClick={onNext}
disabled={!hasNext}
sx={{
color: 'white',
'&:hover': { color: 'primary.main' },
'&:disabled': { color: 'rgba(255, 255, 255, 0.2)' },
}}
>
<SkipNextIcon sx={{ fontSize: 30 }} />
</IconButton>
</Box>
<IconButton
size="small"
sx={{
color: 'rgba(255, 255, 255, 0.4)',
'&:hover': { color: 'white' },
}}
>
<RepeatIcon />
</IconButton>
</Box>
{/* Volume Mini */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1.5, mt: 1 }}>
<IconButton onClick={() => setIsMuted(!isMuted)} size="small" sx={{ color: 'rgba(255, 255, 255, 0.3)' }}>
<VolumeUpIcon sx={{ fontSize: 14 }} />
</IconButton>
<Box sx={{ width: 96 }}>
<Slider
value={isMuted ? 0 : volume}
onChange={(_, value) => {
const vol = value as number;
setVolume(vol);
if (vol > 0) setIsMuted(false);
}}
sx={{
color: 'rgba(255, 255, 255, 0.5)',
height: 4,
padding: '8px 0',
'& .MuiSlider-thumb': {
width: 8,
height: 8,
backgroundColor: '#fff',
},
'& .MuiSlider-track': {
border: 'none',
height: 4,
},
'& .MuiSlider-rail': {
opacity: 0.3,
backgroundColor: '#fff',
height: 4,
},
}}
/>
</Box>
<IconButton onClick={() => setIsMuted(!isMuted)} size="small" sx={{ color: 'rgba(255, 255, 255, 0.3)' }}>
<VolumeOffIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
</Box>
</Box>
{/* Lyrics Overlay */}
{showLyrics && audio.youtube_id && (
<Fade in={showLyrics}>
<Box
sx={{
position: 'absolute',
inset: 0,
bgcolor: 'rgba(0, 0, 0, 0.95)',
backdropFilter: 'blur(10px)',
zIndex: 20,
overflow: 'auto',
}}
>
<LyricsPlayer
youtubeId={audio.youtube_id}
currentTime={currentTime}
onClose={() => setShowLyrics(false)}
embedded={true}
/>
</Box>
</Fade>
)}
</Box>
);
}

View file

@ -0,0 +1,408 @@
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
List,
ListItem,
ListItemText,
Button,
LinearProgress,
Chip,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
FormControl,
InputLabel,
Select,
MenuItem,
Alert,
Divider,
Collapse,
} from '@mui/material';
import {
Download as DownloadIcon,
Pause as PauseIcon,
PlayArrow as ResumeIcon,
Cancel as CancelIcon,
Refresh as RetryIcon,
ExpandMore as ExpandMoreIcon,
CheckCircle as CheckCircleIcon,
Error as ErrorIcon,
CloudDownload as CloudDownloadIcon,
} from '@mui/icons-material';
import api from '../api/client';
interface PlaylistDownload {
id: number;
playlist: number;
playlist_data: {
id: number;
title: string;
thumbnail_url: string;
};
status: 'pending' | 'downloading' | 'completed' | 'failed' | 'paused';
total_items: number;
downloaded_items: number;
failed_items: number;
progress_percent: number;
quality: string;
created_at: string;
started_at: string;
completed_at: string;
error_message: string;
is_complete: boolean;
can_resume: boolean;
}
interface PlaylistDownloadProps {
playlistId?: number;
}
const PlaylistDownloadManager = ({ playlistId }: PlaylistDownloadProps) => {
const [downloads, setDownloads] = useState<PlaylistDownload[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
const [selectedQuality, setSelectedQuality] = useState('medium');
const [expandedItems, setExpandedItems] = useState<{ [key: number]: boolean }>({});
useEffect(() => {
loadDownloads();
// Refresh every 5 seconds for active downloads
const interval = setInterval(() => {
loadDownloads(true);
}, 5000);
return () => clearInterval(interval);
}, [playlistId]);
const loadDownloads = async (silent = false) => {
try {
if (!silent) setLoading(true);
let url = '/playlist/downloads/';
if (playlistId) {
url += `?playlist_id=${playlistId}`;
}
const response = await api.get(url);
setDownloads(response.data);
setError('');
} catch (err: any) {
if (!silent) {
setError(err.response?.data?.detail || 'Failed to load downloads');
}
} finally {
if (!silent) setLoading(false);
}
};
const handleStartDownload = async () => {
if (!playlistId) return;
try {
const response = await api.post('/playlist/downloads/download_playlist/', {
playlist_id: playlistId,
quality: selectedQuality,
});
setSuccess('Playlist download started');
setDownloadDialogOpen(false);
loadDownloads();
setTimeout(() => setSuccess(''), 3000);
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to start download');
}
};
const handlePause = async (downloadId: number) => {
try {
await api.post(`/playlist/downloads/${downloadId}/pause/`);
setSuccess('Download paused');
loadDownloads();
setTimeout(() => setSuccess(''), 3000);
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to pause download');
}
};
const handleResume = async (downloadId: number) => {
try {
await api.post(`/playlist/downloads/${downloadId}/resume/`);
setSuccess('Download resumed');
loadDownloads();
setTimeout(() => setSuccess(''), 3000);
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to resume download');
}
};
const handleCancel = async (downloadId: number) => {
try {
await api.post(`/playlist/downloads/${downloadId}/cancel/`);
setSuccess('Download cancelled');
loadDownloads();
setTimeout(() => setSuccess(''), 3000);
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to cancel download');
}
};
const handleRetryFailed = async (downloadId: number) => {
try {
await api.post(`/playlist/downloads/${downloadId}/retry_failed/`);
setSuccess('Retrying failed items');
loadDownloads();
setTimeout(() => setSuccess(''), 3000);
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to retry');
}
};
const toggleExpand = (downloadId: number) => {
setExpandedItems({
...expandedItems,
[downloadId]: !expandedItems[downloadId],
});
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'success';
case 'downloading':
return 'primary';
case 'failed':
return 'error';
case 'paused':
return 'warning';
default:
return 'default';
}
};
const getStatusIcon = (status: string): React.ReactElement | undefined => {
switch (status) {
case 'completed':
return <CheckCircleIcon />;
case 'failed':
return <ErrorIcon />;
case 'downloading':
return <CloudDownloadIcon />;
default:
return undefined;
}
};
return (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
<Typography variant="h6">Offline Downloads</Typography>
{playlistId && (
<Button
variant="contained"
startIcon={<DownloadIcon />}
onClick={() => setDownloadDialogOpen(true)}
>
Download Playlist
</Button>
)}
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess('')}>
{success}
</Alert>
)}
{loading ? (
<Box display="flex" justifyContent="center" p={4}>
<LinearProgress sx={{ width: '100%' }} />
</Box>
) : downloads.length === 0 ? (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<CloudDownloadIcon sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
No downloads yet
</Typography>
<Typography variant="body2" color="text.secondary">
Download playlists for offline playback
</Typography>
</Paper>
) : (
<List>
{downloads.map((download) => (
<Paper key={download.id} sx={{ mb: 2 }}>
<ListItem>
<ListItemText
primary={
<Box display="flex" alignItems="center" gap={1}>
<Typography variant="subtitle1" fontWeight="bold">
{download.playlist_data.title}
</Typography>
<Chip
label={download.status.toUpperCase()}
color={getStatusColor(download.status)}
size="small"
icon={getStatusIcon(download.status)}
/>
<Chip
label={download.quality.toUpperCase()}
size="small"
variant="outlined"
/>
</Box>
}
secondary={
<Box mt={1}>
<Box display="flex" justifyContent="space-between" mb={0.5}>
<Typography variant="body2">
{download.downloaded_items} / {download.total_items} items
{download.failed_items > 0 && (
<span style={{ color: '#f44336' }}>
{' '}
({download.failed_items} failed)
</span>
)}
</Typography>
<Typography variant="body2">
{download.progress_percent.toFixed(1)}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={download.progress_percent}
color={download.status === 'failed' ? 'error' : 'primary'}
/>
{download.error_message && (
<Typography variant="caption" color="error" sx={{ mt: 1, display: 'block' }}>
Error: {download.error_message}
</Typography>
)}
</Box>
}
/>
<Box display="flex" gap={1}>
{download.status === 'downloading' && (
<IconButton onClick={() => handlePause(download.id)} color="warning">
<PauseIcon />
</IconButton>
)}
{download.can_resume && (
<IconButton onClick={() => handleResume(download.id)} color="primary">
<ResumeIcon />
</IconButton>
)}
{download.failed_items > 0 && (
<IconButton onClick={() => handleRetryFailed(download.id)} color="primary">
<RetryIcon />
</IconButton>
)}
{!download.is_complete && download.status !== 'failed' && (
<IconButton onClick={() => handleCancel(download.id)} color="error">
<CancelIcon />
</IconButton>
)}
<IconButton onClick={() => toggleExpand(download.id)}>
<ExpandMoreIcon
sx={{
transform: expandedItems[download.id] ? 'rotate(180deg)' : 'rotate(0deg)',
transition: '0.3s',
}}
/>
</IconButton>
</Box>
</ListItem>
<Collapse in={expandedItems[download.id]}>
<Divider />
<Box p={2}>
<Typography variant="body2" color="text.secondary">
Started: {download.started_at ? new Date(download.started_at).toLocaleString() : 'Not started'}
</Typography>
{download.completed_at && (
<Typography variant="body2" color="text.secondary">
Completed: {new Date(download.completed_at).toLocaleString()}
</Typography>
)}
<Typography variant="body2" color="text.secondary">
Quality: {download.quality}
</Typography>
</Box>
</Collapse>
</Paper>
))}
</List>
)}
{/* Download Dialog */}
<Dialog open={downloadDialogOpen} onClose={() => setDownloadDialogOpen(false)}>
<DialogTitle>Download Playlist for Offline Playback</DialogTitle>
<DialogContent>
<Box sx={{ pt: 2, minWidth: 300 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Select the quality for downloaded audio files. Higher quality requires more storage.
</Typography>
<FormControl fullWidth>
<InputLabel>Quality</InputLabel>
<Select
value={selectedQuality}
onChange={(e) => setSelectedQuality(e.target.value)}
label="Quality"
>
<MenuItem value="low">
<Box>
<Typography>Low (64 kbps)</Typography>
<Typography variant="caption" color="text.secondary">
Saves storage, lower quality
</Typography>
</Box>
</MenuItem>
<MenuItem value="medium">
<Box>
<Typography>Medium (128 kbps)</Typography>
<Typography variant="caption" color="text.secondary">
Balanced quality and size
</Typography>
</Box>
</MenuItem>
<MenuItem value="high">
<Box>
<Typography>High (256 kbps)</Typography>
<Typography variant="caption" color="text.secondary">
High quality, larger files
</Typography>
</Box>
</MenuItem>
<MenuItem value="ultra">
<Box>
<Typography>Ultra (320 kbps)</Typography>
<Typography variant="caption" color="text.secondary">
Maximum quality, largest files
</Typography>
</Box>
</MenuItem>
</Select>
</FormControl>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setDownloadDialogOpen(false)}>Cancel</Button>
<Button onClick={handleStartDownload} variant="contained" startIcon={<DownloadIcon />}>
Start Download
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default PlaylistDownloadManager;

View file

@ -0,0 +1,369 @@
import { useState } from 'react';
import {
Box,
Typography,
Paper,
FormControl,
InputLabel,
Select,
MenuItem,
FormControlLabel,
Switch,
Button,
CircularProgress,
Alert,
Chip,
Grid,
LinearProgress,
Tooltip,
IconButton,
} from '@mui/material';
import {
Speed as SpeedIcon,
Memory as MemoryIcon,
Storage as StorageIcon,
Refresh as RefreshIcon,
Info as InfoIcon,
CheckCircle as CheckCircleIcon,
Warning as WarningIcon,
Error as ErrorIcon,
} from '@mui/icons-material';
import { useQuickSync } from '../context/QuickSyncContext';
const QuickSyncSettings = () => {
const { status, preferences, loading, updatePreferences, runSpeedTest, refreshStatus } = useQuickSync();
const [testing, setTesting] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const handleModeChange = async (mode: string) => {
try {
await updatePreferences({ mode: mode as any });
setMessage({ type: 'success', text: 'Quality mode updated successfully' });
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage({ type: 'error', text: 'Failed to update quality mode' });
}
};
const handlePreferenceChange = async (key: string, value: boolean) => {
try {
await updatePreferences({ [key]: value });
setMessage({ type: 'success', text: 'Preferences updated successfully' });
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage({ type: 'error', text: 'Failed to update preferences' });
}
};
const handleSpeedTest = async () => {
setTesting(true);
try {
await runSpeedTest();
setMessage({ type: 'success', text: 'Speed test completed' });
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage({ type: 'error', text: 'Speed test failed' });
} finally {
setTesting(false);
}
};
const getNetworkStatusIcon = (status: string) => {
switch (status) {
case 'excellent':
return <CheckCircleIcon color="success" />;
case 'good':
return <CheckCircleIcon color="primary" />;
case 'fair':
return <WarningIcon color="warning" />;
case 'poor':
return <ErrorIcon color="error" />;
default:
return <InfoIcon />;
}
};
const getSystemStatusColor = (status: string) => {
switch (status) {
case 'low_load':
return 'success';
case 'moderate_load':
return 'warning';
case 'high_load':
return 'error';
default:
return 'default';
}
};
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<CircularProgress />
</Box>
);
}
return (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="subtitle1" fontWeight={600}>Quick Sync - Adaptive Streaming</Typography>
<IconButton size="small" onClick={refreshStatus} disabled={loading}>
<RefreshIcon />
</IconButton>
</Box>
{message && (
<Alert severity={message.type} sx={{ mb: 2 }} onClose={() => setMessage(null)}>
{message.text}
</Alert>
)}
{/* Current Status */}
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="body2" gutterBottom fontWeight="bold" mb={1.5}>
Current Status
</Typography>
{status && (
<Grid container spacing={2}>
{/* Network Status */}
<Grid item xs={12} md={4}>
<Box display="flex" alignItems="center" mb={1}>
<SpeedIcon sx={{ mr: 0.5, fontSize: '1.1rem' }} />
<Typography variant="caption" fontWeight="bold">
Network Speed
</Typography>
<Box ml="auto">
{getNetworkStatusIcon(status.network.status)}
</Box>
</Box>
<Typography variant="h6" color="primary" mb={0.75}>
{status.network.speed_mbps.toFixed(2)} Mbps
</Typography>
<Chip
label={status.network.status.toUpperCase()}
size="small"
color={
status.network.status === 'excellent' || status.network.status === 'good'
? 'success'
: status.network.status === 'fair'
? 'warning'
: 'error'
}
/>
</Grid>
{/* System Resources */}
<Grid item xs={12} md={4}>
<Box display="flex" alignItems="center" mb={1}>
<MemoryIcon sx={{ mr: 0.5, fontSize: '1.1rem' }} />
<Typography variant="caption" fontWeight="bold">
System Resources
</Typography>
</Box>
<Box mb={1.5}>
<Box display="flex" justifyContent="space-between" mb={0.5}>
<Typography variant="caption">CPU Usage</Typography>
<Typography variant="caption">{status.system.cpu_percent.toFixed(0)}%</Typography>
</Box>
<LinearProgress
variant="determinate"
value={status.system.cpu_percent}
color={status.system.cpu_percent > 80 ? 'error' : 'primary'}
/>
</Box>
<Box>
<Box display="flex" justifyContent="space-between" mb={0.5}>
<Typography variant="caption">Memory Usage</Typography>
<Typography variant="caption">{status.system.memory_percent.toFixed(0)}%</Typography>
</Box>
<LinearProgress
variant="determinate"
value={status.system.memory_percent}
color={status.system.memory_percent > 85 ? 'error' : 'primary'}
/>
</Box>
</Grid>
{/* Quality Status */}
<Grid item xs={12} md={4}>
<Box display="flex" alignItems="center" mb={1}>
<StorageIcon sx={{ mr: 0.5, fontSize: '1.1rem' }} />
<Typography variant="caption" fontWeight="bold">
Active Quality
</Typography>
</Box>
<Typography variant="h6" color="primary" mb={0.75}>
{status.quality.level.toUpperCase()}
</Typography>
<Typography variant="body2" color="text.secondary" mb={1}>
{status.quality.bitrate} kbps
</Typography>
{status.quality.auto_selected && (
<Chip label="Auto-Selected" size="small" color="info" />
)}
</Grid>
</Grid>
)}
<Box mt={3}>
<Button
variant="outlined"
startIcon={testing ? <CircularProgress size={20} /> : <SpeedIcon />}
onClick={handleSpeedTest}
disabled={testing}
fullWidth
>
{testing ? 'Testing Connection...' : 'Run Speed Test'}
</Button>
</Box>
</Paper>
{/* Quality Settings */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle1" gutterBottom fontWeight="bold">
Quality Settings
</Typography>
<FormControl fullWidth sx={{ mb: 3 }}>
<InputLabel>Quality Mode</InputLabel>
<Select
value={preferences?.mode || 'auto'}
onChange={(e) => handleModeChange(e.target.value)}
label="Quality Mode"
>
<MenuItem value="auto">
<Box>
<Typography>Auto (Recommended)</Typography>
<Typography variant="caption" color="text.secondary">
Adapts to your connection and system
</Typography>
</Box>
</MenuItem>
<MenuItem value="ultra">
<Box>
<Typography>Ultra (320 kbps)</Typography>
<Typography variant="caption" color="text.secondary">
Maximum fidelity - requires 5+ Mbps
</Typography>
</Box>
</MenuItem>
<MenuItem value="high">
<Box>
<Typography>High (256 kbps)</Typography>
<Typography variant="caption" color="text.secondary">
Best experience - requires 2+ Mbps
</Typography>
</Box>
</MenuItem>
<MenuItem value="medium">
<Box>
<Typography>Medium (128 kbps)</Typography>
<Typography variant="caption" color="text.secondary">
Balanced - requires 1+ Mbps
</Typography>
</Box>
</MenuItem>
<MenuItem value="low">
<Box>
<Typography>Low (64 kbps)</Typography>
<Typography variant="caption" color="text.secondary">
Saves bandwidth - requires 0.5+ Mbps
</Typography>
</Box>
</MenuItem>
</Select>
</FormControl>
<FormControlLabel
control={
<Switch
checked={preferences?.prefer_quality || false}
onChange={(e) => handlePreferenceChange('prefer_quality', e.target.checked)}
/>
}
label={
<Box display="flex" alignItems="center">
<Typography>Prefer Higher Quality</Typography>
<Tooltip title="When system resources allow, automatically upgrade to higher quality">
<InfoIcon fontSize="small" sx={{ ml: 1, color: 'text.secondary' }} />
</Tooltip>
</Box>
}
/>
<FormControlLabel
control={
<Switch
checked={preferences?.adapt_to_system || false}
onChange={(e) => handlePreferenceChange('adapt_to_system', e.target.checked)}
/>
}
label={
<Box display="flex" alignItems="center">
<Typography>Adapt to System Load</Typography>
<Tooltip title="Automatically adjust quality based on CPU and memory usage">
<InfoIcon fontSize="small" sx={{ ml: 1, color: 'text.secondary' }} />
</Tooltip>
</Box>
}
/>
<FormControlLabel
control={
<Switch
checked={preferences?.auto_download_quality || false}
onChange={(e) => handlePreferenceChange('auto_download_quality', e.target.checked)}
/>
}
label={
<Box display="flex" alignItems="center">
<Typography>Apply to Downloads</Typography>
<Tooltip title="Use Quick Sync quality settings when downloading audio">
<InfoIcon fontSize="small" sx={{ ml: 1, color: 'text.secondary' }} />
</Tooltip>
</Box>
}
/>
</Paper>
{/* Buffer Settings Info */}
{status && (
<Paper sx={{ p: 3 }}>
<Typography variant="subtitle1" gutterBottom fontWeight="bold">
Buffer Settings
</Typography>
<Grid container spacing={2}>
<Grid item xs={6}>
<Typography variant="body2" color="text.secondary">
Buffer Size
</Typography>
<Typography variant="h6">{status.buffer.buffer_size}s</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="body2" color="text.secondary">
Max Buffer
</Typography>
<Typography variant="h6">{status.buffer.max_buffer_size}s</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="body2" color="text.secondary">
Preload
</Typography>
<Typography variant="h6">{status.buffer.preload}</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="body2" color="text.secondary">
Rebuffer Threshold
</Typography>
<Typography variant="h6">{status.buffer.rebuffer_threshold.toFixed(1)}s</Typography>
</Grid>
</Grid>
</Paper>
)}
</Box>
);
};
export default QuickSyncSettings;

View file

@ -0,0 +1,206 @@
import { Box, List, ListItem, ListItemButton, ListItemIcon, ListItemText, Drawer, Chip } from '@mui/material';
import { useNavigate, useLocation } from 'react-router-dom';
import HomeIcon from '@mui/icons-material/Home';
import SearchIcon from '@mui/icons-material/Search';
import LibraryMusicIcon from '@mui/icons-material/LibraryMusic';
import FavoriteIcon from '@mui/icons-material/Favorite';
import SettingsIcon from '@mui/icons-material/Settings';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import YouTubeIcon from '@mui/icons-material/YouTube';
import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay';
import CloudDoneIcon from '@mui/icons-material/CloudDone';
interface SidebarProps {
mobileOpen?: boolean;
onMobileClose?: () => void;
}
const menuItems = [
{ title: 'Home', path: '/', icon: <HomeIcon /> },
{ title: 'Search', path: '/search', icon: <SearchIcon /> },
{ title: 'Library', path: '/library', icon: <LibraryMusicIcon /> },
{ title: 'Favorites', path: '/favorites', icon: <FavoriteIcon /> },
{ title: 'Channels', path: '/channels', icon: <YouTubeIcon /> },
{ title: 'Playlists', path: '/playlists', icon: <PlaylistPlayIcon /> },
{ title: 'Local Files', path: '/local-files', icon: <CloudUploadIcon /> },
{ title: 'Offline', path: '/offline', icon: <CloudDoneIcon />, isPWA: true },
];
export default function Sidebar({ mobileOpen = false, onMobileClose }: SidebarProps) {
const navigate = useNavigate();
const location = useLocation();
const handleNavigate = (path: string) => {
navigate(path);
if (onMobileClose) {
onMobileClose();
}
};
const drawerContent = (
<Box
sx={{
width: 240,
bgcolor: 'background.default',
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
{/* Logo */}
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box
component="img"
src="/img/logo.png"
alt="SoundWave"
sx={{
width: 40,
height: 40,
borderRadius: '50%',
objectFit: 'cover',
}}
/>
<Box sx={{ fontWeight: 700, fontSize: 20, letterSpacing: '-0.02em' }}>SoundWave</Box>
</Box>
{/* Navigation */}
<List sx={{ flexGrow: 1, px: 1.5, py: 2 }}>
{menuItems.map((item) => (
<ListItem key={item.path} disablePadding sx={{ mb: 1 }}>
<ListItemButton
selected={location.pathname === item.path}
onClick={() => handleNavigate(item.path)}
sx={{
borderRadius: '9999px',
py: 1.5,
px: 2,
transition: 'all 0.3s ease',
'&.Mui-selected': {
bgcolor: 'rgba(19, 236, 106, 0.15)',
color: 'primary.main',
'&:hover': {
bgcolor: 'rgba(19, 236, 106, 0.2)',
},
},
'&:not(.Mui-selected)': {
color: 'text.secondary',
'&:hover': {
bgcolor: 'rgba(255, 255, 255, 0.05)',
color: 'text.primary',
},
},
}}
>
<ListItemIcon sx={{ color: 'inherit', minWidth: 36 }}>
{item.icon}
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{item.title}
{(item as any).isPWA && (
<Chip
label="PWA"
size="small"
sx={{
height: 16,
fontSize: '0.6rem',
fontWeight: 700,
bgcolor: 'success.main',
color: 'white',
'& .MuiChip-label': { px: 0.5 }
}}
/>
)}
</Box>
}
primaryTypographyProps={{
fontWeight: location.pathname === item.path ? 600 : 500,
fontSize: '0.875rem'
}}
/>
</ListItemButton>
</ListItem>
))}
</List>
{/* Settings at bottom */}
<Box sx={{ p: 1.5, pb: 2 }}>
<ListItemButton
selected={location.pathname === '/settings'}
onClick={() => handleNavigate('/settings')}
sx={{
borderRadius: '9999px',
py: 1.5,
px: 2,
transition: 'all 0.3s ease',
'&.Mui-selected': {
bgcolor: 'rgba(19, 236, 106, 0.15)',
color: 'primary.main',
'&:hover': {
bgcolor: 'rgba(19, 236, 106, 0.2)',
},
},
'&:not(.Mui-selected)': {
color: 'text.secondary',
'&:hover': {
bgcolor: 'rgba(255, 255, 255, 0.05)',
color: 'text.primary',
},
},
}}
>
<ListItemIcon sx={{ color: 'inherit', minWidth: 36 }}>
<SettingsIcon />
</ListItemIcon>
<ListItemText
primary="Settings"
primaryTypographyProps={{
fontWeight: location.pathname === '/settings' ? 600 : 500,
fontSize: '0.875rem'
}}
/>
</ListItemButton>
</Box>
</Box>
);
return (
<>
{/* Mobile Drawer */}
<Drawer
variant="temporary"
open={mobileOpen}
onClose={onMobileClose}
ModalProps={{
keepMounted: true, // Better mobile performance
}}
sx={{
display: { xs: 'block', md: 'none' },
'& .MuiDrawer-paper': {
boxSizing: 'border-box',
width: 240,
bgcolor: 'background.default',
borderRight: '1px solid',
borderColor: 'rgba(255, 255, 255, 0.05)',
},
}}
>
{drawerContent}
</Drawer>
{/* Desktop Permanent Sidebar */}
<Box
sx={{
width: 240,
flexShrink: 0,
display: { xs: 'none', md: 'block' },
borderRight: '1px solid',
borderColor: 'rgba(255, 255, 255, 0.05)',
}}
>
{drawerContent}
</Box>
</>
);
}

View file

@ -0,0 +1,53 @@
import React from 'react';
import { Box, CircularProgress, Typography } from '@mui/material';
const SplashScreen: React.FC = () => {
return (
<Box
className="splash-screen"
sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
bgcolor: 'background.default',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
zIndex: 99999,
}}
>
<Box
component="img"
src="/img/logo.png"
alt="SoundWave Logo"
className="splash-logo"
sx={{
width: 160,
height: 160,
mb: 3,
borderRadius: '50%',
filter: 'drop-shadow(0 8px 16px rgba(0,0,0,0.3))',
}}
/>
<Typography
variant="h4"
sx={{
fontWeight: 700,
mb: 1,
color: 'primary.main',
}}
>
SoundWave
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
Music Streaming & YouTube Archive
</Typography>
<CircularProgress size={40} />
</Box>
);
};
export default SplashScreen;

View file

@ -0,0 +1,166 @@
import { Box, Typography } from '@mui/material';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
interface ThemePreviewProps {
name: string;
mode: 'dark' | 'blue' | 'white' | 'green' | 'lightBlue';
isSelected: boolean;
onClick: () => void;
}
const themeColors = {
dark: {
primary: '#22d3ee',
secondary: '#fbbf24',
bg1: '#0f172a',
bg2: '#1e293b',
text: '#f8fafc',
},
blue: {
primary: '#2196F3',
secondary: '#00BCD4',
bg1: '#0D1B2A',
bg2: '#1B263B',
text: '#E0F7FA',
},
white: {
primary: '#1976D2',
secondary: '#9C27B0',
bg1: '#F5F7FA',
bg2: '#FFFFFF',
text: '#1A202C',
},
green: {
primary: '#4CAF50',
secondary: '#00E676',
bg1: '#0D1F12',
bg2: '#1A2F23',
text: '#E8F5E9',
},
lightBlue: {
primary: '#06b6d4',
secondary: '#0ea5e9',
bg1: '#ecfeff',
bg2: '#ffffff',
text: '#0c4a6e',
},
};
export default function ThemePreview({ name, mode, isSelected, onClick }: ThemePreviewProps) {
const colors = themeColors[mode];
return (
<Box
onClick={onClick}
sx={{
position: 'relative',
height: 100,
borderRadius: 2,
cursor: 'pointer',
border: 3,
borderColor: isSelected ? colors.primary : 'divider',
transition: 'all 0.3s ease',
overflow: 'hidden',
'&:hover': {
transform: 'scale(1.05)',
boxShadow: `0 4px 20px ${colors.primary}40`,
},
}}
>
{/* Background */}
<Box
sx={{
position: 'absolute',
inset: 0,
background: `linear-gradient(135deg, ${colors.bg1} 0%, ${colors.bg2} 100%)`,
}}
/>
{/* Content preview */}
<Box
sx={{
position: 'absolute',
top: 8,
left: 8,
right: 8,
bottom: 8,
display: 'flex',
flexDirection: 'column',
gap: 0.5,
}}
>
{/* Header bar */}
<Box
sx={{
height: 8,
borderRadius: 0.5,
bgcolor: colors.primary,
width: '60%',
}}
/>
{/* Content bars */}
<Box
sx={{
height: 4,
borderRadius: 0.5,
bgcolor: colors.text,
opacity: 0.7,
width: '80%',
}}
/>
<Box
sx={{
height: 4,
borderRadius: 0.5,
bgcolor: colors.text,
opacity: 0.5,
width: '60%',
}}
/>
<Box
sx={{
height: 4,
borderRadius: 0.5,
bgcolor: colors.secondary,
opacity: 0.7,
width: '40%',
mt: 0.5,
}}
/>
</Box>
{/* Theme name */}
<Box
sx={{
position: 'absolute',
bottom: 4,
left: 8,
right: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography
sx={{
color: colors.text,
fontSize: '0.75rem',
fontWeight: 600,
textShadow: mode === 'white' ? 'none' : '0 1px 2px rgba(0,0,0,0.5)',
}}
>
{name}
</Typography>
{isSelected && (
<CheckCircleIcon
sx={{
fontSize: 16,
color: colors.primary,
}}
/>
)}
</Box>
</Box>
);
}

View file

@ -0,0 +1,156 @@
import { useState, useEffect } from 'react';
import { AppBar, Toolbar, Avatar, IconButton, Typography, Box, Tooltip } from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import LogoutIcon from '@mui/icons-material/Logout';
import AvatarDialog from './AvatarDialog';
interface TopBarProps {
onLogout: () => void;
onMenuClick?: () => void;
}
interface UserData {
username: string;
first_name?: string;
last_name?: string;
avatar?: string;
avatar_url?: string;
}
export default function TopBar({ onLogout, onMenuClick }: TopBarProps) {
const [avatarDialogOpen, setAvatarDialogOpen] = useState(false);
const [userData, setUserData] = useState<UserData | null>(null);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
useEffect(() => {
fetchUserData();
}, []);
const fetchUserData = async () => {
try {
const response = await fetch('/api/user/account/', {
headers: {
'Authorization': `Token ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
const data = await response.json();
setUserData(data);
setAvatarUrl(data.avatar_url);
}
} catch (error) {
console.error('Failed to fetch user data:', error);
}
};
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return 'Good Morning';
if (hour < 18) return 'Good Afternoon';
return 'Good Evening';
};
const handleAvatarChange = (newAvatarUrl: string | null) => {
setAvatarUrl(newAvatarUrl);
};
return (
<AppBar
position="static"
elevation={0}
sx={{
bgcolor: 'transparent',
borderBottom: 'none',
}}
>
<Toolbar sx={{ minHeight: { xs: '64px', md: '80px' }, px: { xs: 2, md: 3 } }}>
{/* Mobile Menu Button */}
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={onMenuClick}
sx={{ mr: 2, display: { md: 'none' } }}
>
<MenuIcon />
</IconButton>
<Box sx={{ flexGrow: 1, display: 'flex', alignItems: 'center', gap: { xs: 1.5, md: 2.5 } }}>
<Box sx={{ position: 'relative' }}>
<Avatar
src={avatarUrl || undefined}
onClick={() => setAvatarDialogOpen(true)}
sx={{
width: { xs: 40, md: 64 },
height: { xs: 40, md: 64 },
border: '2px solid',
borderColor: 'rgba(255, 255, 255, 0.1)',
cursor: 'pointer',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'scale(1.05)',
borderColor: 'primary.main',
},
}}
>
{userData?.username?.charAt(0).toUpperCase()}
</Avatar>
<Box
sx={{
position: 'absolute',
bottom: 0,
right: 0,
width: { xs: 12, md: 20 },
height: { xs: 12, md: 20 },
bgcolor: 'primary.main',
borderRadius: '50%',
border: { xs: '2px solid', md: '4px solid' },
borderColor: 'background.default',
}}
/>
</Box>
<Box>
<Typography variant="h5" sx={{ fontWeight: 700, letterSpacing: '-0.02em', mb: 0.5, fontSize: { xs: '1.1rem', md: '1.5rem' } }}>
{getGreeting()}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 500, display: { xs: 'none', sm: 'block' } }}>
{userData?.first_name || userData?.last_name
? `${userData.first_name || ''} ${userData.last_name || ''}`.trim()
: userData?.username || 'Music Lover'}
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Tooltip title="Logout" arrow>
<IconButton
onClick={onLogout}
sx={{
width: { xs: 44, md: 48 },
height: { xs: 44, md: 48 },
border: '1px solid',
borderColor: 'rgba(255, 255, 255, 0.1)',
transition: 'all 0.3s ease',
'&:hover': {
bgcolor: 'rgba(255, 82, 82, 0.1)',
borderColor: 'rgba(255, 82, 82, 0.5)',
color: '#ff5252',
},
}}
>
<LogoutIcon />
</IconButton>
</Tooltip>
</Box>
</Toolbar>
{/* Avatar Dialog */}
<AvatarDialog
open={avatarDialogOpen}
onClose={() => setAvatarDialogOpen(false)}
currentAvatar={avatarUrl}
onAvatarChange={handleAvatarChange}
/>
</AppBar>
);
}

View file

@ -0,0 +1,348 @@
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
TextField,
Button,
Alert,
Divider,
Grid,
} from '@mui/material';
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import LockIcon from '@mui/icons-material/Lock';
import axios from 'axios';
interface UserData {
id: number;
username: string;
email: string;
first_name: string;
last_name: string;
date_joined: string;
storage_quota_gb: number;
storage_used_gb: number;
max_channels: number;
max_playlists: number;
}
export default function UserProfileCard() {
const [userData, setUserData] = useState<UserData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
// Form states
const [username, setUsername] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
useEffect(() => {
loadUserData();
}, []);
const loadUserData = async () => {
try {
const response = await axios.get('/api/user/account/', {
headers: {
'Authorization': `Token ${localStorage.getItem('token')}`,
},
});
setUserData(response.data);
setUsername(response.data.username);
setFirstName(response.data.first_name || '');
setLastName(response.data.last_name || '');
setEmail(response.data.email);
} catch (err: any) {
setError('Failed to load user data');
}
};
const handleUpdateProfile = async () => {
// Check if anything changed
const usernameChanged = username !== userData?.username;
const firstNameChanged = firstName !== (userData?.first_name || '');
const lastNameChanged = lastName !== (userData?.last_name || '');
const emailChanged = email !== userData?.email;
if (!usernameChanged && !firstNameChanged && !lastNameChanged && !emailChanged) {
setError('No changes detected');
return;
}
// Password required for username or email change
if ((usernameChanged || emailChanged) && !currentPassword) {
setError('Current password required to change username or email');
return;
}
// Validate username
if (usernameChanged) {
if (username.length < 3) {
setError('Username must be at least 3 characters long');
return;
}
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
setError('Username can only contain letters, numbers, and underscores');
return;
}
}
setLoading(true);
setError('');
setSuccess('');
try {
const payload: any = {};
if (usernameChanged) payload.username = username;
if (firstNameChanged) payload.first_name = firstName;
if (lastNameChanged) payload.last_name = lastName;
if (emailChanged) payload.email = email;
// Add password if username or email changed
if ((usernameChanged || emailChanged) && currentPassword.trim()) {
payload.current_password = currentPassword;
}
const response = await axios.patch('/api/user/profile/',
payload,
{
headers: {
'Authorization': `Token ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
}
);
// Update local state with response data instead of reloading
if (response.data.user) {
setUserData(response.data.user);
setUsername(response.data.user.username);
setFirstName(response.data.user.first_name || '');
setLastName(response.data.user.last_name || '');
setEmail(response.data.user.email);
}
setSuccess('Profile updated successfully!');
setCurrentPassword('');
setTimeout(() => setSuccess(''), 3000);
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to update profile');
} finally {
setLoading(false);
}
};
const handleChangePassword = async () => {
if (!currentPassword || !newPassword || !confirmPassword) {
setError('Please fill in all password fields');
return;
}
if (newPassword !== confirmPassword) {
setError('New passwords do not match');
return;
}
if (newPassword.length < 8) {
setError('Password must be at least 8 characters long');
return;
}
setLoading(true);
setError('');
setSuccess('');
try {
await axios.post('/api/user/change-password/',
{
current_password: currentPassword,
new_password: newPassword
},
{
headers: {
'Authorization': `Token ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
}
);
setSuccess('Password changed successfully!');
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setTimeout(() => setSuccess(''), 3000);
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to change password');
} finally {
setLoading(false);
}
};
return (
<Paper sx={{ p: 3 }}>
<Box display="flex" alignItems="center" mb={3}>
<AccountCircleIcon sx={{ mr: 1, fontSize: 32, color: 'primary.main' }} />
<Typography variant="h6">Profile Information</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess('')}>
{success}
</Alert>
)}
{userData && (
<>
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} sm={6}>
<Typography variant="subtitle2" color="text.secondary">Username</Typography>
<Typography variant="body1" fontWeight={600}>{userData.username}</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="subtitle2" color="text.secondary">Member Since</Typography>
<Typography variant="body1">{new Date(userData.date_joined).toLocaleDateString()}</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="subtitle2" color="text.secondary">Storage</Typography>
<Typography variant="body1">
{(userData.storage_used_gb || 0).toFixed(2)} / {userData.storage_quota_gb || 0} GB
</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="subtitle2" color="text.secondary">Limits</Typography>
<Typography variant="body1">
{userData.max_channels || 0} channels, {userData.max_playlists || 0} playlists
</Typography>
</Grid>
</Grid>
<Divider sx={{ my: 3 }} />
{/* Update Profile Section */}
<Box mb={3}>
<Box display="flex" alignItems="center" mb={2}>
<AccountCircleIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="h6">Update Profile</Typography>
</Box>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
label="Username (Login ID)"
value={username}
onChange={(e) => setUsername(e.target.value)}
fullWidth
helperText="Used for login - requires password to change"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="First Name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
fullWidth
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Last Name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
fullWidth
/>
</Grid>
<Grid item xs={12}>
<TextField
label="Email Address"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
fullWidth
/>
</Grid>
<Grid item xs={12}>
<TextField
label="Current Password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
fullWidth
helperText="Required for username or email changes"
/>
</Grid>
<Grid item xs={12}>
<Button
variant="contained"
onClick={handleUpdateProfile}
disabled={loading}
fullWidth
>
Update Profile
</Button>
</Grid>
</Grid>
</Box>
<Divider sx={{ my: 3 }} />
{/* Change Password Section */}
<Box>
<Box display="flex" alignItems="center" mb={2}>
<LockIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="h6">Change Password</Typography>
</Box>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
label="Current Password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
fullWidth
/>
</Grid>
<Grid item xs={12}>
<TextField
label="New Password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
fullWidth
helperText="Minimum 8 characters"
/>
</Grid>
<Grid item xs={12}>
<TextField
label="Confirm New Password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
fullWidth
/>
</Grid>
<Grid item xs={12}>
<Button
variant="contained"
onClick={handleChangePassword}
disabled={loading}
fullWidth
>
Change Password
</Button>
</Grid>
</Grid>
</Box>
</>
)}
</Paper>
);
}

View file

@ -0,0 +1,143 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { pwaManager } from '../utils/pwa';
interface PWAContextType {
isOnline: boolean;
canInstall: boolean;
isInstalled: boolean;
isUpdateAvailable: boolean;
cacheSize: { usage: number; quota: number } | null;
showInstallPrompt: () => Promise<boolean>;
updateApp: () => Promise<void>;
clearCache: () => Promise<boolean>;
cacheAudio: (url: string) => Promise<boolean>;
cachePlaylist: (playlistId: string, audioUrls: string[]) => Promise<boolean>;
removePlaylistCache: (playlistId: string, audioUrls: string[]) => Promise<boolean>;
requestNotifications: () => Promise<NotificationPermission>;
}
const PWAContext = createContext<PWAContextType | undefined>(undefined);
export const usePWA = () => {
const context = useContext(PWAContext);
if (!context) {
throw new Error('usePWA must be used within PWAProvider');
}
return context;
};
interface PWAProviderProps {
children: ReactNode;
}
export const PWAProvider: React.FC<PWAProviderProps> = ({ children }) => {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [canInstall, setCanInstall] = useState(false);
const [isInstalled, setIsInstalled] = useState(pwaManager.getIsInstalled());
const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
const [cacheSize, setCacheSize] = useState<{ usage: number; quota: number } | null>(null);
useEffect(() => {
// Register service worker
pwaManager.registerServiceWorker();
// Set up event listeners
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
const handleCanInstall = () => setCanInstall(true);
const handleInstalled = () => {
setIsInstalled(true);
setCanInstall(false);
};
const handleUpdate = () => setIsUpdateAvailable(true);
pwaManager.on('online', handleOnline);
pwaManager.on('offline', handleOffline);
pwaManager.on('canInstall', handleCanInstall);
pwaManager.on('installed', handleInstalled);
pwaManager.on('updateAvailable', handleUpdate);
// Get cache size
const updateCacheSize = async () => {
const size = await pwaManager.getCacheSize();
setCacheSize(size);
};
updateCacheSize();
// Check initial install state
setCanInstall(pwaManager.canInstall());
return () => {
pwaManager.off('online', handleOnline);
pwaManager.off('offline', handleOffline);
pwaManager.off('canInstall', handleCanInstall);
pwaManager.off('installed', handleInstalled);
pwaManager.off('updateAvailable', handleUpdate);
};
}, []);
const showInstallPrompt = async () => {
return await pwaManager.showInstallPrompt();
};
const updateApp = async () => {
await pwaManager.updateServiceWorker();
};
const clearCache = async () => {
const result = await pwaManager.clearCache();
if (result) {
const size = await pwaManager.getCacheSize();
setCacheSize(size);
}
return result;
};
const cacheAudio = async (url: string) => {
const result = await pwaManager.cacheAudio(url);
if (result) {
const size = await pwaManager.getCacheSize();
setCacheSize(size);
}
return result;
};
const cachePlaylist = async (playlistId: string, audioUrls: string[]) => {
const result = await pwaManager.cachePlaylist(playlistId, audioUrls);
if (result) {
const size = await pwaManager.getCacheSize();
setCacheSize(size);
}
return result;
};
const removePlaylistCache = async (playlistId: string, audioUrls: string[]) => {
const result = await pwaManager.removePlaylistCache(playlistId, audioUrls);
if (result) {
const size = await pwaManager.getCacheSize();
setCacheSize(size);
}
return result;
};
const requestNotifications = async () => {
return await pwaManager.requestNotificationPermission();
};
const value: PWAContextType = {
isOnline,
canInstall,
isInstalled,
isUpdateAvailable,
cacheSize,
showInstallPrompt,
updateApp,
clearCache,
cacheAudio,
cachePlaylist,
removePlaylistCache,
requestNotifications,
};
return <PWAContext.Provider value={value}>{children}</PWAContext.Provider>;
};

View file

@ -0,0 +1,141 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import api from '../api/client';
interface QuickSyncStatus {
network: {
speed_mbps: number;
status: 'excellent' | 'good' | 'fair' | 'poor';
};
system: {
cpu_percent: number;
memory_percent: number;
memory_available_mb: number;
status: 'low_load' | 'moderate_load' | 'high_load';
};
quality: {
level: 'low' | 'medium' | 'high' | 'ultra' | 'auto';
bitrate: number;
description: string;
auto_selected: boolean;
};
buffer: {
buffer_size: number;
preload: string;
max_buffer_size: number;
rebuffer_threshold: number;
};
timestamp: number;
}
interface QuickSyncPreferences {
mode: 'auto' | 'low' | 'medium' | 'high' | 'ultra';
prefer_quality: boolean;
adapt_to_system: boolean;
auto_download_quality: boolean;
}
interface QuickSyncContextType {
status: QuickSyncStatus | null;
preferences: QuickSyncPreferences | null;
loading: boolean;
updatePreferences: (prefs: Partial<QuickSyncPreferences>) => Promise<void>;
runSpeedTest: () => Promise<void>;
refreshStatus: () => Promise<void>;
}
const QuickSyncContext = createContext<QuickSyncContextType | undefined>(undefined);
export const useQuickSync = () => {
const context = useContext(QuickSyncContext);
if (!context) {
throw new Error('useQuickSync must be used within QuickSyncProvider');
}
return context;
};
interface QuickSyncProviderProps {
children: ReactNode;
}
export const QuickSyncProvider = ({ children }: QuickSyncProviderProps) => {
const [status, setStatus] = useState<QuickSyncStatus | null>(null);
const [preferences, setPreferences] = useState<QuickSyncPreferences | null>(null);
const [loading, setLoading] = useState(true);
const fetchStatus = async () => {
try {
const response = await api.get('/audio/quick-sync/status/');
setStatus(response.data.status);
setPreferences(response.data.preferences);
} catch (error: any) {
// Silently handle 401/403 errors (not authenticated or no permission)
if (error?.response?.status === 401 || error?.response?.status === 403) {
// Quick Sync feature not available or user not authenticated
setStatus(null);
setPreferences(null);
} else {
console.error('Error fetching Quick Sync status:', error);
}
} finally {
setLoading(false);
}
};
const updatePreferences = async (prefs: Partial<QuickSyncPreferences>) => {
try {
const response = await api.post('/audio/quick-sync/preferences/', prefs);
setPreferences(response.data.preferences);
setStatus(response.data.status);
} catch (error) {
console.error('Error updating Quick Sync preferences:', error);
throw error;
}
};
const runSpeedTest = async () => {
try {
setLoading(true);
const response = await api.post('/audio/quick-sync/test/');
await fetchStatus(); // Refresh full status after test
} catch (error) {
console.error('Error running speed test:', error);
throw error;
} finally {
setLoading(false);
}
};
const refreshStatus = async () => {
setLoading(true);
await fetchStatus();
};
useEffect(() => {
// Only fetch if user is authenticated
const token = localStorage.getItem('token');
if (!token) {
setLoading(false);
return;
}
fetchStatus();
// Refresh status every 5 minutes
const interval = setInterval(fetchStatus, 5 * 60 * 1000);
return () => clearInterval(interval);
}, []);
return (
<QuickSyncContext.Provider
value={{
status,
preferences,
loading,
updatePreferences,
runSpeedTest,
refreshStatus,
}}
>
{children}
</QuickSyncContext.Provider>
);
};

21
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,21 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import CssBaseline from '@mui/material/CssBaseline'
import AppWithTheme from './AppWithTheme'
import { QuickSyncProvider } from './context/QuickSyncContext'
import { PWAProvider } from './context/PWAContext'
import './style.css'
import './styles/pwa.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<PWAProvider>
<QuickSyncProvider>
<AppWithTheme />
</QuickSyncProvider>
</PWAProvider>
</BrowserRouter>
</React.StrictMode>,
)

View file

@ -0,0 +1,690 @@
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton,
Chip,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
FormControlLabel,
Switch,
LinearProgress,
Tooltip,
Alert,
Grid,
Card,
CardContent,
} from '@mui/material';
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Info as InfoIcon,
Block as BlockIcon,
CheckCircle as CheckCircleIcon,
Refresh as RefreshIcon,
PersonAdd as PersonAddIcon,
Storage as StorageIcon,
Group as GroupIcon,
VideoLibrary as VideoLibraryIcon,
} from '@mui/icons-material';
import api from '../api/client';
interface User {
id: number;
username: string;
email: string;
is_admin: boolean;
is_active: boolean;
storage_quota_gb: number;
storage_used_gb: number;
storage_percent_used: number;
max_channels: number;
max_playlists: number;
date_joined: string;
last_login: string;
stats: {
total_channels: number;
total_playlists: number;
total_audio_files: number;
};
}
interface SystemStats {
users: {
total: number;
active: number;
admin: number;
};
content: {
channels: number;
playlists: number;
audio_files: number;
};
storage: {
used_gb: number;
quota_gb: number;
};
}
const AdminUsersPage = () => {
const [users, setUsers] = useState<User[]>([]);
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [userDetailsOpen, setUserDetailsOpen] = useState(false);
// Form state
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
password_confirm: '',
is_admin: false,
is_active: true,
storage_quota_gb: 50,
max_channels: 50,
max_playlists: 100,
user_notes: '',
});
useEffect(() => {
loadUsers();
loadSystemStats();
}, []);
const loadUsers = async () => {
try {
setLoading(true);
const response = await api.get('/user/admin/users/');
// API returns paginated response with 'results' array
setUsers(response.data.results || response.data);
setError('');
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to load users');
} finally {
setLoading(false);
}
};
const loadSystemStats = async () => {
try {
const response = await api.get('/user/admin/users/system_stats/');
setSystemStats(response.data);
} catch (err) {
console.error('Failed to load system stats:', err);
}
};
const handleCreateUser = async () => {
try {
await api.post('/user/admin/users/', formData);
setSuccess('User created successfully');
setCreateDialogOpen(false);
resetForm();
loadUsers();
loadSystemStats();
setTimeout(() => setSuccess(''), 3000);
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to create user');
}
};
const handleUpdateUser = async () => {
if (!selectedUser) return;
try {
await api.patch(`/user/admin/users/${selectedUser.id}/`, {
is_admin: formData.is_admin,
is_active: formData.is_active,
storage_quota_gb: formData.storage_quota_gb,
max_channels: formData.max_channels,
max_playlists: formData.max_playlists,
user_notes: formData.user_notes,
});
setSuccess('User updated successfully');
setEditDialogOpen(false);
loadUsers();
setTimeout(() => setSuccess(''), 3000);
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to update user');
}
};
const handleToggleActive = async (user: User) => {
try {
await api.post(`/user/admin/users/${user.id}/toggle_active/`);
setSuccess(`User ${user.is_active ? 'deactivated' : 'activated'}`);
loadUsers();
setTimeout(() => setSuccess(''), 3000);
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to toggle user status');
}
};
const handleResetStorage = async (user: User) => {
try {
await api.post(`/user/admin/users/${user.id}/reset_storage/`);
setSuccess('Storage reset successfully');
loadUsers();
setTimeout(() => setSuccess(''), 3000);
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to reset storage');
}
};
const handleReset2FA = async (user: User) => {
try {
await api.post(`/user/admin/users/${user.id}/reset_2fa/`);
setSuccess('2FA reset successfully');
loadUsers();
setTimeout(() => setSuccess(''), 3000);
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to reset 2FA');
}
};
const openEditDialog = (user: User) => {
setSelectedUser(user);
setFormData({
username: user.username,
email: user.email,
password: '',
password_confirm: '',
is_admin: user.is_admin,
is_active: user.is_active,
storage_quota_gb: user.storage_quota_gb,
max_channels: user.max_channels,
max_playlists: user.max_playlists,
user_notes: '',
});
setEditDialogOpen(true);
};
const openUserDetails = (user: User) => {
setSelectedUser(user);
setUserDetailsOpen(true);
};
const resetForm = () => {
setFormData({
username: '',
email: '',
password: '',
password_confirm: '',
is_admin: false,
is_active: true,
storage_quota_gb: 50,
max_channels: 50,
max_playlists: 100,
user_notes: '',
});
};
const getStorageColor = (percent: number) => {
if (percent > 90) return 'error';
if (percent > 75) return 'warning';
return 'success';
};
return (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
<Typography variant="h4" fontWeight="bold">
User Management
</Typography>
<Box>
<IconButton onClick={loadUsers} sx={{ mr: 1 }}>
<RefreshIcon />
</IconButton>
<Button
variant="contained"
startIcon={<PersonAddIcon />}
onClick={() => setCreateDialogOpen(true)}
>
Create User
</Button>
</Box>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess('')}>
{success}
</Alert>
)}
{/* System Statistics */}
{systemStats && (
<Grid container spacing={3} sx={{ mb: 3 }}>
<Grid item xs={12} md={3}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" mb={1}>
<GroupIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="h6">Users</Typography>
</Box>
<Typography variant="h4">{systemStats.users.total}</Typography>
<Typography variant="body2" color="text.secondary">
{systemStats.users.active} active, {systemStats.users.admin} admins
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={3}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" mb={1}>
<VideoLibraryIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="h6">Content</Typography>
</Box>
<Typography variant="h4">{systemStats.content.channels}</Typography>
<Typography variant="body2" color="text.secondary">
{systemStats.content.playlists} playlists, {systemStats.content.audio_files} audio
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={3}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" mb={1}>
<StorageIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="h6">Storage</Typography>
</Box>
<Typography variant="h4">{systemStats.storage.used_gb.toFixed(1)} GB</Typography>
<Typography variant="body2" color="text.secondary">
/ {systemStats.storage.quota_gb} GB allocated
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
)}
{/* Users Table */}
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>Email</TableCell>
<TableCell>Status</TableCell>
<TableCell>Storage</TableCell>
<TableCell>Content</TableCell>
<TableCell>Joined</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} align="center">
<LinearProgress />
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center">
No users found
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.id}>
<TableCell>
<Box display="flex" alignItems="center">
{user.username}
{user.is_admin && (
<Chip label="Admin" size="small" color="primary" sx={{ ml: 1 }} />
)}
</Box>
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Chip
label={user.is_active ? 'Active' : 'Inactive'}
color={user.is_active ? 'success' : 'default'}
size="small"
icon={user.is_active ? <CheckCircleIcon /> : <BlockIcon />}
/>
</TableCell>
<TableCell>
<Box width={120}>
<Typography variant="caption">
{user.storage_used_gb.toFixed(1)} / {user.storage_quota_gb} GB
</Typography>
<LinearProgress
variant="determinate"
value={Math.min(user.storage_percent_used, 100)}
color={getStorageColor(user.storage_percent_used)}
/>
</Box>
</TableCell>
<TableCell>
<Typography variant="body2">
{user.stats.total_channels} channels
</Typography>
<Typography variant="caption" color="text.secondary">
{user.stats.total_playlists} playlists, {user.stats.total_audio_files} audio
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{new Date(user.date_joined).toLocaleDateString()}
</Typography>
</TableCell>
<TableCell>
<Tooltip title="User Details">
<IconButton size="small" onClick={() => openUserDetails(user)}>
<InfoIcon />
</IconButton>
</Tooltip>
<Tooltip title="Edit User">
<IconButton size="small" onClick={() => openEditDialog(user)}>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title={user.is_active ? 'Deactivate' : 'Activate'}>
<IconButton size="small" onClick={() => handleToggleActive(user)}>
{user.is_active ? <BlockIcon /> : <CheckCircleIcon />}
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
{/* Create User Dialog */}
<Dialog open={createDialogOpen} onClose={() => setCreateDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Create New User</DialogTitle>
<DialogContent>
<Box sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Username"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
fullWidth
required
/>
<TextField
label="Email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
fullWidth
required
/>
<TextField
label="Password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
fullWidth
required
/>
<TextField
label="Confirm Password"
type="password"
value={formData.password_confirm}
onChange={(e) => setFormData({ ...formData, password_confirm: e.target.value })}
fullWidth
required
/>
<TextField
label="Storage Quota (GB)"
type="number"
value={formData.storage_quota_gb}
onChange={(e) => setFormData({ ...formData, storage_quota_gb: parseInt(e.target.value) })}
fullWidth
/>
<TextField
label="Max Channels"
type="number"
value={formData.max_channels}
onChange={(e) => setFormData({ ...formData, max_channels: parseInt(e.target.value) })}
fullWidth
/>
<TextField
label="Max Playlists"
type="number"
value={formData.max_playlists}
onChange={(e) => setFormData({ ...formData, max_playlists: parseInt(e.target.value) })}
fullWidth
/>
<TextField
label="Notes"
multiline
rows={3}
value={formData.user_notes}
onChange={(e) => setFormData({ ...formData, user_notes: e.target.value })}
fullWidth
/>
<FormControlLabel
control={
<Switch
checked={formData.is_admin}
onChange={(e) => setFormData({ ...formData, is_admin: e.target.checked })}
/>
}
label="Admin User"
/>
<FormControlLabel
control={
<Switch
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
/>
}
label="Active"
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setCreateDialogOpen(false)}>Cancel</Button>
<Button onClick={handleCreateUser} variant="contained">
Create User
</Button>
</DialogActions>
</Dialog>
{/* Edit User Dialog */}
<Dialog open={editDialogOpen} onClose={() => setEditDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Edit User: {selectedUser?.username}</DialogTitle>
<DialogContent>
<Box sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Storage Quota (GB)"
type="number"
value={formData.storage_quota_gb}
onChange={(e) => setFormData({ ...formData, storage_quota_gb: parseInt(e.target.value) })}
fullWidth
/>
<TextField
label="Max Channels"
type="number"
value={formData.max_channels}
onChange={(e) => setFormData({ ...formData, max_channels: parseInt(e.target.value) })}
fullWidth
/>
<TextField
label="Max Playlists"
type="number"
value={formData.max_playlists}
onChange={(e) => setFormData({ ...formData, max_playlists: parseInt(e.target.value) })}
fullWidth
/>
<TextField
label="Notes"
multiline
rows={3}
value={formData.user_notes}
onChange={(e) => setFormData({ ...formData, user_notes: e.target.value })}
fullWidth
/>
<FormControlLabel
control={
<Switch
checked={formData.is_admin}
onChange={(e) => setFormData({ ...formData, is_admin: e.target.checked })}
/>
}
label="Admin User"
/>
<FormControlLabel
control={
<Switch
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
/>
}
label="Active"
/>
{selectedUser && (
<Box display="flex" gap={1}>
<Button
variant="outlined"
color="warning"
onClick={() => handleResetStorage(selectedUser)}
fullWidth
>
Reset Storage
</Button>
<Button
variant="outlined"
color="warning"
onClick={() => handleReset2FA(selectedUser)}
fullWidth
>
Reset 2FA
</Button>
</Box>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setEditDialogOpen(false)}>Cancel</Button>
<Button onClick={handleUpdateUser} variant="contained">
Save Changes
</Button>
</DialogActions>
</Dialog>
{/* User Details Dialog */}
<Dialog open={userDetailsOpen} onClose={() => setUserDetailsOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>User Details: {selectedUser?.username}</DialogTitle>
<DialogContent>
{selectedUser && (
<Box sx={{ pt: 2 }}>
<Grid container spacing={2}>
<Grid item xs={6}>
<Typography variant="subtitle2" color="text.secondary">
Email
</Typography>
<Typography variant="body1">{selectedUser.email}</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="subtitle2" color="text.secondary">
Status
</Typography>
<Chip
label={selectedUser.is_active ? 'Active' : 'Inactive'}
color={selectedUser.is_active ? 'success' : 'default'}
size="small"
/>
</Grid>
<Grid item xs={6}>
<Typography variant="subtitle2" color="text.secondary">
Role
</Typography>
<Chip
label={selectedUser.is_admin ? 'Admin' : 'User'}
color={selectedUser.is_admin ? 'primary' : 'default'}
size="small"
/>
</Grid>
<Grid item xs={6}>
<Typography variant="subtitle2" color="text.secondary">
Storage Usage
</Typography>
<Typography variant="body1">
{selectedUser.storage_used_gb.toFixed(2)} / {selectedUser.storage_quota_gb} GB
({selectedUser.storage_percent_used.toFixed(1)}%)
</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="subtitle2" color="text.secondary">
Channels
</Typography>
<Typography variant="body1">
{selectedUser.stats.total_channels} / {selectedUser.max_channels}
</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="subtitle2" color="text.secondary">
Playlists
</Typography>
<Typography variant="body1">
{selectedUser.stats.total_playlists} / {selectedUser.max_playlists}
</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="subtitle2" color="text.secondary">
Audio Files
</Typography>
<Typography variant="body1">{selectedUser.stats.total_audio_files}</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="subtitle2" color="text.secondary">
Date Joined
</Typography>
<Typography variant="body1">
{new Date(selectedUser.date_joined).toLocaleString()}
</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="subtitle2" color="text.secondary">
Last Login
</Typography>
<Typography variant="body1">
{selectedUser.last_login
? new Date(selectedUser.last_login).toLocaleString()
: 'Never'}
</Typography>
</Grid>
</Grid>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setUserDetailsOpen(false)}>Close</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default AdminUsersPage;

View file

@ -0,0 +1,295 @@
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
Card,
CardContent,
CardActions,
Grid,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Avatar,
Chip,
IconButton,
Alert,
LinearProgress,
} from '@mui/material';
import {
Add as AddIcon,
Delete as DeleteIcon,
Refresh as RefreshIcon,
YouTube as YouTubeIcon,
} from '@mui/icons-material';
import { channelAPI } from '../api/client';
interface Channel {
id: number;
channel_id: string;
channel_name: string;
channel_thumbnail: string;
subscribed: boolean;
video_count: number;
subscriber_count: number;
downloaded_count: number;
last_refreshed: string;
sync_status: 'pending' | 'syncing' | 'success' | 'failed' | 'stale';
status_display: string;
error_message: string;
active: boolean;
progress_percent: number;
}
export default function ChannelsPage() {
const [channels, setChannels] = useState<Channel[]>([]);
const [loading, setLoading] = useState(true);
const [openDialog, setOpenDialog] = useState(false);
const [channelUrl, setChannelUrl] = useState('');
const [error, setError] = useState('');
const loadChannels = async () => {
try {
const response = await channelAPI.list();
// Handle both array response and paginated object response
const data = Array.isArray(response.data) ? response.data : (response.data?.results || response.data?.data || []);
setChannels(data);
} catch (err) {
console.error('Failed to load channels:', err);
setChannels([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadChannels();
}, []);
const handleSubscribe = async () => {
setError('');
try {
await channelAPI.subscribe({ url: channelUrl });
setChannelUrl('');
setOpenDialog(false);
loadChannels();
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to subscribe to channel');
}
};
const handleUnsubscribe = async (channelId: string) => {
if (!confirm('Are you sure you want to unsubscribe from this channel?')) return;
try {
await channelAPI.unsubscribe(channelId);
loadChannels();
} catch (err) {
console.error('Failed to unsubscribe:', err);
}
};
const formatNumber = (num: number) => {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num.toString();
};
const getStatusColor = (status: string): 'default' | 'primary' | 'success' | 'error' | 'warning' => {
switch (status) {
case 'syncing': return 'primary';
case 'success': return 'success';
case 'failed': return 'error';
case 'stale': return 'warning';
default: return 'default';
}
};
const getLastRefreshText = (lastRefresh: string) => {
const date = new Date(lastRefresh);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
};
return (
<Box>
{/* Header */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4" fontWeight="bold">
YouTube Channels
</Typography>
<Button
variant="contained"
size="small"
startIcon={<AddIcon />}
onClick={() => setOpenDialog(true)}
>
Subscribe to Channel
</Button>
</Box>
{/* Channels Grid */}
<Grid container spacing={2}>
{channels.map((channel) => (
<Grid item xs={12} sm={6} md={4} key={channel.id}>
<Card>
<CardContent>
{/* Channel Avatar & Name */}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1.5, mt: channel.channel_thumbnail ? -3 : 0 }}>
<Avatar
src={channel.channel_thumbnail}
sx={{
width: 64,
height: 64,
border: '3px solid',
borderColor: 'background.paper',
boxShadow: 2,
}}
>
<YouTubeIcon />
</Avatar>
<Box sx={{ ml: 1.5, flex: 1 }}>
<Typography variant="subtitle1" fontWeight="bold" noWrap>
{channel.channel_name}
</Typography>
</Box>
</Box>
{/* Status Badges */}
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 0.5 }}>
<Chip
label={channel.status_display}
color={getStatusColor(channel.sync_status)}
size="small"
/>
{!channel.active && (
<Chip label="Inactive" color="error" size="small" variant="outlined" />
)}
</Box>
{/* Last Refresh */}
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
{getLastRefreshText(channel.last_refreshed)}
</Typography>
{/* Error Message */}
{channel.error_message && (
<Alert severity="error" sx={{ mt: 1, mb: 1, py: 0 }}>
<Typography variant="caption">{channel.error_message}</Typography>
</Alert>
)}
{/* Stats */}
<Box sx={{ display: 'flex', gap: 1.5, mt: 1.5 }}>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
Subscribers
</Typography>
<Typography variant="body2" fontWeight="bold" sx={{ fontSize: '0.8125rem' }}>
{formatNumber(channel.subscriber_count)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Videos
</Typography>
<Typography variant="body2" fontWeight="bold">
{formatNumber(channel.video_count)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Downloaded
</Typography>
<Typography variant="body2" fontWeight="bold">
{channel.downloaded_count} ({channel.progress_percent}%)
</Typography>
</Box>
</Box>
</CardContent>
<CardActions sx={{ justifyContent: 'space-between', px: 2, pb: 2 }}>
<IconButton size="small" title="Refresh channel">
<RefreshIcon />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => handleUnsubscribe(channel.channel_id)}
title="Unsubscribe"
>
<DeleteIcon />
</IconButton>
</CardActions>
</Card>
</Grid>
))}
</Grid>
{/* Empty State */}
{!loading && channels.length === 0 && (
<Box
sx={{
textAlign: 'center',
py: 8,
color: 'text.secondary',
}}
>
<YouTubeIcon sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} />
<Typography variant="h6" gutterBottom>
No channels subscribed
</Typography>
<Typography variant="body2" sx={{ mb: 3 }}>
Subscribe to YouTube channels to automatically download their content
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setOpenDialog(true)}
>
Subscribe to Channel
</Button>
</Box>
)}
{/* Subscribe Dialog */}
<Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="sm" fullWidth>
<DialogTitle>Subscribe to YouTube Channel</DialogTitle>
<DialogContent>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField
autoFocus
margin="dense"
label="Channel URL"
placeholder="https://www.youtube.com/@channelname or channel ID"
fullWidth
value={channelUrl}
onChange={(e) => setChannelUrl(e.target.value)}
helperText="Enter a YouTube channel URL or channel ID"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenDialog(false)}>Cancel</Button>
<Button onClick={handleSubscribe} variant="contained" disabled={!channelUrl}>
Subscribe
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

@ -0,0 +1,36 @@
import { Box, Typography } from '@mui/material';
import FavoriteIcon from '@mui/icons-material/Favorite';
import type { Audio } from '../types';
interface FavoritesPageProps {
setCurrentAudio: (audio: Audio) => void;
}
export default function FavoritesPage({ setCurrentAudio }: FavoritesPageProps) {
return (
<Box>
<Typography variant="h4" sx={{ mb: 2, fontWeight: 600 }}>
Favorites
</Typography>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 300,
gap: 1.5,
}}
>
<FavoriteIcon sx={{ fontSize: 48, color: 'text.secondary' }} />
<Typography variant="subtitle1" color="text.secondary">
No favorites yet
</Typography>
<Typography variant="caption" color="text.secondary">
Start adding songs to your favorites
</Typography>
</Box>
</Box>
);
}

View file

@ -0,0 +1,227 @@
import { Box, Typography, Grid, Card, CardMedia, CardContent, IconButton } from '@mui/material';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { audioAPI, playlistAPI } from '../api/client';
import type { Audio, Playlist } from '../types';
interface HomePageProps {
setCurrentAudio: (audio: Audio) => void;
}
export default function HomePage({ setCurrentAudio }: HomePageProps) {
const navigate = useNavigate();
const [newAudio, setNewAudio] = useState<Audio[]>([]);
const [playlists, setPlaylists] = useState<Playlist[]>([]);
useEffect(() => {
loadNewAudio();
loadPlaylists();
}, []);
const loadNewAudio = async () => {
try {
const response = await audioAPI.list({ sort: 'downloaded', order: 'desc' });
const data = response.data?.data || response.data || [];
setNewAudio(Array.isArray(data) ? data.slice(0, 3) : []);
} catch (error) {
console.error('Failed to load audio:', error);
setNewAudio([]);
}
};
const loadPlaylists = async () => {
try {
const response = await playlistAPI.list();
const data = response.data?.data || response.data || [];
setPlaylists(Array.isArray(data) ? data.slice(0, 3) : []);
} catch (error) {
console.error('Failed to load playlists:', error);
setPlaylists([]);
}
};
return (
<Box>
{/* Newly Added Songs */}
<Box sx={{ mb: 6 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2, px: 0.5 }}>
<Typography variant="h6" sx={{ fontWeight: 700, letterSpacing: '-0.02em' }}>
Newly Added Songs
</Typography>
<Typography
variant="body2"
onClick={() => navigate('/library')}
sx={{ color: 'primary.main', cursor: 'pointer', fontWeight: 500, '&:hover': { opacity: 0.8 } }}
>
See All
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 3, overflowX: 'auto', pb: 2, '&::-webkit-scrollbar': { height: 8 }, '&::-webkit-scrollbar-thumb': { bgcolor: 'rgba(255,255,255,0.1)', borderRadius: 1 } }}>
{newAudio.map((audio) => (
<Box
key={audio.id}
sx={{
minWidth: 160,
width: 160,
cursor: 'pointer',
'& .play-button': {
opacity: 0,
transform: 'translateY(8px)',
},
'&:hover .play-button': {
opacity: 1,
transform: 'translateY(0)',
},
'&:hover .card-image': {
transform: 'scale(1.05)',
},
}}
onClick={() => setCurrentAudio(audio)}
>
<Box
sx={{
position: 'relative',
aspectRatio: '1',
borderRadius: 3,
overflow: 'hidden',
mb: 1.5,
}}
>
<Box
className="card-image"
sx={{
width: '100%',
height: '100%',
backgroundImage: `url(${audio.thumbnail_url || '/placeholder.jpg'})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
transition: 'transform 0.5s ease',
}}
/>
<Box
sx={{
position: 'absolute',
inset: 0,
bgcolor: 'rgba(0, 0, 0, 0.2)',
transition: 'background-color 0.3s ease',
'&:hover': {
bgcolor: 'rgba(0, 0, 0, 0.4)',
},
}}
/>
<IconButton
className="play-button"
sx={{
position: 'absolute',
bottom: 12,
right: 12,
width: 40,
height: 40,
bgcolor: 'primary.main',
color: 'background.dark',
transition: 'all 0.3s ease',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
'&:hover': {
bgcolor: 'primary.main',
transform: 'scale(1.1)',
},
}}
>
<PlayArrowIcon />
</IconButton>
</Box>
<Typography variant="body2" noWrap sx={{ fontWeight: 600, mb: 0.5 }}>
{audio.title}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
{audio.channel_name}
</Typography>
</Box>
))}
</Box>
</Box>
{/* Your Playlists */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2, px: 0.5 }}>
<Typography variant="h6" sx={{ fontWeight: 700, letterSpacing: '-0.02em' }}>
Your Playlists
</Typography>
<Typography
variant="body2"
onClick={() => navigate('/playlists')}
sx={{ color: 'primary.main', cursor: 'pointer', fontWeight: 500, '&:hover': { opacity: 0.8 } }}
>
See All
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{playlists.map((playlist) => (
<Box
key={playlist.id}
onClick={() => navigate(`/playlists/${playlist.playlist_id}`)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
p: 1.5,
borderRadius: 3,
cursor: 'pointer',
transition: 'background-color 0.3s ease',
'&:hover': {
bgcolor: 'rgba(255, 255, 255, 0.05)',
},
'& .play-btn': {
opacity: 0,
transition: 'opacity 0.3s ease',
},
'&:hover .play-btn': {
opacity: 1,
},
}}
>
<Box
sx={{
width: 56,
height: 56,
borderRadius: 2,
bgcolor: 'primary.main',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)',
}}
>
<PlayArrowIcon sx={{ fontSize: 28, color: 'background.dark' }} />
</Box>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Typography variant="subtitle2" noWrap sx={{ fontWeight: 600, mb: 0.25 }}>
{playlist.title}
</Typography>
<Typography variant="caption" color="text.secondary">
{playlist.item_count} Tracks
</Typography>
</Box>
<IconButton
className="play-btn"
size="small"
sx={{
width: 32,
height: 32,
border: '1px solid',
borderColor: 'rgba(255, 255, 255, 0.1)',
}}
>
<PlayArrowIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
))}
</Box>
</Box>
</Box>
);
}

View file

@ -0,0 +1,235 @@
import {
Box,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton,
Paper,
Button,
Snackbar,
} from '@mui/material';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import FavoriteIcon from '@mui/icons-material/Favorite';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import ShuffleIcon from '@mui/icons-material/Shuffle';
import { useEffect, useState } from 'react';
import { audioAPI } from '../api/client';
import type { Audio } from '../types';
interface LibraryPageProps {
setCurrentAudio: (audio: Audio, queue?: Audio[]) => void;
}
export default function LibraryPage({ setCurrentAudio }: LibraryPageProps) {
const [audioList, setAudioList] = useState<Audio[]>([]);
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
useEffect(() => {
loadAudio();
}, []);
const loadAudio = async () => {
try {
const response = await audioAPI.list();
const data = response.data?.data || response.data || [];
setAudioList(Array.isArray(data) ? data : []);
} catch (error) {
console.error('Failed to load audio:', error);
setAudioList([]);
}
};
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatFileSize = (bytes: number) => {
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(1)} MB`;
};
const handlePlayAll = () => {
if (audioList.length === 0) {
setSnackbarMessage('No tracks in library');
setSnackbarOpen(true);
return;
}
setCurrentAudio(audioList[0], audioList);
setSnackbarMessage(`Playing ${audioList.length} tracks`);
setSnackbarOpen(true);
};
const handleShuffle = () => {
if (audioList.length === 0) {
setSnackbarMessage('No tracks to shuffle');
setSnackbarOpen(true);
return;
}
// Shuffle array using Fisher-Yates algorithm
const shuffled = [...audioList];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
setCurrentAudio(shuffled[0], shuffled);
setSnackbarMessage(`Shuffled ${shuffled.length} tracks`);
setSnackbarOpen(true);
};
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3, flexWrap: 'wrap', gap: 2 }}>
<Typography variant="h4" sx={{ fontWeight: 700, letterSpacing: '-0.02em' }}>
Library
</Typography>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Button
variant="contained"
startIcon={<PlayArrowIcon />}
onClick={handlePlayAll}
disabled={audioList.length === 0}
sx={{
minWidth: { xs: '48px', sm: '120px' },
bgcolor: 'primary.main',
color: 'background.dark',
fontWeight: 600,
'&:hover': { bgcolor: 'primary.dark' },
'&:disabled': { bgcolor: 'rgba(255, 255, 255, 0.1)' },
}}
>
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>Play All</Box>
</Button>
<Button
variant="outlined"
startIcon={<ShuffleIcon />}
onClick={handleShuffle}
disabled={audioList.length === 0}
sx={{
minWidth: { xs: '48px', sm: '120px' },
borderColor: 'primary.main',
color: 'primary.main',
fontWeight: 600,
'&:hover': {
borderColor: 'primary.dark',
bgcolor: 'rgba(19, 236, 106, 0.05)'
},
'&:disabled': {
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'rgba(255, 255, 255, 0.3)'
},
}}
>
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>Shuffle</Box>
</Button>
</Box>
</Box>
<TableContainer
component={Paper}
sx={{
bgcolor: 'background.paper',
borderRadius: 3,
border: '1px solid',
borderColor: 'rgba(255, 255, 255, 0.05)',
'& .MuiTableCell-root': {
borderColor: 'rgba(255, 255, 255, 0.05)',
},
}}
>
<Table>
<TableHead>
<TableRow>
<TableCell width={50} sx={{ fontWeight: 600 }}>#</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Title</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Channel</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Duration</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Size</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Plays</TableCell>
<TableCell align="center" width={100} sx={{ fontWeight: 600 }}>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{audioList.map((audio, index) => (
<TableRow
key={audio.id}
sx={{
cursor: 'pointer',
transition: 'background-color 0.3s ease',
'&:hover': {
bgcolor: 'rgba(19, 236, 106, 0.05)',
},
}}
onClick={() => setCurrentAudio(audio)}
>
<TableCell sx={{ color: 'text.secondary' }}>{index + 1}</TableCell>
<TableCell>
<Typography variant="body2" noWrap sx={{ maxWidth: 300, fontWeight: 500 }}>
{audio.title}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" color="text.secondary">
{audio.channel_name}
</Typography>
</TableCell>
<TableCell align="right" sx={{ color: 'text.secondary' }}>
{formatDuration(audio.duration)}
</TableCell>
<TableCell align="right" sx={{ color: 'text.secondary' }}>
{formatFileSize(audio.file_size)}
</TableCell>
<TableCell align="right" sx={{ color: 'text.secondary' }}>
{audio.play_count}
</TableCell>
<TableCell align="center" onClick={(e) => e.stopPropagation()}>
<IconButton
size="small"
sx={{
color: 'primary.main',
'&:hover': {
bgcolor: 'rgba(19, 236, 106, 0.1)',
},
}}
>
<PlayArrowIcon />
</IconButton>
<IconButton
size="small"
sx={{
'&:hover': {
color: 'primary.main',
bgcolor: 'rgba(19, 236, 106, 0.1)',
},
}}
>
<FavoriteBorderIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{/* Snackbar for notifications */}
<Snackbar
open={snackbarOpen}
autoHideDuration={3000}
onClose={() => setSnackbarOpen(false)}
message={snackbarMessage}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
/>
</Box>
);
}

View file

@ -0,0 +1,641 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Grid,
Card,
CardContent,
CardMedia,
Button,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Chip,
Menu,
MenuItem,
LinearProgress,
Alert,
Tab,
Tabs,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
InputAdornment,
FormControl,
InputLabel,
Select,
Divider,
} from '@mui/material';
import {
CloudUpload as UploadIcon,
PlayArrow as PlayIcon,
Pause as PauseIcon,
Favorite as FavoriteIcon,
FavoriteBorder as FavoriteOutlineIcon,
MoreVert as MoreIcon,
Delete as DeleteIcon,
Edit as EditIcon,
Search as SearchIcon,
Album as AlbumIcon,
Person as PersonIcon,
MusicNote as MusicIcon,
QueueMusic as PlaylistIcon,
CloudDownload as DownloadIcon,
} from '@mui/icons-material';
import api from '../api/client';
interface LocalAudio {
id: number;
title: string;
artist: string;
album: string;
year?: number;
genre: string;
duration: number;
duration_formatted: string;
file_url: string;
cover_art_url?: string;
file_size_mb: number;
audio_format: string;
bitrate?: number;
play_count: number;
is_favorite: boolean;
uploaded_date: string;
tags: string[];
notes: string;
}
interface LocalPlaylist {
id: number;
title: string;
description: string;
cover_image_url?: string;
items_count: number;
created_date: string;
}
interface LocalFilesPageProps {
currentAudio?: any;
onPlay: (audio: any) => void;
isPlaying: boolean;
}
const LocalFilesPage: React.FC<LocalFilesPageProps> = ({ currentAudio, onPlay, isPlaying }) => {
const [tab, setTab] = useState(0);
const [audioFiles, setAudioFiles] = useState<LocalAudio[]>([]);
const [playlists, setPlaylists] = useState<LocalPlaylist[]>([]);
const [loading, setLoading] = useState(false);
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploading, setUploading] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
// Upload metadata will be extracted from ID3 tags automatically
const [searchQuery, setSearchQuery] = useState('');
const [filterArtist, setFilterArtist] = useState('');
const [filterAlbum, setFilterAlbum] = useState('');
const [filterGenre, setFilterGenre] = useState('');
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
const [artists, setArtists] = useState<string[]>([]);
const [albums, setAlbums] = useState<any[]>([]);
const [genres, setGenres] = useState<string[]>([]);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [selectedAudio, setSelectedAudio] = useState<LocalAudio | null>(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [stats, setStats] = useState<any>({});
const [alert, setAlert] = useState<{ message: string; severity: 'success' | 'error' | 'info' } | null>(null);
useEffect(() => {
// Get CSRF token on page load
api.get('/audio/local-audio/', { params: { limit: 1 } }).catch(() => {});
loadAudioFiles();
loadFilters();
loadStats();
}, [searchQuery, filterArtist, filterAlbum, filterGenre, showFavoritesOnly]);
useEffect(() => {
if (tab === 1) {
loadPlaylists();
}
}, [tab]);
const loadAudioFiles = async () => {
setLoading(true);
try {
const params: any = {};
if (searchQuery) params.search = searchQuery;
if (filterArtist) params.artist = filterArtist;
if (filterAlbum) params.album = filterAlbum;
if (filterGenre) params.genre = filterGenre;
if (showFavoritesOnly) params.favorites = 'true';
const response = await api.get('/audio/local-audio/', { params });
const data = response.data || [];
setAudioFiles(Array.isArray(data) ? data : []);
} catch (error) {
console.error('Error loading audio files:', error);
setAudioFiles([]);
setAlert({ message: 'Failed to load audio files', severity: 'error' });
} finally {
setLoading(false);
}
};
const loadPlaylists = async () => {
try {
const response = await api.get('/audio/local-playlists/');
const data = response.data || [];
setPlaylists(Array.isArray(data) ? data : []);
} catch (error) {
console.error('Error loading playlists:', error);
setPlaylists([]);
}
};
const loadFilters = async () => {
try {
const [artistsRes, albumsRes, genresRes] = await Promise.all([
api.get('/audio/local-audio/artists/').catch(() => ({ data: [] })),
api.get('/audio/local-audio/albums/').catch(() => ({ data: [] })),
api.get('/audio/local-audio/genres/').catch(() => ({ data: [] })),
]);
setArtists(artistsRes.data);
setAlbums(albumsRes.data);
setGenres(genresRes.data);
} catch (error) {
console.error('Error loading filters:', error);
}
};
const loadStats = async () => {
try {
const response = await api.get('/audio/local-audio/stats/');
setStats(response.data);
} catch (error) {
console.error('Error loading stats:', error);
}
};
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setSelectedFile(file);
}
};
const handleUpload = async () => {
if (!selectedFile) return;
setUploading(true);
setUploadProgress(0);
const formData = new FormData();
formData.append('file', selectedFile);
try {
await api.post('/audio/local-audio/', formData, {
headers: {
'Content-Type': 'multipart/form-data',
'X-CSRFToken': getCookie('csrftoken') || ''
},
withCredentials: true,
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 1));
setUploadProgress(percentCompleted);
},
});
setAlert({ message: 'File uploaded! Metadata extracted from ID3 tags.', severity: 'success' });
setUploadDialogOpen(false);
setSelectedFile(null);
loadAudioFiles();
loadStats();
} catch (error: any) {
console.error('Upload error:', error);
setAlert({
message: error.response?.data?.error || error.response?.data?.detail || 'Failed to upload file',
severity: 'error',
});
} finally {
setUploading(false);
setUploadProgress(0);
}
};
// Helper function to get CSRF token from cookies
const getCookie = (name: string) => {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift();
return null;
};
const handlePlay = (audio: LocalAudio) => {
// Convert to format expected by player
const audioForPlayer = {
youtube_id: `local-${audio.id}`,
title: audio.title,
artist: audio.artist || 'Unknown Artist',
album: audio.album,
duration: audio.duration,
file_url: audio.file_url,
cover_art_url: audio.cover_art_url,
is_local: true,
};
onPlay(audioForPlayer);
};
const handleToggleFavorite = async (audio: LocalAudio) => {
try {
await api.post(`/audio/local-audio/${audio.id}/toggle_favorite/`);
loadAudioFiles();
} catch (error) {
console.error('Error toggling favorite:', error);
}
};
const handleDelete = async (audio: LocalAudio) => {
if (!confirm(`Delete "${audio.title}"?`)) return;
try {
await api.delete(`/audio/local-audio/${audio.id}/`);
setAlert({ message: 'File deleted successfully', severity: 'success' });
loadAudioFiles();
loadStats();
} catch (error) {
console.error('Error deleting file:', error);
setAlert({ message: 'Failed to delete file', severity: 'error' });
}
setAnchorEl(null);
};
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, audio: LocalAudio) => {
setAnchorEl(event.currentTarget);
setSelectedAudio(audio);
};
const handleMenuClose = () => {
setAnchorEl(null);
setSelectedAudio(null);
};
const clearFilters = () => {
setSearchQuery('');
setFilterArtist('');
setFilterAlbum('');
setFilterGenre('');
setShowFavoritesOnly(false);
};
return (
<Box sx={{ p: 3 }}>
{/* Header */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h4" sx={{ fontWeight: 600 }}>
My Local Files
</Typography>
<Button
variant="contained"
startIcon={<UploadIcon />}
onClick={() => setUploadDialogOpen(true)}
>
Upload Audio
</Button>
</Box>
{/* Alert */}
{alert && (
<Alert severity={alert.severity} onClose={() => setAlert(null)} sx={{ mb: 2 }}>
{alert.message}
</Alert>
)}
{/* Stats Cards */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} sm={6} md={3}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Total Files
</Typography>
<Typography variant="h4">{stats.total_files || 0}</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Artists
</Typography>
<Typography variant="h4">{stats.total_artists || 0}</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Total Size
</Typography>
<Typography variant="h4">{(stats.total_size_mb || 0).toFixed(0)} MB</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Favorites
</Typography>
<Typography variant="h4">{stats.favorites || 0}</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Tabs */}
<Tabs value={tab} onChange={(_, newValue) => setTab(newValue)} sx={{ mb: 2 }}>
<Tab label="Audio Files" />
<Tab label="Playlists" />
</Tabs>
{/* Tab Content */}
{tab === 0 && (
<>
{/* Filters */}
<Box sx={{ mb: 3, display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
sx={{ minWidth: 250 }}
/>
<FormControl sx={{ minWidth: 150 }}>
<InputLabel>Artist</InputLabel>
<Select
value={filterArtist}
onChange={(e) => setFilterArtist(e.target.value)}
label="Artist"
>
<MenuItem value="">All Artists</MenuItem>
{artists.map((artist) => (
<MenuItem key={artist} value={artist}>
{artist}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl sx={{ minWidth: 150 }}>
<InputLabel>Album</InputLabel>
<Select
value={filterAlbum}
onChange={(e) => setFilterAlbum(e.target.value)}
label="Album"
>
<MenuItem value="">All Albums</MenuItem>
{albums.map((album) => (
<MenuItem key={album.album} value={album.album}>
{album.album}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl sx={{ minWidth: 150 }}>
<InputLabel>Genre</InputLabel>
<Select
value={filterGenre}
onChange={(e) => setFilterGenre(e.target.value)}
label="Genre"
>
<MenuItem value="">All Genres</MenuItem>
{genres.map((genre) => (
<MenuItem key={genre} value={genre}>
{genre}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant={showFavoritesOnly ? 'contained' : 'outlined'}
startIcon={<FavoriteIcon />}
onClick={() => setShowFavoritesOnly(!showFavoritesOnly)}
>
Favorites
</Button>
<Button onClick={clearFilters}>Clear Filters</Button>
</Box>
{/* Audio Grid */}
{loading ? (
<LinearProgress />
) : audioFiles.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 8 }}>
<MusicIcon sx={{ fontSize: 80, color: 'text.secondary', mb: 2 }} />
<Typography variant="h6" color="textSecondary" gutterBottom>
No audio files yet
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
Upload your local audio files to start listening
</Typography>
<Button
variant="contained"
startIcon={<UploadIcon />}
onClick={() => setUploadDialogOpen(true)}
>
Upload Your First File
</Button>
</Box>
) : (
<Grid container spacing={2}>
{audioFiles.map((audio) => {
const isCurrentlyPlaying =
currentAudio?.youtube_id === `local-${audio.id}` && isPlaying;
return (
<Grid item xs={12} sm={6} md={4} lg={3} key={audio.id}>
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<CardMedia
sx={{
height: 200,
bgcolor: 'grey.200',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{audio.cover_art_url ? (
<img
src={audio.cover_art_url}
alt={audio.title}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<AlbumIcon sx={{ fontSize: 80, color: 'grey.400' }} />
)}
</CardMedia>
<CardContent sx={{ flexGrow: 1 }}>
<Typography variant="h6" noWrap title={audio.title}>
{audio.title}
</Typography>
<Typography variant="body2" color="textSecondary" noWrap>
{audio.artist || 'Unknown Artist'}
</Typography>
{audio.album && (
<Typography variant="caption" color="textSecondary" noWrap>
{audio.album}
</Typography>
)}
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="caption" color="textSecondary">
{audio.duration_formatted}
</Typography>
<Typography variant="caption" color="textSecondary">
{audio.audio_format.toUpperCase()}
</Typography>
</Box>
{audio.tags && audio.tags.length > 0 && (
<Box sx={{ mt: 1, display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{audio.tags.slice(0, 2).map((tag, idx) => (
<Chip key={idx} label={tag} size="small" />
))}
</Box>
)}
</CardContent>
<Box sx={{ p: 1, display: 'flex', justifyContent: 'space-between' }}>
<Box>
<IconButton
color="primary"
onClick={() => handlePlay(audio)}
>
{isCurrentlyPlaying ? <PauseIcon /> : <PlayIcon />}
</IconButton>
<IconButton
color={audio.is_favorite ? 'error' : 'default'}
onClick={() => handleToggleFavorite(audio)}
>
{audio.is_favorite ? <FavoriteIcon /> : <FavoriteOutlineIcon />}
</IconButton>
</Box>
<IconButton onClick={(e) => handleMenuOpen(e, audio)}>
<MoreIcon />
</IconButton>
</Box>
</Card>
</Grid>
);
})}
</Grid>
)}
</>
)}
{tab === 1 && (
<Box>
<Typography variant="body1" color="textSecondary">
Playlists feature coming soon...
</Typography>
</Box>
)}
{/* Context Menu */}
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
<MenuItem
onClick={() => {
if (selectedAudio) {
const link = document.createElement('a');
link.href = selectedAudio.file_url;
link.download = selectedAudio.title;
link.click();
}
handleMenuClose();
}}
>
<DownloadIcon sx={{ mr: 1 }} /> Download
</MenuItem>
<MenuItem
onClick={() => {
if (selectedAudio) handleDelete(selectedAudio);
}}
>
<DeleteIcon sx={{ mr: 1 }} /> Delete
</MenuItem>
</Menu>
{/* Upload Dialog */}
<Dialog
open={uploadDialogOpen}
onClose={() => !uploading && setUploadDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Upload Audio File</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2 }}>
<input
accept="audio/*"
style={{ display: 'none' }}
id="upload-file"
type="file"
onChange={handleFileSelect}
/>
<label htmlFor="upload-file">
<Button
variant="outlined"
component="span"
startIcon={<UploadIcon />}
fullWidth
sx={{ mb: 2 }}
>
Choose File
</Button>
</label>
{selectedFile && (
<Box sx={{ mt: 2, p: 2, bgcolor: 'rgba(19, 236, 106, 0.1)', borderRadius: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
{selectedFile.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{(selectedFile.size / (1024 * 1024)).toFixed(2)} MB Metadata will be read from ID3 tags
</Typography>
</Box>
)}
{uploading && (
<Box sx={{ mt: 2 }}>
<LinearProgress variant="determinate" value={uploadProgress} />
<Typography variant="body2" color="textSecondary" sx={{ mt: 1, textAlign: 'center' }}>
Uploading... {uploadProgress}%
</Typography>
</Box>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setUploadDialogOpen(false)} disabled={uploading}>
Cancel
</Button>
<Button
onClick={handleUpload}
variant="contained"
disabled={!selectedFile || uploading}
>
Upload
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default LocalFilesPage;

View file

@ -0,0 +1,533 @@
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
IconButton,
LinearProgress,
Alert,
Snackbar,
Tooltip,
} from '@mui/material';
import {
FolderOpen as FolderIcon,
PlayArrow as PlayIcon,
Delete as DeleteIcon,
Refresh as RefreshIcon,
Add as AddIcon,
} from '@mui/icons-material';
import { localAudioDB, type LocalAudioFile } from '../utils/localAudioDB';
import { extractMetadata, getAudioDuration } from '../utils/id3Reader';
import type { Audio } from '../types';
interface LocalFilesPageProps {
setCurrentAudio: (audio: Audio) => void;
}
export default function LocalFilesPage({ setCurrentAudio }: LocalFilesPageProps) {
const [audioFiles, setAudioFiles] = useState<LocalAudioFile[]>([]);
const [loading, setLoading] = useState(false);
const [alert, setAlert] = useState<{ message: string; severity: 'success' | 'error' | 'info' } | null>(null);
useEffect(() => {
loadFiles();
}, []);
const loadFiles = async () => {
try {
const files = await localAudioDB.getAll();
setAudioFiles(files);
} catch (error) {
console.error('Error loading files:', error);
}
};
const processFiles = async (files: File[]) => {
const processedFiles: LocalAudioFile[] = [];
for (const file of files) {
try {
const metadata = await extractMetadata(file);
const duration = await getAudioDuration(file);
const localFile: LocalAudioFile = {
id: `${Date.now()}-${Math.random()}`,
title: metadata.title || file.name,
artist: metadata.artist || 'Unknown Artist',
album: metadata.album || '',
year: metadata.year || null,
genre: metadata.genre || '',
duration,
file,
fileName: file.name,
fileSize: file.size,
mimeType: file.type,
coverArt: metadata.coverArt,
addedDate: new Date(),
playCount: 0,
};
processedFiles.push(localFile);
} catch (error) {
console.error(`Error processing ${file.name}:`, error);
}
}
return processedFiles;
};
const handleSelectFiles = async () => {
try {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.accept = 'audio/*';
input.onchange = async (e: Event) => {
const target = e.target as HTMLInputElement;
const files = Array.from(target.files || []);
if (files.length === 0) return;
setLoading(true);
setAlert({ message: `Processing ${files.length} files...`, severity: 'info' });
const processedFiles = await processFiles(files);
await localAudioDB.addFiles(processedFiles);
await loadFiles();
setLoading(false);
setAlert({ message: `Added ${processedFiles.length} files successfully!`, severity: 'success' });
};
input.click();
} catch (error) {
console.error('Error selecting files:', error);
setAlert({ message: 'Failed to select files', severity: 'error' });
setLoading(false);
}
};
const handleSelectFolder = async () => {
try {
// Check if File System Access API is supported
if (!('showDirectoryPicker' in window)) {
setAlert({
message: 'Folder selection not supported in this browser. Use Chrome, Edge, or Opera.',
severity: 'error'
});
return;
}
// Check if we're on HTTPS or localhost
const isSecureContext = window.isSecureContext;
if (!isSecureContext) {
setAlert({
message: 'Folder selection requires HTTPS or localhost. For local network access, use "Select Files" instead or access via https://sound.iulian.uk',
severity: 'info'
});
return;
}
const dirHandle = await (window as any).showDirectoryPicker({
mode: 'read',
});
setLoading(true);
setAlert({ message: 'Scanning folder and subfolders...', severity: 'info' });
const audioFiles: File[] = [];
const audioExtensions = ['.mp3', '.m4a', '.flac', '.wav', '.ogg', '.opus', '.aac', '.wma'];
// Recursive function to scan directory
async function scanDirectory(dirHandle: any, path = '') {
for await (const entry of dirHandle.values()) {
if (entry.kind === 'file') {
const file = await entry.getFile();
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
if (audioExtensions.includes(ext)) {
audioFiles.push(file);
}
} else if (entry.kind === 'directory') {
// Recursively scan subdirectory
await scanDirectory(entry, `${path}/${entry.name}`);
}
}
}
await scanDirectory(dirHandle);
if (audioFiles.length === 0) {
setLoading(false);
setAlert({ message: 'No audio files found in the selected folder', severity: 'info' });
return;
}
setAlert({ message: `Processing ${audioFiles.length} audio files...`, severity: 'info' });
const processedFiles = await processFiles(audioFiles);
await localAudioDB.addFiles(processedFiles);
await loadFiles();
setLoading(false);
setAlert({
message: `Successfully added ${processedFiles.length} files from folder!`,
severity: 'success'
});
} catch (error: any) {
console.error('Error selecting folder:', error);
if (error.name === 'AbortError') {
setAlert({ message: 'Folder selection cancelled', severity: 'info' });
} else {
setAlert({ message: 'Failed to read folder', severity: 'error' });
}
setLoading(false);
}
};
const handlePlay = async (localFile: LocalAudioFile) => {
try {
// Update play count
await localAudioDB.updatePlayCount(localFile.id);
// Create object URL for the file
const audioURL = localFile.file ? URL.createObjectURL(localFile.file) : '';
// Convert to Audio format expected by player
const audio: Audio = {
id: parseInt(localFile.id.split('-')[0]) || Date.now(),
youtube_id: undefined, // Local files don't have YouTube ID
title: localFile.title,
channel_name: localFile.artist,
channel_id: '',
description: `${localFile.album}${localFile.year ? ` (${localFile.year})` : ''}`,
thumbnail_url: localFile.coverArt || '/placeholder.jpg',
duration: localFile.duration,
file_size: localFile.fileSize,
file_path: audioURL,
media_url: audioURL, // THIS is what Player uses for local files
play_count: localFile.playCount,
published_date: localFile.addedDate.toISOString(),
downloaded_date: localFile.addedDate.toISOString(),
view_count: 0,
like_count: 0,
audio_format: localFile.mimeType.split('/')[1] || 'mp3',
artist: localFile.artist,
album: localFile.album,
cover_art_url: localFile.coverArt,
};
setCurrentAudio(audio);
} catch (error) {
console.error('Error playing file:', error);
setAlert({ message: 'Failed to play file', severity: 'error' });
}
};
const handleDelete = async (id: string) => {
if (!confirm('Remove this file from your library?')) return;
try {
await localAudioDB.delete(id);
await loadFiles();
setAlert({ message: 'File removed', severity: 'success' });
} catch (error) {
console.error('Error deleting file:', error);
setAlert({ message: 'Failed to remove file', severity: 'error' });
}
};
const handleClearAll = async () => {
if (!confirm('Remove ALL files from your library? This cannot be undone.')) return;
try {
await localAudioDB.clear();
await loadFiles();
setAlert({ message: 'All files removed', severity: 'success' });
} catch (error) {
console.error('Error clearing library:', error);
setAlert({ message: 'Failed to clear library', severity: 'error' });
}
};
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatFileSize = (bytes: number) => {
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(1)} MB`;
};
return (
<Box>
{/* Header */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3, px: 0.5, flexWrap: 'wrap', gap: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 700, letterSpacing: '-0.02em' }}>
My Local Files
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Button
variant="outlined"
size="small"
startIcon={<RefreshIcon />}
onClick={loadFiles}
sx={{
borderRadius: 1,
textTransform: 'none',
minWidth: 'auto',
px: 1.5,
py: 0.5,
fontSize: '0.813rem'
}}
>
Refresh
</Button>
{audioFiles.length > 0 && (
<Button
variant="outlined"
size="small"
color="error"
onClick={handleClearAll}
sx={{
borderRadius: 1,
textTransform: 'none',
minWidth: 'auto',
px: 1.5,
py: 0.5,
fontSize: '0.813rem'
}}
>
Clear All
</Button>
)}
<Button
variant="contained"
size="small"
startIcon={<AddIcon />}
onClick={handleSelectFiles}
disabled={loading}
sx={{
borderRadius: 1,
textTransform: 'none',
fontWeight: 600,
minWidth: 'auto',
px: 2,
py: 0.5,
fontSize: '0.813rem'
}}
>
Select Files
</Button>
<Tooltip
title={!window.isSecureContext
? 'Folder selection requires HTTPS or localhost. Currently viewing over HTTP. Use "Select Files" instead, or access via https://sound.iulian.uk'
: 'Select a folder to scan recursively including all subfolders'
}
arrow
>
<span>
<Button
variant="contained"
size="small"
startIcon={<FolderIcon />}
onClick={handleSelectFolder}
disabled={loading || !window.isSecureContext}
color="secondary"
sx={{
borderRadius: 1,
textTransform: 'none',
fontWeight: 600,
minWidth: 'auto',
px: 2,
py: 0.5,
fontSize: '0.813rem'
}}
>
Select Folder {!window.isSecureContext && '🔒'}
</Button>
</span>
</Tooltip>
</Box>
</Box>
{/* Info Alert */}
{audioFiles.length === 0 && !loading && (
<Alert severity="info" sx={{ mb: 3, borderRadius: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
No local files yet
</Typography>
<Typography variant="caption">
Click "Select Files" to choose individual audio files (works everywhere), or "Select Folder" to scan an entire folder including subfolders (requires HTTPS or localhost). Files are stored in your browser and play locally without uploading.
</Typography>
</Alert>
)}
{/* Loading */}
{loading && (
<Box sx={{ mb: 3 }}>
<LinearProgress />
</Box>
)}
{/* Files Table */}
{audioFiles.length > 0 && (
<TableContainer
component={Paper}
sx={{
bgcolor: 'background.paper',
borderRadius: 3,
border: '1px solid',
borderColor: 'rgba(255, 255, 255, 0.05)',
'& .MuiTableCell-root': {
borderColor: 'rgba(255, 255, 255, 0.05)',
padding: { xs: '6px 8px', sm: '8px 12px' },
fontSize: { xs: '0.7rem', sm: '0.8125rem' },
},
}}
>
<Table size="small">
<TableHead>
<TableRow>
<TableCell width={30} sx={{ fontWeight: 600, fontSize: { xs: '0.7rem', sm: '0.75rem' } }}>#</TableCell>
<TableCell sx={{ fontWeight: 600, fontSize: { xs: '0.7rem', sm: '0.75rem' } }}>Title</TableCell>
<TableCell sx={{ fontWeight: 600, display: { xs: 'none', sm: 'table-cell' }, fontSize: '0.75rem' }}>Artist</TableCell>
<TableCell sx={{ fontWeight: 600, display: { xs: 'none', md: 'table-cell' }, fontSize: '0.75rem' }}>Album</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, fontSize: { xs: '0.7rem', sm: '0.75rem' } }}>Duration</TableCell>
<TableCell align="center" width={80} sx={{ fontWeight: 600, fontSize: { xs: '0.7rem', sm: '0.75rem' } }}>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{audioFiles.map((file, index) => (
<TableRow
key={file.id}
sx={{
cursor: 'pointer',
transition: 'background-color 0.3s ease',
'&:hover': {
bgcolor: 'rgba(19, 236, 106, 0.05)',
},
}}
onClick={() => handlePlay(file)}
>
<TableCell sx={{ color: 'text.secondary', fontSize: { xs: '0.7rem', sm: '0.75rem' } }}>
{index + 1}
</TableCell>
<TableCell>
<Box>
<Typography
variant="body2"
noWrap
sx={{
maxWidth: { xs: 150, sm: 200, md: 300 },
fontWeight: 500,
fontSize: { xs: '0.75rem', sm: '0.813rem' },
lineHeight: 1.3,
}}
>
{file.title}
</Typography>
<Typography
variant="caption"
noWrap
sx={{
display: { xs: 'block', sm: 'none' },
color: 'text.secondary',
fontSize: '0.65rem',
maxWidth: 150,
}}
>
{file.artist}
</Typography>
</Box>
</TableCell>
<TableCell sx={{ display: { xs: 'none', sm: 'table-cell' } }}>
<Typography
variant="body2"
color="text.secondary"
noWrap
sx={{
maxWidth: 120,
fontSize: '0.75rem',
}}
>
{file.artist}
</Typography>
</TableCell>
<TableCell sx={{ display: { xs: 'none', md: 'table-cell' } }}>
<Typography
variant="body2"
color="text.secondary"
noWrap
sx={{
maxWidth: 150,
fontSize: '0.75rem',
}}
>
{file.album || '-'}
</Typography>
</TableCell>
<TableCell align="right" sx={{ color: 'text.secondary', fontSize: { xs: '0.7rem', sm: '0.75rem' } }}>
{formatDuration(file.duration)}
</TableCell>
<TableCell align="center" onClick={(e) => e.stopPropagation()}>
<IconButton
size="small"
onClick={() => handlePlay(file)}
sx={{
color: 'primary.main',
padding: { xs: '4px', sm: '6px' },
'&:hover': {
bgcolor: 'rgba(19, 236, 106, 0.1)',
},
}}
>
<PlayIcon sx={{ fontSize: { xs: '1rem', sm: '1.2rem' } }} />
</IconButton>
<IconButton
size="small"
onClick={() => handleDelete(file.id)}
sx={{
color: 'error.main',
padding: { xs: '4px', sm: '6px' },
'&:hover': {
bgcolor: 'rgba(255, 0, 0, 0.1)',
},
}}
>
<DeleteIcon sx={{ fontSize: { xs: '1rem', sm: '1.2rem' } }} />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{/* Snackbar for alerts */}
<Snackbar
open={!!alert}
autoHideDuration={4000}
onClose={() => setAlert(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert onClose={() => setAlert(null)} severity={alert?.severity} sx={{ borderRadius: 2 }}>
{alert?.message}
</Alert>
</Snackbar>
</Box>
);
}

View file

@ -0,0 +1,399 @@
import { useState } from 'react';
import {
Box,
TextField,
Button,
Checkbox,
FormControlLabel,
Typography,
InputAdornment,
IconButton,
} from '@mui/material';
import PersonOutlineIcon from '@mui/icons-material/PersonOutline';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import { userAPI } from '../api/client';
interface LoginPageProps {
onLoginSuccess: () => void;
}
export default function LoginPage({ onLoginSuccess }: LoginPageProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [rememberPassword, setRememberPassword] = useState(false);
const [error, setError] = useState('');
const [requires2FA, setRequires2FA] = useState(false);
const [twoFactorCode, setTwoFactorCode] = useState('');
const handleLogin = async () => {
try {
const response = await userAPI.login({
username,
password,
...(requires2FA && { two_factor_code: twoFactorCode })
});
if (response.data.requires_2fa) {
setRequires2FA(true);
setError('Please enter your two-factor authentication code');
return;
}
localStorage.setItem('token', response.data.token);
onLoginSuccess();
} catch (err: any) {
if (err.response?.data?.requires_2fa) {
setRequires2FA(true);
setError('Please enter your two-factor authentication code');
} else {
setError('Invalid credentials or verification code');
}
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleLogin();
}
};
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
width: '100vw',
background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%)',
position: 'relative',
overflow: 'auto',
}}
>
{/* Logo Section - Top Half */}
<Box
sx={{
flex: { xs: 0.6, sm: 1 },
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: { xs: 3, sm: 4 },
paddingTop: { xs: 6, sm: 8 },
}}
>
{/* Animated Logo */}
<Box
component="img"
src="/img/logo.png"
alt="SoundWave Logo"
sx={{
width: { xs: 180, sm: 220 },
height: { xs: 180, sm: 220 },
borderRadius: '50%',
mb: 3,
boxShadow: '0 20px 60px rgba(34, 211, 238, 0.3)',
border: '4px solid rgba(34, 211, 238, 0.2)',
position: 'relative',
animation: 'pulse 2s ease-in-out infinite',
'@keyframes pulse': {
'0%, 100%': {
transform: 'scale(1)',
boxShadow: '0 20px 60px rgba(34, 211, 238, 0.3)',
},
'50%': {
transform: 'scale(1.05)',
boxShadow: '0 25px 70px rgba(34, 211, 238, 0.5)',
},
},
}}
/>
{/* App Name */}
<Typography
sx={{
fontSize: { xs: '2.5rem', sm: '3.5rem' },
fontWeight: 800,
letterSpacing: '-0.03em',
color: '#22d3ee',
textAlign: 'center',
textShadow: '0 4px 20px rgba(34, 211, 238, 0.4)',
mb: 1,
}}
>
SoundWave
</Typography>
<Typography
sx={{
fontSize: { xs: '0.9rem', sm: '1rem' },
color: '#94a3b8',
textAlign: 'center',
fontWeight: 500,
}}
>
Your Personal Music Hub
</Typography>
</Box>
{/* Login Form Section - Bottom Half */}
<Box
sx={{
flex: { xs: 1, sm: 1.2 },
backgroundColor: 'rgba(30, 41, 59, 0.8)',
backdropFilter: 'blur(20px)',
borderTopLeftRadius: { xs: 32, sm: 40 },
borderTopRightRadius: { xs: 32, sm: 40 },
padding: { xs: 3, sm: 4 },
paddingTop: { xs: 4, sm: 5 },
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
border: '1px solid rgba(34, 211, 238, 0.1)',
borderBottom: 'none',
}}
>
<Box
sx={{
width: '100%',
maxWidth: { xs: '100%', sm: 420 },
padding: { xs: 0, sm: 2 },
}}
>
<Typography
variant="h4"
sx={{
color: '#f8fafc',
fontWeight: 700,
marginBottom: { xs: 3, sm: 4 },
fontSize: { xs: '1.5rem', sm: '1.75rem' },
textAlign: 'center',
}}
>
Welcome Back
</Typography>
{/* Username Field */}
<TextField
fullWidth
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyPress={handleKeyPress}
autoFocus
sx={{
marginBottom: 2,
'& .MuiOutlinedInput-root': {
backgroundColor: 'rgba(15, 23, 42, 0.6)',
borderRadius: 3,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
'& fieldset': {
borderColor: 'rgba(34, 211, 238, 0.2)',
borderWidth: 2,
},
'&:hover fieldset': {
borderColor: 'rgba(34, 211, 238, 0.4)',
},
'&.Mui-focused fieldset': {
borderColor: '#22d3ee',
borderWidth: 2,
},
},
'& .MuiOutlinedInput-input': {
padding: { xs: '14px 16px', sm: '16px 18px' },
fontSize: '0.95rem',
color: '#f8fafc',
},
}}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<PersonOutlineIcon sx={{ color: '#94a3b8', fontSize: 22 }} />
</InputAdornment>
),
}}
/>
{/* Password Field */}
<TextField
fullWidth
type={showPassword ? 'text' : 'password'}
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyPress={handleKeyPress}
sx={{
marginBottom: 1.5,
'& .MuiOutlinedInput-root': {
backgroundColor: 'rgba(15, 23, 42, 0.6)',
borderRadius: 3,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
'& fieldset': {
borderColor: 'rgba(34, 211, 238, 0.2)',
borderWidth: 2,
},
'&:hover fieldset': {
borderColor: 'rgba(34, 211, 238, 0.4)',
},
'&.Mui-focused fieldset': {
borderColor: '#22d3ee',
borderWidth: 2,
},
},
'& .MuiOutlinedInput-input': {
padding: { xs: '14px 16px', sm: '16px 18px' },
fontSize: '0.95rem',
color: '#f8fafc',
},
}}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<LockOutlinedIcon sx={{ color: '#94a3b8', fontSize: 22 }} />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
sx={{
backgroundColor: 'rgba(34, 211, 238, 0.2)',
borderRadius: '50%',
width: 36,
height: 36,
'&:hover': {
backgroundColor: 'rgba(34, 211, 238, 0.3)',
},
}}
>
{showPassword ? (
<VisibilityOffOutlinedIcon sx={{ color: '#22d3ee', fontSize: 18 }} />
) : (
<VisibilityOutlinedIcon sx={{ color: '#22d3ee', fontSize: 18 }} />
)}
</IconButton>
</InputAdornment>
),
}}
/>
{/* Two-Factor Code Field - shown only when 2FA is required */}
{requires2FA && (
<TextField
fullWidth
placeholder="Two-Factor Code"
value={twoFactorCode}
onChange={(e) => setTwoFactorCode(e.target.value)}
onKeyPress={handleKeyPress}
autoFocus
inputProps={{ maxLength: 6 }}
sx={{
marginBottom: 1.5,
'& .MuiOutlinedInput-root': {
backgroundColor: 'rgba(15, 23, 42, 0.6)',
borderRadius: 3,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
'& fieldset': {
borderColor: 'rgba(34, 211, 238, 0.2)',
borderWidth: 2,
},
'&:hover fieldset': {
borderColor: 'rgba(34, 211, 238, 0.4)',
},
'&.Mui-focused fieldset': {
borderColor: '#22d3ee',
borderWidth: 2,
},
},
'& .MuiOutlinedInput-input': {
padding: '14px 16px',
fontSize: '0.95rem',
color: '#f8fafc',
textAlign: 'center',
letterSpacing: '0.5em',
},
}}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<LockOutlinedIcon sx={{ color: '#94a3b8', fontSize: 22 }} />
</InputAdornment>
),
}}
/>
)}
{/* Remember Password Checkbox */}
<FormControlLabel
control={
<Checkbox
checked={rememberPassword}
onChange={(e) => setRememberPassword(e.target.checked)}
sx={{
color: '#475569',
'&.Mui-checked': {
color: '#22d3ee',
},
}}
/>
}
label={
<Typography sx={{ color: '#94a3b8', fontSize: '0.9rem' }}>
Remember Password
</Typography>
}
sx={{ marginBottom: { xs: 2.5, sm: 3 } }}
/>
{/* Login Button */}
<Button
fullWidth
variant="contained"
onClick={handleLogin}
sx={{
background: 'linear-gradient(135deg, #22d3ee 0%, #06b6d4 100%)',
color: '#0f172a',
padding: { xs: '14px', sm: '16px' },
borderRadius: 3,
fontSize: '0.95rem',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.5px',
boxShadow: '0 4px 20px rgba(34, 211, 238, 0.4)',
border: '1px solid rgba(34, 211, 238, 0.3)',
'&:hover': {
background: 'linear-gradient(135deg, #06b6d4 0%, #0891b2 100%)',
boxShadow: '0 6px 30px rgba(34, 211, 238, 0.6)',
transform: 'translateY(-2px)',
},
transition: 'all 0.3s ease',
}}
endIcon={<ArrowForwardIcon />}
>
Login
</Button>
{error && (
<Typography
sx={{
color: '#ef4444',
fontSize: '0.875rem',
marginTop: 2,
textAlign: 'center',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
padding: '8px 12px',
borderRadius: 2,
border: '1px solid rgba(239, 68, 68, 0.2)',
}}
>
{error}
</Typography>
)}
</Box>
</Box>
</Box>
);
}

View file

@ -0,0 +1,391 @@
import { useState } from 'react';
import {
Box,
TextField,
Button,
Checkbox,
FormControlLabel,
Typography,
InputAdornment,
IconButton,
} from '@mui/material';
import PersonOutlineIcon from '@mui/icons-material/PersonOutline';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import { userAPI } from '../api/client';
interface LoginPageProps {
onLoginSuccess: () => void;
}
export default function LoginPage({ onLoginSuccess }: LoginPageProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [rememberPassword, setRememberPassword] = useState(false);
const [error, setError] = useState('');
const [requires2FA, setRequires2FA] = useState(false);
const [twoFactorCode, setTwoFactorCode] = useState('');
const handleLogin = async () => {
try {
const response = await userAPI.login({
username,
password,
...(requires2FA && { two_factor_code: twoFactorCode })
});
if (response.data.requires_2fa) {
setRequires2FA(true);
setError('Please enter your two-factor authentication code');
return;
}
localStorage.setItem('token', response.data.token);
onLoginSuccess();
} catch (err: any) {
if (err.response?.data?.requires_2fa) {
setRequires2FA(true);
setError('Please enter your two-factor authentication code');
} else {
setError('Invalid credentials or verification code');
}
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleLogin();
}
};
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
width: '100vw',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
position: 'relative',
overflow: 'auto',
}}
>
{/* Logo Section - Top Half */}
<Box
sx={{
flex: { xs: 0.6, sm: 1 },
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: { xs: 3, sm: 4 },
paddingTop: { xs: 6, sm: 8 },
}}
>
{/* Animated Logo */}
<Box
component="img"
src="/img/logo.svg"
alt="SoundWave Logo"
sx={{
width: { xs: 180, sm: 220 },
height: { xs: 180, sm: 220 },
borderRadius: '50%',
mb: 3,
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
border: '4px solid rgba(255, 255, 255, 0.2)',
position: 'relative',
animation: 'pulse 2s ease-in-out infinite',
'@keyframes pulse': {
'0%, 100%': {
transform: 'scale(1)',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
},
'50%': {
transform: 'scale(1.05)',
boxShadow: '0 25px 70px rgba(0, 0, 0, 0.4)',
},
},
}}
/>
{/* App Name */}
<Typography
sx={{
fontSize: { xs: '2.5rem', sm: '3.5rem' },
fontWeight: 800,
letterSpacing: '-0.03em',
color: 'white',
textAlign: 'center',
textShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
mb: 1,
}}
>
SoundWave
</Typography>
<Typography
sx={{
fontSize: { xs: '0.9rem', sm: '1rem' },
color: 'rgba(255, 255, 255, 0.8)',
textAlign: 'center',
fontWeight: 500,
}}
>
Your Personal Music Hub
</Typography>
</Box>
{/* Login Form Section - Bottom Half */}
<Box
sx={{
flex: { xs: 1, sm: 1.2 },
backgroundColor: '#F1F3F4',
borderTopLeftRadius: { xs: 32, sm: 40 },
borderTopRightRadius: { xs: 32, sm: 40 },
padding: { xs: 3, sm: 4 },
paddingTop: { xs: 4, sm: 5 },
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Box
sx={{
width: '100%',
maxWidth: { xs: '100%', sm: 420 },
padding: { xs: 0, sm: 2 },
}}
>
<Typography
variant="h4"
sx={{
color: '#2D3748',
fontWeight: 700,
marginBottom: { xs: 3, sm: 4 },
fontSize: { xs: '1.5rem', sm: '1.75rem' },
textAlign: 'center',
}}
>
Login Here!
</Typography>
{/* Username Field */}
<TextField
fullWidth
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyPress={handleKeyPress}
autoFocus
sx={{
marginBottom: 2,
'& .MuiOutlinedInput-root': {
backgroundColor: 'white',
borderRadius: 3,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
'& fieldset': {
borderColor: 'transparent',
borderWidth: 2,
},
'&:hover fieldset': {
borderColor: 'rgba(102, 126, 234, 0.3)',
},
'&.Mui-focused fieldset': {
borderColor: '#667eea',
borderWidth: 2,
},
},
'& .MuiOutlinedInput-input': {
padding: { xs: '14px 16px', sm: '16px 18px' },
fontSize: '0.95rem',
color: '#2D3748',
},
}}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<PersonOutlineIcon sx={{ color: '#A0AEC0', fontSize: 22 }} />
</InputAdornment>
),
}}
/>
{/* Password Field */}
<TextField
fullWidth
type={showPassword ? 'text' : 'password'}
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyPress={handleKeyPress}
sx={{
marginBottom: 1.5,
'& .MuiOutlinedInput-root': {
backgroundColor: 'white',
borderRadius: 3,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
'& fieldset': {
borderColor: 'transparent',
borderWidth: 2,
},
'&:hover fieldset': {
borderColor: 'rgba(102, 126, 234, 0.3)',
},
'&.Mui-focused fieldset': {
borderColor: '#667eea',
borderWidth: 2,
},
},
'& .MuiOutlinedInput-input': {
padding: { xs: '14px 16px', sm: '16px 18px' },
fontSize: '0.95rem',
color: '#2D3748',
},
}}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<LockOutlinedIcon sx={{ color: '#A0AEC0', fontSize: 22 }} />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
sx={{
backgroundColor: '#4A5568',
borderRadius: '50%',
width: 36,
height: 36,
'&:hover': {
backgroundColor: '#2D3748',
},
}}
>
{showPassword ? (
<VisibilityOffOutlinedIcon sx={{ color: 'white', fontSize: 18 }} />
) : (
<VisibilityOutlinedIcon sx={{ color: 'white', fontSize: 18 }} />
)}
</IconButton>
</InputAdornment>
),
}}
/>
{/* Two-Factor Code Field - shown only when 2FA is required */}
{requires2FA && (
<TextField
fullWidth
placeholder="Two-Factor Code"
value={twoFactorCode}
onChange={(e) => setTwoFactorCode(e.target.value)}
onKeyPress={handleKeyPress}
autoFocus
inputProps={{ maxLength: 6 }}
sx={{
marginBottom: 1.5,
'& .MuiOutlinedInput-root': {
backgroundColor: 'white',
borderRadius: 3,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
'& fieldset': {
borderColor: 'transparent',
borderWidth: 2,
},
'&:hover fieldset': {
borderColor: 'rgba(102, 126, 234, 0.3)',
},
'&.Mui-focused fieldset': {
borderColor: '#667eea',
borderWidth: 2,
},
},
'& .MuiOutlinedInput-input': {
padding: '14px 16px',
fontSize: '0.95rem',
color: '#2D3748',
textAlign: 'center',
letterSpacing: '0.5em',
},
}}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<LockOutlinedIcon sx={{ color: '#A0AEC0', fontSize: 22 }} />
</InputAdornment>
),
}}
/>
)}
{/* Remember Password Checkbox */}
<FormControlLabel
control={
<Checkbox
checked={rememberPassword}
onChange={(e) => setRememberPassword(e.target.checked)}
sx={{
color: '#CBD5E0',
'&.Mui-checked': {
color: '#667eea',
},
}}
/>
}
label={
<Typography sx={{ color: '#718096', fontSize: '0.9rem' }}>
Remember Password
</Typography>
}
sx={{ marginBottom: { xs: 2.5, sm: 3 } }}
/>
{/* Login Button */}
<Button
fullWidth
variant="contained"
onClick={handleLogin}
sx={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
padding: { xs: '14px', sm: '16px' },
borderRadius: 3,
fontSize: '0.95rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
boxShadow: '0 4px 15px rgba(102, 126, 234, 0.4)',
'&:hover': {
background: 'linear-gradient(135deg, #5a67d8 0%, #6b3fa0 100%)',
boxShadow: '0 6px 20px rgba(102, 126, 234, 0.5)',
transform: 'translateY(-1px)',
},
transition: 'all 0.2s ease',
}}
endIcon={<ArrowForwardIcon />}
>
Login
</Button>
{error && (
<Typography
sx={{
color: '#E53E3E',
fontSize: '0.875rem',
marginTop: 2,
textAlign: 'center',
}}
>
{error}
</Typography>
)}
</Box>
</Box>
</Box>
);
}

View file

@ -0,0 +1,359 @@
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Card,
CardContent,
Button,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Chip,
Alert,
LinearProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Divider,
CircularProgress,
} from '@mui/material';
import {
Delete as DeleteIcon,
CloudDone as CloudDoneIcon,
Storage as StorageIcon,
Refresh as RefreshIcon,
CloudOff as CloudOffIcon,
CheckCircle as CheckCircleIcon,
} from '@mui/icons-material';
import { usePWA } from '../context/PWAContext';
import { offlineStorage } from '../utils/offlineStorage';
import { useNavigate } from 'react-router-dom';
interface CachedPlaylist {
id: number;
playlist_id: string;
title: string;
channel_name: string;
item_count: number;
downloaded_count: number;
offline: boolean;
lastSync: number;
}
export default function OfflineManagerPage() {
const navigate = useNavigate();
const { isOnline, cacheSize, clearCache, removePlaylistCache } = usePWA();
const [cachedPlaylists, setCachedPlaylists] = useState<CachedPlaylist[]>([]);
const [loading, setLoading] = useState(true);
const [confirmDialog, setConfirmDialog] = useState<{ open: boolean; playlistId?: string; title?: string }>({
open: false,
});
const [clearAllDialog, setClearAllDialog] = useState(false);
useEffect(() => {
loadCachedPlaylists();
}, []);
const loadCachedPlaylists = async () => {
try {
const playlists = await offlineStorage.getOfflinePlaylists();
setCachedPlaylists(playlists);
} catch (err) {
console.error('Failed to load cached playlists:', err);
} finally {
setLoading(false);
}
};
const handleRemovePlaylist = async (playlist: CachedPlaylist) => {
try {
// Build audio URLs
const audioUrls = (playlist as any).items?.map((item: any) =>
`/api/audio/${item.audio.youtube_id}/download/`
) || [];
// Remove from cache
await removePlaylistCache(playlist.playlist_id, audioUrls);
// Remove from IndexedDB
await offlineStorage.removePlaylist(playlist.id);
// Reload list
await loadCachedPlaylists();
setConfirmDialog({ open: false });
} catch (err) {
console.error('Failed to remove playlist:', err);
alert('Failed to remove offline data');
}
};
const handleClearAll = async () => {
try {
// Clear all caches
await clearCache();
// Clear IndexedDB playlists
await offlineStorage.clearAllData();
// Reload list
await loadCachedPlaylists();
setClearAllDialog(false);
} catch (err) {
console.error('Failed to clear all:', err);
alert('Failed to clear all offline data');
}
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
const formatDate = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
<CircularProgress />
</Box>
);
}
return (
<Box sx={{ maxWidth: 800, mx: 'auto', px: 2 }}>
{/* Header */}
<Box sx={{ mb: 4 }}>
<Typography variant="h5" sx={{ fontWeight: 700, letterSpacing: '-0.02em', mb: 1 }}>
Offline Storage
</Typography>
<Typography variant="body2" color="text.secondary">
Manage playlists cached for offline playback
</Typography>
</Box>
{/* Online Status */}
{!isOnline && (
<Alert severity="warning" icon={<CloudOffIcon />} sx={{ mb: 3 }}>
You are currently offline. You can only play cached content.
</Alert>
)}
{/* Storage Info */}
{cacheSize && (
<Card sx={{ mb: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<StorageIcon color="primary" sx={{ fontSize: 32 }} />
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Storage Used
</Typography>
<Typography variant="h5" sx={{ fontWeight: 700, color: 'primary.main' }}>
{formatBytes(cacheSize.usage)}
</Typography>
</Box>
<Box sx={{ textAlign: 'right' }}>
<Typography variant="caption" color="text.secondary">
Available
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatBytes(cacheSize.quota - cacheSize.usage)}
</Typography>
</Box>
</Box>
<LinearProgress
variant="determinate"
value={(cacheSize.usage / cacheSize.quota) * 100}
sx={{ height: 8, borderRadius: 1 }}
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
{((cacheSize.usage / cacheSize.quota) * 100).toFixed(1)}% of {formatBytes(cacheSize.quota)} used
</Typography>
</CardContent>
</Card>
)}
{/* Cached Playlists */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CloudDoneIcon color="success" />
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Offline Playlists
</Typography>
<Chip label={cachedPlaylists.length} size="small" color="success" />
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
startIcon={<RefreshIcon />}
onClick={loadCachedPlaylists}
>
Refresh
</Button>
{cachedPlaylists.length > 0 && (
<Button
size="small"
color="error"
startIcon={<DeleteIcon />}
onClick={() => setClearAllDialog(true)}
>
Clear All
</Button>
)}
</Box>
</Box>
<Divider sx={{ mb: 2 }} />
{cachedPlaylists.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<CloudOffIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 2, opacity: 0.3 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
No playlists cached for offline use
</Typography>
<Button variant="contained" onClick={() => navigate('/playlists')}>
Browse Playlists
</Button>
</Box>
) : (
<List disablePadding>
{cachedPlaylists.map((playlist, index) => (
<Box key={playlist.id}>
{index > 0 && <Divider />}
<ListItem
sx={{
py: 2,
cursor: 'pointer',
'&:hover': {
bgcolor: 'rgba(255, 255, 255, 0.05)',
},
}}
onClick={() => navigate(`/playlists/${playlist.playlist_id}`)}
>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{playlist.title}
</Typography>
<CheckCircleIcon sx={{ fontSize: 16, color: 'success.main' }} />
</Box>
}
secondary={
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
{playlist.channel_name}
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Chip
label={`${playlist.downloaded_count} tracks`}
size="small"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
<Chip
label={`Cached ${formatDate(playlist.lastSync)}`}
size="small"
sx={{ height: 20, fontSize: '0.7rem' }}
variant="outlined"
/>
</Box>
</Box>
}
/>
<ListItemSecondaryAction>
<IconButton
edge="end"
color="error"
onClick={(e) => {
e.stopPropagation();
setConfirmDialog({
open: true,
playlistId: playlist.playlist_id,
title: playlist.title,
});
}}
>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</Box>
))}
</List>
)}
</CardContent>
</Card>
{/* Remove Confirmation Dialog */}
<Dialog
open={confirmDialog.open}
onClose={() => setConfirmDialog({ open: false })}
>
<DialogTitle>Remove Offline Playlist?</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to remove "{confirmDialog.title}" from offline storage?
You'll need to re-download it to play offline.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmDialog({ open: false })}>
Cancel
</Button>
<Button
color="error"
variant="contained"
onClick={() => {
const playlist = cachedPlaylists.find(
(p) => p.playlist_id === confirmDialog.playlistId
);
if (playlist) handleRemovePlaylist(playlist);
}}
>
Remove
</Button>
</DialogActions>
</Dialog>
{/* Clear All Confirmation Dialog */}
<Dialog open={clearAllDialog} onClose={() => setClearAllDialog(false)}>
<DialogTitle>Clear All Offline Data?</DialogTitle>
<DialogContent>
<Typography sx={{ mb: 2 }}>
This will remove all cached playlists and free up storage space.
You'll need to re-download playlists to use them offline.
</Typography>
<Alert severity="warning">
This action cannot be undone.
</Alert>
</DialogContent>
<DialogActions>
<Button onClick={() => setClearAllDialog(false)}>
Cancel
</Button>
<Button color="error" variant="contained" onClick={handleClearAll}>
Clear All
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

@ -0,0 +1,723 @@
import { useState, useEffect } from 'react';
import {
Box,
Typography,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
CircularProgress,
Alert,
Chip,
LinearProgress,
Button,
Snackbar,
Tooltip,
Card,
} from '@mui/material';
import {
PlayArrow as PlayIcon,
Pause as PauseIcon,
ArrowBack as BackIcon,
Download as DownloadIcon,
Shuffle as ShuffleIcon,
CloudDownload as CloudDownloadIcon,
CloudOff as CloudOffIcon,
DeleteOutline as DeleteIcon,
CloudDone as CloudDoneIcon,
WifiOff as WifiOffIcon,
Storage as StorageIcon,
} from '@mui/icons-material';
import { useParams, useNavigate } from 'react-router-dom';
import { playlistAPI, audioAPI } from '../api/client';
import { usePWA } from '../context/PWAContext';
import { offlineStorage } from '../utils/offlineStorage';
import type { Audio } from '../types';
interface PlaylistItem {
id: number;
position: number;
added_date: string;
audio: Audio;
}
interface PlaylistDetail {
id: number;
playlist_id: string;
title: string;
description: string;
channel_name: string;
thumbnail_url: string;
subscribed: boolean;
item_count: number;
downloaded_count: number;
last_refresh: string | null;
sync_status: 'pending' | 'syncing' | 'success' | 'failed' | 'stale';
status_display: string;
error_message: string;
active: boolean;
progress_percent: number;
items?: PlaylistItem[];
}
interface PlaylistDetailPageProps {
setCurrentAudio: (audio: Audio, queue?: Audio[]) => void;
}
export default function PlaylistDetailPage({ setCurrentAudio }: PlaylistDetailPageProps) {
const { playlistId } = useParams<{ playlistId: string }>();
const navigate = useNavigate();
const { isOnline, cachePlaylist, removePlaylistCache, cacheSize } = usePWA();
const [playlist, setPlaylist] = useState<PlaylistDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [isOfflineAvailable, setIsOfflineAvailable] = useState(false);
const [isDownloadingOffline, setIsDownloadingOffline] = useState(false);
const [offlineProgress, setOfflineProgress] = useState(0);
useEffect(() => {
if (playlistId) {
loadPlaylist();
checkOfflineAvailability();
}
}, [playlistId]);
const checkOfflineAvailability = async () => {
if (!playlistId) return;
try {
const cachedPlaylist = await offlineStorage.getPlaylist(playlistId);
setIsOfflineAvailable(cachedPlaylist?.offline || false);
} catch (err) {
console.error('Failed to check offline status:', err);
}
};
const loadPlaylist = async () => {
try {
setLoading(true);
const response = await playlistAPI.getWithItems(playlistId!);
setPlaylist(response.data);
setError('');
} catch (err: any) {
console.error('Failed to load playlist:', err);
setError(err.response?.data?.detail || 'Failed to load playlist');
} finally {
setLoading(false);
}
};
const handleDownloadPlaylist = async () => {
if (!playlist?.items) return;
const downloadedTracks = playlist.items.filter(item => item.audio.file_path && item.audio.youtube_id);
if (downloadedTracks.length === 0) {
setSnackbarMessage('No downloaded tracks to save');
setSnackbarOpen(true);
return;
}
setSnackbarMessage(`Downloading ${downloadedTracks.length} tracks to your device`);
setSnackbarOpen(true);
// Download each track with authentication
for (const item of downloadedTracks) {
try {
const blob = await audioAPI.downloadFile(item.audio.youtube_id!);
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = item.audio.title || 'audio';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
// Stagger downloads to prevent browser blocking
await new Promise(resolve => setTimeout(resolve, 500));
} catch (err) {
console.error(`Failed to download ${item.audio.title}:`, err);
}
}
};
const handleCacheForOffline = async () => {
if (!playlist?.items || !isOnline) {
setSnackbarMessage(isOnline ? 'No tracks to cache' : 'Must be online to download for offline use');
setSnackbarOpen(true);
return;
}
const downloadedTracks = playlist.items.filter(item => item.audio.file_path);
if (downloadedTracks.length === 0) {
setSnackbarMessage('No tracks downloaded yet. Download tracks first.');
setSnackbarOpen(true);
return;
}
setIsDownloadingOffline(true);
setOfflineProgress(0);
setSnackbarMessage(`Caching ${downloadedTracks.length} tracks for offline...`);
setSnackbarOpen(true);
try {
// Build audio URLs for caching
const audioUrls = downloadedTracks.map(item =>
`/api/audio/${item.audio.youtube_id}/download/`
);
// Cache playlist metadata and audio files via Service Worker
const cached = await cachePlaylist(playlist.playlist_id, audioUrls);
if (cached) {
// Save playlist metadata to IndexedDB
await offlineStorage.savePlaylist({
id: playlist.id,
playlist_id: playlist.playlist_id,
title: playlist.title,
description: playlist.description,
channel_name: playlist.channel_name,
thumbnail_url: playlist.thumbnail_url,
item_count: playlist.item_count,
downloaded_count: downloadedTracks.length,
items: downloadedTracks.map(item => ({
id: item.id,
position: item.position,
audio: item.audio,
})),
offline: true,
lastSync: Date.now(),
});
setIsOfflineAvailable(true);
setSnackbarMessage(`${downloadedTracks.length} tracks available offline!`);
} else {
setSnackbarMessage('Failed to cache playlist');
}
} catch (err) {
console.error('Failed to cache offline:', err);
setSnackbarMessage('Offline caching failed');
} finally {
setIsDownloadingOffline(false);
setOfflineProgress(0);
setSnackbarOpen(true);
}
};
const handleRemoveOffline = async () => {
if (!playlist?.items) return;
try {
const audioUrls = playlist.items
.filter(item => item.audio.file_path)
.map(item => `/api/audio/${item.audio.youtube_id}/download/`);
// Remove from Service Worker cache
await removePlaylistCache(playlist.playlist_id, audioUrls);
// Remove from IndexedDB
await offlineStorage.removePlaylist(playlist.id);
setIsOfflineAvailable(false);
setSnackbarMessage('Offline data removed');
setSnackbarOpen(true);
} catch (err) {
console.error('Failed to remove offline data:', err);
setSnackbarMessage('Failed to remove offline data');
setSnackbarOpen(true);
}
};
const handleDownloadTrackToDevice = async (youtubeId: string, title: string) => {
try {
setSnackbarMessage(`Downloading "${title}" to your device`);
setSnackbarOpen(true);
const blob = await audioAPI.downloadFile(youtubeId);
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = title || 'audio';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
console.error('Download failed:', err);
setSnackbarMessage('Download failed');
setSnackbarOpen(true);
}
};
const handlePlayAll = () => {
if (!playlist?.items) return;
const downloadedTracks = playlist.items
.filter(item => item.audio.file_path)
.map(item => item.audio);
if (downloadedTracks.length > 0) {
setCurrentAudio(downloadedTracks[0], downloadedTracks);
setSnackbarMessage(`Playing ${downloadedTracks.length} tracks`);
setSnackbarOpen(true);
} else {
setSnackbarMessage('No downloaded tracks to play');
setSnackbarOpen(true);
}
};
const handleShuffle = () => {
if (!playlist?.items) return;
const downloadedTracks = playlist.items
.filter(item => item.audio.file_path)
.map(item => item.audio);
if (downloadedTracks.length > 0) {
// Shuffle array using Fisher-Yates algorithm
const shuffled = [...downloadedTracks];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
setCurrentAudio(shuffled[0], shuffled);
setSnackbarMessage(`Shuffled ${shuffled.length} tracks`);
setSnackbarOpen(true);
} else {
setSnackbarMessage('No downloaded tracks to shuffle');
setSnackbarOpen(true);
}
};
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const getStatusColor = (status: string): 'default' | 'primary' | 'success' | 'error' | 'warning' => {
switch (status) {
case 'syncing': return 'primary';
case 'success': return 'success';
case 'failed': return 'error';
case 'stale': return 'warning';
default: return 'default';
}
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
<CircularProgress />
</Box>
);
}
if (error || !playlist) {
return (
<Box>
<IconButton onClick={() => navigate('/playlists')} sx={{ mb: 2 }}>
<BackIcon />
</IconButton>
<Alert severity="error">{error || 'Playlist not found'}</Alert>
</Box>
);
}
return (
<Box>
{/* Header with Back Button */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3, flexWrap: 'wrap' }}>
<IconButton
onClick={() => navigate('/playlists')}
sx={{
bgcolor: 'rgba(255, 255, 255, 0.05)',
'&:hover': { bgcolor: 'rgba(255, 255, 255, 0.1)' }
}}
>
<BackIcon />
</IconButton>
<Box sx={{ flex: 1, minWidth: 200 }}>
<Typography variant="h5" sx={{ fontWeight: 700, letterSpacing: '-0.02em', mb: 0.5 }}>
{playlist.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{playlist.channel_name}
</Typography>
</Box>
<Chip
label={playlist.status_display}
color={getStatusColor(playlist.sync_status)}
size="small"
/>
</Box>
{/* Offline Status Card - PWA Feature */}
{!isOnline && (
<Card sx={{ mb: 3, bgcolor: 'rgba(255, 193, 7, 0.1)', border: '1px solid rgba(255, 193, 7, 0.3)' }}>
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 2 }}>
<WifiOffIcon sx={{ color: 'warning.main' }} />
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'warning.main' }}>
Offline Mode
</Typography>
<Typography variant="caption" color="text.secondary">
{isOfflineAvailable
? 'This playlist is available offline'
: 'You are offline. Cache playlists when online for offline access.'}
</Typography>
</Box>
</Box>
</Card>
)}
{/* Control Buttons */}
<Box sx={{ display: 'flex', gap: 1.5, mb: 3, flexWrap: 'wrap' }}>
<Button
variant="contained"
startIcon={<PlayIcon />}
onClick={handlePlayAll}
disabled={!playlist.downloaded_count}
sx={{
minWidth: { xs: '48px', sm: '120px' },
bgcolor: 'primary.main',
color: 'background.dark',
fontWeight: 600,
'&:hover': { bgcolor: 'primary.dark' },
'&:disabled': { bgcolor: 'rgba(255, 255, 255, 0.1)' },
}}
>
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>Play All</Box>
</Button>
<Button
variant="outlined"
startIcon={<ShuffleIcon />}
onClick={handleShuffle}
disabled={!playlist.downloaded_count}
sx={{
minWidth: { xs: '48px', sm: '120px' },
borderColor: 'primary.main',
color: 'primary.main',
fontWeight: 600,
'&:hover': {
borderColor: 'primary.dark',
bgcolor: 'rgba(19, 236, 106, 0.05)'
},
'&:disabled': {
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'rgba(255, 255, 255, 0.3)'
},
}}
>
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>Shuffle</Box>
</Button>
<Button
variant="outlined"
startIcon={<CloudDownloadIcon />}
onClick={handleDownloadPlaylist}
disabled={!playlist.downloaded_count}
sx={{
minWidth: { xs: '48px', sm: '150px' },
borderColor: 'primary.main',
color: 'primary.main',
fontWeight: 600,
'&:hover': {
borderColor: 'primary.dark',
bgcolor: 'rgba(19, 236, 106, 0.05)'
},
'&:disabled': {
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'rgba(255, 255, 255, 0.3)'
},
}}
title="Download all tracks to your device"
>
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>Save All</Box>
</Button>
</Box>
{/* Offline Caching Controls - PWA Feature */}
<Card sx={{
mb: 3,
bgcolor: isOfflineAvailable ? 'rgba(76, 175, 80, 0.1)' : 'rgba(33, 150, 243, 0.1)',
border: `1px solid ${isOfflineAvailable ? 'rgba(76, 175, 80, 0.3)' : 'rgba(33, 150, 243, 0.3)'}`,
}}>
<Box sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
{isOfflineAvailable ? (
<CloudDoneIcon sx={{ color: 'success.main' }} />
) : (
<StorageIcon sx={{ color: 'info.main' }} />
)}
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
{isOfflineAvailable ? '📱 Offline Ready' : '💾 Cache for Offline'}
</Typography>
<Typography variant="caption" color="text.secondary">
{isOfflineAvailable
? `${playlist.downloaded_count} tracks cached and available without internet`
: 'Download this playlist to listen without an internet connection'}
</Typography>
</Box>
</Box>
{isDownloadingOffline && (
<Box sx={{ mb: 2 }}>
<LinearProgress
variant="determinate"
value={offlineProgress}
sx={{ height: 6, borderRadius: 1, mb: 1 }}
/>
<Typography variant="caption" color="text.secondary">
Caching tracks... {offlineProgress}%
</Typography>
</Box>
)}
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
{!isOfflineAvailable ? (
<Button
variant="contained"
startIcon={<CloudDownloadIcon />}
onClick={handleCacheForOffline}
disabled={!isOnline || isDownloadingOffline || !playlist.downloaded_count}
size="small"
sx={{
bgcolor: 'info.main',
color: 'white',
fontWeight: 600,
'&:hover': { bgcolor: 'info.dark' },
'&:disabled': { bgcolor: 'rgba(255, 255, 255, 0.1)' },
}}
>
{isDownloadingOffline ? 'Caching...' : 'Make Available Offline'}
</Button>
) : (
<>
<Tooltip title="Remove offline cache to free up storage space">
<Button
variant="outlined"
startIcon={<DeleteIcon />}
onClick={handleRemoveOffline}
size="small"
sx={{
borderColor: 'error.main',
color: 'error.main',
fontWeight: 600,
'&:hover': {
borderColor: 'error.dark',
bgcolor: 'rgba(244, 67, 54, 0.05)'
},
}}
>
Remove Offline
</Button>
</Tooltip>
<Chip
icon={<CloudDoneIcon />}
label="Cached"
color="success"
size="small"
sx={{ fontWeight: 600 }}
/>
</>
)}
{cacheSize && (
<Box sx={{ ml: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<Typography variant="caption" color="text.secondary">
Storage Used
</Typography>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
{(cacheSize.usage / 1024 / 1024).toFixed(1)} MB / {(cacheSize.quota / 1024 / 1024 / 1024).toFixed(1)} GB
</Typography>
</Box>
)}
</Box>
</Box>
</Card>
{/* Progress Bar */}
{playlist.downloaded_count < playlist.item_count && (
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="caption" color="text.secondary">
Download Progress
</Typography>
<Typography variant="caption" color="text.secondary">
{playlist.downloaded_count} / {playlist.item_count} tracks
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={playlist.progress_percent}
sx={{ height: 6, borderRadius: 1 }}
/>
</Box>
)}
{/* Stats */}
<Box sx={{ display: 'flex', gap: 3, mb: 3, flexWrap: 'wrap' }}>
<Box>
<Typography variant="caption" color="text.secondary">
Total Tracks
</Typography>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
{playlist.item_count}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Downloaded
</Typography>
<Typography variant="h6" sx={{ fontWeight: 600, color: 'primary.main' }}>
{playlist.downloaded_count}
</Typography>
</Box>
{playlist.last_refresh && (
<Box>
<Typography variant="caption" color="text.secondary">
Last Updated
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{formatDate(playlist.last_refresh)}
</Typography>
</Box>
)}
</Box>
{/* Tracks Table */}
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell width={60} sx={{ fontWeight: 600, fontSize: { xs: '0.7rem', sm: '0.75rem' } }}>#</TableCell>
<TableCell sx={{ fontWeight: 600, fontSize: { xs: '0.7rem', sm: '0.75rem' } }}>Title</TableCell>
<TableCell sx={{ fontWeight: 600, fontSize: { xs: '0.7rem', sm: '0.75rem' }, display: { xs: 'none', md: 'table-cell' } }}>Channel</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, fontSize: { xs: '0.7rem', sm: '0.75rem' } }}>Duration</TableCell>
<TableCell align="center" width={120} sx={{ fontWeight: 600, fontSize: { xs: '0.7rem', sm: '0.75rem' } }}>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{playlist.items && playlist.items.length > 0 ? (
playlist.items.map((item, index) => (
<TableRow
key={item.id}
sx={{
cursor: 'pointer',
transition: 'background-color 0.3s ease',
'&:hover': {
bgcolor: 'rgba(19, 236, 106, 0.05)',
},
opacity: item.audio.file_path ? 1 : 0.5,
}}
onClick={() => item.audio.file_path && setCurrentAudio(item.audio)}
>
<TableCell sx={{ color: 'text.secondary', fontSize: { xs: '0.7rem', sm: '0.75rem' } }}>
{index + 1}
</TableCell>
<TableCell>
<Box>
<Typography
variant="body2"
noWrap
sx={{
maxWidth: { xs: 200, sm: 300, md: 400 },
fontWeight: 500,
fontSize: { xs: '0.75rem', sm: '0.875rem' },
}}
>
{item.audio.title}
</Typography>
{!item.audio.file_path && (
<Chip
label="Not Downloaded"
size="small"
color="warning"
sx={{ mt: 0.5, height: 18, fontSize: '0.65rem' }}
/>
)}
</Box>
</TableCell>
<TableCell sx={{ display: { xs: 'none', md: 'table-cell' } }}>
<Typography
variant="body2"
color="text.secondary"
noWrap
sx={{
maxWidth: 150,
fontSize: '0.75rem',
}}
>
{item.audio.channel_name}
</Typography>
</TableCell>
<TableCell align="right" sx={{ color: 'text.secondary', fontSize: { xs: '0.7rem', sm: '0.75rem' } }}>
{formatDuration(item.audio.duration)}
</TableCell>
<TableCell align="center" onClick={(e) => e.stopPropagation()}>
<Box sx={{ display: 'flex', gap: 0.5, justifyContent: 'center' }}>
<IconButton
size="small"
onClick={() => item.audio.file_path && setCurrentAudio(item.audio)}
disabled={!item.audio.file_path}
sx={{
color: 'primary.main',
'&:disabled': { color: 'rgba(255, 255, 255, 0.3)' },
}}
title="Play"
>
<PlayIcon />
</IconButton>
{item.audio.file_path && item.audio.youtube_id && (
<IconButton
size="small"
onClick={() => handleDownloadTrackToDevice(item.audio.youtube_id!, item.audio.title)}
sx={{
color: 'primary.main',
}}
title="Download to device"
>
<DownloadIcon />
</IconButton>
)}
</Box>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} align="center" sx={{ py: 4 }}>
<Typography variant="body2" color="text.secondary">
No tracks found in this playlist
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{/* Snackbar for notifications */}
<Snackbar
open={snackbarOpen}
autoHideDuration={3000}
onClose={() => setSnackbarOpen(false)}
message={snackbarMessage}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
/>
</Box>
);
}

View file

@ -0,0 +1,365 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Typography,
Button,
Card,
CardContent,
CardActions,
Grid,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Avatar,
Chip,
IconButton,
Alert,
LinearProgress,
Tooltip,
Badge,
} from '@mui/material';
import {
Add as AddIcon,
Delete as DeleteIcon,
PlaylistPlay as PlaylistIcon,
Download as DownloadIcon,
CloudDone as CloudDoneIcon,
WifiOff as WifiOffIcon,
} from '@mui/icons-material';
import { playlistAPI } from '../api/client';
import { usePWA } from '../context/PWAContext';
import { offlineStorage } from '../utils/offlineStorage';
interface Playlist {
id: number;
playlist_id: string;
title: string;
channel_name: string;
thumbnail_url: string;
subscribed: boolean;
item_count: number;
downloaded_count: number;
last_refresh: string | null;
sync_status: 'pending' | 'syncing' | 'success' | 'failed' | 'stale';
status_display: string;
error_message: string;
active: boolean;
progress_percent: number;
}
export default function PlaylistsPage() {
const navigate = useNavigate();
const { isOnline } = usePWA();
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [loading, setLoading] = useState(true);
const [openDialog, setOpenDialog] = useState(false);
const [playlistUrl, setPlaylistUrl] = useState('');
const [error, setError] = useState('');
const [offlinePlaylists, setOfflinePlaylists] = useState<Set<number>>(new Set());
const loadPlaylists = async () => {
try {
const response = await playlistAPI.list();
// Handle both array response and paginated object response
const data = Array.isArray(response.data) ? response.data : (response.data?.results || response.data?.data || []);
setPlaylists(data);
// Load offline status
await loadOfflineStatus();
} catch (err) {
console.error('Failed to load playlists:', err);
setPlaylists([]);
} finally {
setLoading(false);
}
};
const loadOfflineStatus = async () => {
try {
const cachedPlaylists = await offlineStorage.getOfflinePlaylists();
const offlineIds = new Set(cachedPlaylists.map(p => p.id));
setOfflinePlaylists(offlineIds);
} catch (err) {
console.error('Failed to load offline status:', err);
}
};
useEffect(() => {
loadPlaylists();
}, []);
const handleSubscribe = async () => {
setError('');
setLoading(true);
try {
await playlistAPI.create({ url: playlistUrl });
setPlaylistUrl('');
setOpenDialog(false);
// Show success message
alert('✅ Playlist subscription started! Fetching metadata and downloading audio...');
// Immediately reload to show pending status
await loadPlaylists();
setLoading(false);
// Poll for updates every 3 seconds for the next 30 seconds
const pollInterval = setInterval(async () => {
await loadPlaylists();
}, 3000);
setTimeout(() => clearInterval(pollInterval), 30000);
} catch (err: any) {
setLoading(false);
setError(err.response?.data?.detail || 'Failed to subscribe to playlist');
}
};
const handleDownload = async (playlistId: string) => {
try {
await playlistAPI.download(playlistId);
// Reload playlists to show updated status
setTimeout(loadPlaylists, 1000);
} catch (err) {
console.error('Failed to start download:', err);
}
};
const handleDelete = async (playlistId: string) => {
if (!confirm('Are you sure you want to remove this playlist?')) return;
try {
await playlistAPI.delete(playlistId);
loadPlaylists();
} catch (err) {
console.error('Failed to delete playlist:', err);
}
};
const getProgress = (playlist: Playlist) => {
return playlist.progress_percent || 0;
};
const getStatusColor = (status: string): 'default' | 'primary' | 'success' | 'error' | 'warning' => {
switch (status) {
case 'syncing': return 'primary';
case 'success': return 'success';
case 'failed': return 'error';
case 'stale': return 'warning';
default: return 'default';
}
};
const getLastRefreshText = (lastRefresh: string | null) => {
if (!lastRefresh) return 'Never synced';
const date = new Date(lastRefresh);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
};
return (
<Box>
{/* Header */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3, px: 0.5 }}>
<Typography variant="h6" sx={{ fontWeight: 700, letterSpacing: '-0.02em' }}>
YouTube Playlists
</Typography>
<Button
variant="contained"
size="small"
startIcon={<AddIcon />}
onClick={() => setOpenDialog(true)}
sx={{ borderRadius: '9999px', textTransform: 'none', fontWeight: 600 }}
>
Add Playlist
</Button>
</Box>
{/* Playlists Grid */}
<Box sx={{ display: 'flex', gap: 3, overflowX: 'auto', pb: 2, '&::-webkit-scrollbar': { height: 8 }, '&::-webkit-scrollbar-thumb': { bgcolor: 'rgba(255,255,255,0.1)', borderRadius: 1 } }}>
{playlists.map((playlist) => (
<Card
key={playlist.id}
onClick={() => navigate(`/playlists/${playlist.playlist_id}`)}
sx={{
minWidth: 160,
width: 160,
cursor: 'pointer',
transition: 'transform 0.2s',
'&:hover': {
transform: 'translateY(-4px)',
bgcolor: 'rgba(255, 255, 255, 0.05)'
}
}}
>
{/* Playlist Thumbnail */}
<Box
sx={{
height: 160,
width: 160,
backgroundImage: `url(${playlist.thumbnail_url})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'grey.900',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '12px 12px 0 0',
position: 'relative',
}}
>
{!playlist.thumbnail_url && (
<PlaylistIcon sx={{ fontSize: 40, color: 'grey.600' }} />
)}
{/* Offline Badge */}
{offlinePlaylists.has(playlist.id) && (
<Chip
icon={<CloudDoneIcon sx={{ fontSize: 14 }} />}
label="Offline"
size="small"
sx={{
position: 'absolute',
top: 8,
right: 8,
height: 20,
fontSize: '0.65rem',
fontWeight: 600,
bgcolor: 'success.main',
color: 'white',
'& .MuiChip-icon': { color: 'white' },
}}
/>
)}
{/* Offline Mode Indicator */}
{!isOnline && !offlinePlaylists.has(playlist.id) && (
<Chip
icon={<WifiOffIcon sx={{ fontSize: 14 }} />}
label="Unavailable"
size="small"
sx={{
position: 'absolute',
top: 8,
right: 8,
height: 20,
fontSize: '0.65rem',
fontWeight: 600,
bgcolor: 'rgba(0, 0, 0, 0.7)',
color: 'warning.main',
'& .MuiChip-icon': { color: 'warning.main' },
}}
/>
)}
</Box>
<CardContent sx={{ p: 1.5, pb: 1 }}>
{/* Playlist Name */}
<Typography variant="body2" fontWeight={600} noWrap sx={{ mb: 0.5 }}>
{playlist.title}
</Typography>
{/* Channel */}
<Typography variant="caption" color="text.secondary" noWrap>
{playlist.channel_name}
</Typography>
{/* Progress */}
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem' }}>
{playlist.downloaded_count}/{playlist.item_count}
</Typography>
<LinearProgress
variant="determinate"
value={getProgress(playlist)}
sx={{ height: 3, borderRadius: 1, mt: 0.5 }}
/>
</Box>
</CardContent>
<CardActions sx={{ justifyContent: 'space-between', px: 1, py: 0.5, gap: 0.5 }}>
<IconButton
size="small"
color="primary"
onClick={(e) => { e.stopPropagation(); handleDownload(playlist.playlist_id); }}
disabled={playlist.sync_status === 'syncing'}
title={playlist.sync_status === 'syncing' ? 'Downloading...' : 'Download'}
sx={{ width: 28, height: 28 }}
>
<DownloadIcon sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
size="small"
color="error"
onClick={(e) => { e.stopPropagation(); handleDelete(playlist.playlist_id); }}
title="Remove"
sx={{ width: 28, height: 28 }}
>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</CardActions>
</Card>
))}
</Box>
{/* Empty State */}
{!loading && playlists.length === 0 && (
<Box
sx={{
textAlign: 'center',
py: 8,
color: 'text.secondary',
}}
>
<PlaylistIcon sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} />
<Typography variant="h6" gutterBottom>
No playlists added
</Typography>
<Typography variant="body2" sx={{ mb: 3 }}>
Add YouTube playlists to automatically download all their videos
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setOpenDialog(true)}
>
Add Playlist
</Button>
</Box>
)}
{/* Add Playlist Dialog */}
<Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="sm" fullWidth>
<DialogTitle>Add YouTube Playlist</DialogTitle>
<DialogContent>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField
autoFocus
margin="dense"
label="Playlist URL"
placeholder="https://www.youtube.com/playlist?list=..."
fullWidth
value={playlistUrl}
onChange={(e) => setPlaylistUrl(e.target.value)}
helperText="Enter a YouTube playlist URL"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenDialog(false)}>Cancel</Button>
<Button onClick={handleSubscribe} variant="contained" disabled={!playlistUrl}>
Add Playlist
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

Some files were not shown because too many files have changed in this diff Show more