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
112
frontend/README.md
Normal 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! 🎧
|
||||
1
frontend/dist/assets/index-BeXoqz9j.css
vendored
Normal file
9
frontend/dist/assets/index-ChIfYXgy.js
vendored
Normal file
179
frontend/dist/assets/mui-DW1KyNMb.js
vendored
Normal file
59
frontend/dist/assets/vendor-Bv7lQTk9.js
vendored
Normal file
11
frontend/dist/avatars/preset_1.svg
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
After Width: | Height: | Size: 15 KiB |
271
frontend/dist/icon-preview.html
vendored
Normal 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
|
|
@ -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
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/dist/img/icons/apple-touch-icon.png
vendored
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
frontend/dist/img/icons/icon-128x128.png
vendored
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
frontend/dist/img/icons/icon-144x144.png
vendored
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
frontend/dist/img/icons/icon-152x152.png
vendored
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
frontend/dist/img/icons/icon-192x192-maskable.png
vendored
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
frontend/dist/img/icons/icon-192x192.png
vendored
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
frontend/dist/img/icons/icon-384x384.png
vendored
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
frontend/dist/img/icons/icon-512x512-maskable.png
vendored
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
frontend/dist/img/icons/icon-512x512.png
vendored
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
frontend/dist/img/icons/icon-72x72.png
vendored
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
frontend/dist/img/icons/icon-96x96.png
vendored
Normal file
|
After Width: | Height: | Size: 12 KiB |
17
frontend/dist/img/icons/logo-source.svg
vendored
Normal 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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
# This will be replaced with the actual image file
|
||||
BIN
frontend/dist/img/logo-temp.png
vendored
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
frontend/dist/img/logo.png
vendored
Normal file
|
After Width: | Height: | Size: 100 KiB |
39
frontend/dist/img/logo.svg
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
34
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
11
frontend/public/avatars/preset_1.svg
Normal 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/public/avatars/preset_2.svg
Normal 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/public/avatars/preset_3.svg
Normal 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/public/avatars/preset_4.svg
Normal 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/public/avatars/preset_5.svg
Normal 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
|
After Width: | Height: | Size: 15 KiB |
271
frontend/public/icon-preview.html
Normal 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/public/img/GENERATE_ICONS.md
Normal 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/public/img/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/public/img/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
frontend/public/img/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
frontend/public/img/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
frontend/public/img/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
frontend/public/img/icons/icon-192x192-maskable.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
frontend/public/img/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
frontend/public/img/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
frontend/public/img/icons/icon-512x512-maskable.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
frontend/public/img/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
frontend/public/img/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
frontend/public/img/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
17
frontend/public/img/icons/logo-source.svg
Normal 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/public/img/logo-app.svg
Normal 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/public/img/logo-new.png
Normal file
|
|
@ -0,0 +1 @@
|
|||
# This will be replaced with the actual image file
|
||||
BIN
frontend/public/img/logo-temp.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
frontend/public/img/logo.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
39
frontend/public/img/logo.svg
Normal 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 |
135
frontend/public/manifest.json
Normal 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/public/robots.txt
Normal 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/public/service-worker.js
Normal 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/public/sitemap.xml
Normal 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
|
|
@ -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;
|
||||
37
frontend/src/AppWithTheme.tsx
Normal 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
|
|
@ -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/'),
|
||||
};
|
||||
55
frontend/src/components/AdminRoute.tsx
Normal 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}</>;
|
||||
}
|
||||
266
frontend/src/components/AvatarDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
362
frontend/src/components/LyricsPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
230
frontend/src/components/PWAPrompts.tsx
Normal 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;
|
||||
316
frontend/src/components/PWASettingsCard.tsx
Normal 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;
|
||||
607
frontend/src/components/Player.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
408
frontend/src/components/PlaylistDownloadManager.tsx
Normal 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;
|
||||
369
frontend/src/components/QuickSyncSettings.tsx
Normal 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;
|
||||
206
frontend/src/components/Sidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
53
frontend/src/components/SplashScreen.tsx
Normal 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;
|
||||
166
frontend/src/components/ThemePreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
frontend/src/components/TopBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
348
frontend/src/components/UserProfileCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
frontend/src/context/PWAContext.tsx
Normal 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>;
|
||||
};
|
||||
141
frontend/src/context/QuickSyncContext.tsx
Normal 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
|
|
@ -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>,
|
||||
)
|
||||
690
frontend/src/pages/AdminUsersPage.tsx
Normal 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;
|
||||
295
frontend/src/pages/ChannelsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
frontend/src/pages/FavoritesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
227
frontend/src/pages/HomePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
235
frontend/src/pages/LibraryPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
641
frontend/src/pages/LocalFilesPage.tsx
Normal 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;
|
||||
533
frontend/src/pages/LocalFilesPageNew.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
399
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
391
frontend/src/pages/LoginPage.tsx.backup
Normal 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>
|
||||
);
|
||||
}
|
||||
359
frontend/src/pages/OfflineManagerPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
723
frontend/src/pages/PlaylistDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
365
frontend/src/pages/PlaylistsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||