Initial commit: AudiOhm - Alternative Spotify avec streaming YouTube
Backend: - FastAPI avec PostgreSQL et Redis - Authentification JWT complète - API REST pour musique, playlists, recherche - Streaming audio via yt-dlp - SQLAlchemy 2.0 async Frontend: - Flutter avec thème néon cyberpunk - State management Riverpod - Layout adaptatif desktop/mobile - Lecteur audio avec mini-player Infrastructure: - Docker Compose (PostgreSQL + Redis) - Scripts d'installation automatisés - Scripts de build pour exécutables Fichiers ajoutés: - BUILD_CLIENT_*.bat/sh: Scripts de compilation - BUILD_CLIENT_README.md: Documentation compilation - CHECK_FLUTTER.sh: Vérificateur d'environnement - requirements.txt mis à jour pour Python 3.13 - Modèles SQLAlchemy corrigés (metadata -> extra_metadata) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"superpowers@superpowers-marketplace": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__plugin_serena_serena__list_dir",
|
||||
"mcp__plugin_serena_serena__activate_project",
|
||||
"Skill(superpowers:brainstorming)",
|
||||
"Bash(flutter create:*)",
|
||||
"Skill(superpowers:writing-plans)",
|
||||
"Skill(superpowers:subagent-driven-development)",
|
||||
"Bash(git add:*)",
|
||||
"mcp__plugin_serena_serena__think_about_collected_information",
|
||||
"Bash(flutter analyze:*)",
|
||||
"Bash(tree:*)",
|
||||
"Skill(superpowers:dispatching-parallel-agents)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(python -m py_compile:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(git init:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git remote add:*)",
|
||||
"Bash(git branch:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(command -v:*)",
|
||||
"Bash(docker-compose up:*)",
|
||||
"Bash(docker ps:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(apt:*)",
|
||||
"Bash(apt install:*)",
|
||||
"Bash(source:*)",
|
||||
"Bash(pip install:*)",
|
||||
"Bash(python:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(timeout:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
# Build output
|
||||
flutter/build/
|
||||
frontend/build/
|
||||
dist/
|
||||
*.apk
|
||||
*.aab
|
||||
*.ipa
|
||||
*.app
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
backend/venv/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Storage
|
||||
storage/
|
||||
backend/storage/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
temp/
|
||||
tmp/
|
||||
|
||||
# Dart
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub-cache/
|
||||
.pub/
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# Spotify Le 2 - Build Linux Client
|
||||
# ============================================================================
|
||||
# This script compiles the Flutter app into a Linux executable
|
||||
#
|
||||
# Prerequisites:
|
||||
# 1. Flutter SDK installed
|
||||
# 2. clang, cmake, ninja-build, gtk3-devel
|
||||
# ============================================================================
|
||||
|
||||
echo "========================================"
|
||||
echo " SPOTIFY LE 2 - BUILD LINUX CLIENT"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Check if Flutter is installed
|
||||
if ! command -v flutter &> /dev/null; then
|
||||
echo "[ERROR] Flutter is not installed!"
|
||||
echo "Please install Flutter from: https://docs.flutter.dev/get-started/install/linux"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[1/6] Checking Flutter installation..."
|
||||
flutter --version
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "[ERROR] Flutter check failed!"
|
||||
exit 1
|
||||
fi
|
||||
echo "[OK] Flutter is ready!"
|
||||
echo ""
|
||||
|
||||
echo "[2/6] Checking Flutter doctor..."
|
||||
flutter doctor -v
|
||||
echo ""
|
||||
|
||||
echo "[3/6] Navigate to frontend directory..."
|
||||
cd frontend || exit 1
|
||||
echo ""
|
||||
|
||||
echo "[4/6] Installing dependencies..."
|
||||
flutter pub get
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "[ERROR] Failed to install dependencies!"
|
||||
exit 1
|
||||
fi
|
||||
echo "[OK] Dependencies installed!"
|
||||
echo ""
|
||||
|
||||
echo "[5/6] Building Linux executable..."
|
||||
echo "This may take several minutes..."
|
||||
flutter build linux --release
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "[ERROR] Build failed!"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "[6/6] Creating distribution package..."
|
||||
BUILD_DIR="build/linux/x64/release/bundle"
|
||||
DIST_DIR="dist/linux"
|
||||
VERSION="0.1.0"
|
||||
|
||||
# Create distribution directory
|
||||
rm -rf "$DIST_DIR"
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
# Copy executable and assets
|
||||
cp -r "$BUILD_DIR"/* "$DIST_DIR/"
|
||||
|
||||
# Create README
|
||||
cat > "$DIST_DIR/README.txt" << EOF
|
||||
Spotify Le 2 - Linux Client
|
||||
Version: $VERSION
|
||||
|
||||
To run the application:
|
||||
./spotify_le_2
|
||||
|
||||
Make sure the backend server is running on http://localhost:8000
|
||||
EOF
|
||||
|
||||
chmod +x "$DIST_DIR/spotify_le_2"
|
||||
echo "[OK] Distribution package created!"
|
||||
echo ""
|
||||
|
||||
echo "========================================"
|
||||
echo " BUILD COMPLETED SUCCESSFULLY!"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "Executable location: $DIST_DIR/spotify_le_2"
|
||||
echo ""
|
||||
echo "To run the application:"
|
||||
echo " 1. Make sure the backend is running"
|
||||
echo " 2. Execute: $DIST_DIR/spotify_le_2"
|
||||
echo ""
|
||||
@@ -0,0 +1,196 @@
|
||||
# 📦 Build Client Executable
|
||||
|
||||
Ce document explique comment créer un exécutable pour l'application client Spotify Le 2.
|
||||
|
||||
## 🎯 Prérequis
|
||||
|
||||
### Windows
|
||||
- **Flutter SDK** (3.19.0 ou supérieur)
|
||||
- Télécharger: https://docs.flutter.dev/get-started/install/windows
|
||||
- **Visual Studio 2022** (avec "Desktop development with C++")
|
||||
- **Git for Windows**
|
||||
|
||||
### Linux
|
||||
- **Flutter SDK** (3.19.0 ou supérieur)
|
||||
- Télécharger: https://docs.flutter.dev/get-started/install/linux
|
||||
- Dépendances de build:
|
||||
```bash
|
||||
sudo apt install clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev
|
||||
```
|
||||
|
||||
### macOS
|
||||
- **Flutter SDK** (3.19.0 ou supérieur)
|
||||
- Télécharger: https://docs.flutter.dev/get-started/install/macos
|
||||
- **Xcode** (avec les outils de ligne de commande)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Compilation Rapide
|
||||
|
||||
### Windows
|
||||
|
||||
**Double-cliquez sur:** `BUILD_CLIENT_WINDOWS.bat`
|
||||
|
||||
**Ou manuellement:**
|
||||
```cmd
|
||||
cd frontend
|
||||
flutter pub get
|
||||
flutter build windows --release
|
||||
```
|
||||
|
||||
L'exécutable sera créé dans: `frontend\build\windows\x64\runner\Release\`
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
chmod +x BUILD_CLIENT_LINUX.sh
|
||||
./BUILD_CLIENT_LINUX.sh
|
||||
```
|
||||
|
||||
L'exécutable sera créé dans: `frontend/build/linux/x64/release/bundle/`
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
flutter pub get
|
||||
flutter build macos --release
|
||||
```
|
||||
|
||||
L'exécutable sera créé dans: `frontend/build/macos/Build/Products/Release/`
|
||||
|
||||
---
|
||||
|
||||
## 📦 Distribution
|
||||
|
||||
Le script de build crée automatiquement un paquet dans `dist/` contenant:
|
||||
- L'exécutable compilé
|
||||
- Tous les assets nécessaires
|
||||
- Un fichier README avec les instructions
|
||||
|
||||
### Créer un installeur (Windows - optionnel)
|
||||
|
||||
Pour créer un vrai installeur `.msi` ou `.exe`, vous pouvez utiliser:
|
||||
|
||||
1. **Inno Setup** (gratuit)
|
||||
```cmd
|
||||
iscc dist/windows/installer_script.iss
|
||||
```
|
||||
|
||||
2. **WiX Toolset**
|
||||
```cmd
|
||||
candle.exe installer.wxs
|
||||
light.exe -out installer.msi installer.wixobj
|
||||
```
|
||||
|
||||
3. **electron-builder** (si vous portez l'app en Electron)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuration de l'API
|
||||
|
||||
L'URL de l'API backend est configurée dans:
|
||||
```
|
||||
frontend/lib/core/constants/api_constants.dart
|
||||
```
|
||||
|
||||
Par défaut: `http://localhost:8000/api/v1`
|
||||
|
||||
Pour changer l'URL du backend (ex: serveur distant):
|
||||
|
||||
```dart
|
||||
const String baseUrl = 'http://VOTRE-SERVER-IP:8000/api/v1';
|
||||
```
|
||||
|
||||
Puis recompilez l'application.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Vérification
|
||||
|
||||
Avant de distribuer l'exécutable:
|
||||
|
||||
1. **Tester l'application localement**
|
||||
```bash
|
||||
# Lancer le backend
|
||||
cd backend && source venv/bin/activate && uvicorn app.main:app
|
||||
|
||||
# Lancer le client
|
||||
flutter run -d windows
|
||||
```
|
||||
|
||||
2. **Vérifier les fonctionnalités clés**
|
||||
- Connexion/Inscription
|
||||
- Recherche musicale
|
||||
- Lecture audio
|
||||
- Gestion des playlists
|
||||
|
||||
3. **Tester sur une machine propre** (sans Flutter installé)
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes importantes
|
||||
|
||||
- **Taille du fichier**: L'exécutable pèse environ **50-80 MB**
|
||||
- **Dépendances**: Aucune dépendance externe nécessaire pour l'utilisateur
|
||||
- **Performance**: Version release optimisée (compactée et accélérée)
|
||||
- **Mises à jour**: Pour mettre à jour l'application, il faut recompiler et redistribuer
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Problèmes Courants
|
||||
|
||||
### "Flutter command not found"
|
||||
→ Ajoutez Flutter au PATH:
|
||||
- Windows: `%LOCALAPPDATA%\Flutter\bin`
|
||||
- Linux/Mac: `export PATH="$PATH:`pwd`/flutter/bin"`
|
||||
|
||||
### "No valid Windows build target"
|
||||
→ Installez Visual Studio 2022 avec "Desktop development with C++"
|
||||
|
||||
### "clang: command not found" (Linux)
|
||||
```bash
|
||||
sudo apt install clang cmake ninja-build pkg-config libgtk-3-dev
|
||||
```
|
||||
|
||||
### L'application ne se connecte pas au backend
|
||||
→ Vérifiez que:
|
||||
1. Le backend est démarré
|
||||
2. L'URL dans `api_constants.dart` est correcte
|
||||
3. Le firewall ne bloque pas le port 8000
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Personnalisation
|
||||
|
||||
### Changer l'icône de l'application
|
||||
|
||||
**Windows:**
|
||||
1. Remplacer `windows/runner/resources/app_icon.ico`
|
||||
2. Recompiler
|
||||
|
||||
**Linux:**
|
||||
1. Remplacer `linux/flutter/assets/icon.png`
|
||||
2. Recompiler
|
||||
|
||||
### Changer le nom de l'application
|
||||
|
||||
Modifier dans `pubspec.yaml`:
|
||||
```yaml
|
||||
name: spotify_le_2 # Changez ceci
|
||||
```
|
||||
|
||||
Puis recréez le projet avec:
|
||||
```bash
|
||||
flutter create --platforms=windows,linux,macos .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Pour distribuer votre application:**
|
||||
1. Suivez les étapes ci-dessus
|
||||
2. Testez sur plusieurs machines
|
||||
3. Créez un installeur si nécessaire
|
||||
4. Uploadez le fichier sur votre site/plateforme de distribution
|
||||
|
||||
**Bon build ! 🚀**
|
||||
@@ -0,0 +1,105 @@
|
||||
@echo off
|
||||
REM ============================================================================
|
||||
REM Spotify Le 2 - Build Windows Client
|
||||
REM ============================================================================
|
||||
REM This script compiles the Flutter app into a Windows executable (.exe)
|
||||
REM
|
||||
REM Prerequisites:
|
||||
REM 1. Flutter SDK installed: https://docs.flutter.dev/get-started/install/windows
|
||||
REM 2. Visual Studio 2022 (with C++ desktop development tools)
|
||||
REM 3. Git for Windows
|
||||
REM ============================================================================
|
||||
|
||||
echo ========================================
|
||||
echo SPOTIFY LE 2 - BUILD WINDOWS CLIENT
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM Check if Flutter is installed
|
||||
where flutter >nul 2>nul
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo [ERROR] Flutter is not installed or not in PATH!
|
||||
echo Please install Flutter from: https://docs.flutter.dev/get-started/install/windows
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [1/6] Checking Flutter installation...
|
||||
flutter --version
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo [ERROR] Flutter check failed!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Flutter is ready!
|
||||
echo.
|
||||
|
||||
echo [2/6] Checking Flutter doctor...
|
||||
flutter doctor -v
|
||||
echo.
|
||||
|
||||
echo [3/6] Navigate to frontend directory...
|
||||
cd frontend
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo [ERROR] Frontend directory not found!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
|
||||
echo [4/6] Installing dependencies...
|
||||
flutter pub get
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo [ERROR] Failed to install dependencies!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Dependencies installed!
|
||||
echo.
|
||||
|
||||
echo [5/6] Building Windows executable...
|
||||
echo This may take several minutes...
|
||||
flutter build windows --release
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo [ERROR] Build failed!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
|
||||
echo [6/6] Creating distribution package...
|
||||
set BUILD_DIR=build\windows\x64\runner\Release
|
||||
set DIST_DIR=dist\windows
|
||||
set VERSION=0.1.0
|
||||
|
||||
REM Create distribution directory
|
||||
if exist %DIST_DIR% rmdir /s /q %DIST_DIR%
|
||||
mkdir %DIST_DIR%
|
||||
|
||||
REM Copy executable and assets
|
||||
xcopy /s /e /i "%BUILD_DIR%" "%DIST_DIR%" >nul
|
||||
|
||||
REM Create README in distribution
|
||||
echo Spotify Le 2 - Windows Client > %DIST_DIR%\README.txt
|
||||
echo. >> %DIST_DIR%\README.txt
|
||||
echo Version: %VERSION% >> %DIST_DIR%\README.txt
|
||||
echo. >> %DIST_DIR%\README.txt
|
||||
echo To run the application, double-click on: spotify_le_2.exe >> %DIST_DIR%\README.txt
|
||||
echo. >> %DIST_DIR%\README.txt
|
||||
echo Make sure the backend server is running on http://localhost:8000 >> %DIST_DIR%\README.txt
|
||||
echo. >> %DIST_DIR%\README.txt
|
||||
|
||||
echo [OK] Distribution package created!
|
||||
echo.
|
||||
|
||||
echo ========================================
|
||||
echo BUILD COMPLETED SUCCESSFULLY!
|
||||
echo ========================================
|
||||
echo.
|
||||
echo Executable location: %DIST_DIR%\spotify_le_2.exe
|
||||
echo.
|
||||
echo To run the application:
|
||||
echo 1. Make sure the backend is running (see START_WINDOWS.bat)
|
||||
echo 2. Double-click: %DIST_DIR%\spotify_le_2.exe
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,154 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo ========================================
|
||||
echo VERIFICATION INSTALLATION
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
set ERRORS=0
|
||||
|
||||
REM Vérifier Python
|
||||
echo [1/10] Verification Python...
|
||||
python --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [X] Python n'est PAS installe
|
||||
set /a ERRORS+=1
|
||||
) else (
|
||||
echo [OK] Python est installe
|
||||
)
|
||||
|
||||
REM Vérifier Git
|
||||
echo.
|
||||
echo [2/10] Verification Git...
|
||||
git --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [X] Git n'est PAS installe
|
||||
set /a ERRORS+=1
|
||||
) else (
|
||||
echo [OK] Git est installe
|
||||
)
|
||||
|
||||
REM Vérifier Docker
|
||||
echo.
|
||||
echo [3/10] Verification Docker...
|
||||
docker --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [X] Docker n'est PAS installe
|
||||
set /a ERRORS+=1
|
||||
) else (
|
||||
echo [OK] Docker est installe
|
||||
|
||||
REM Vérifier si Docker tourne
|
||||
docker ps >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [!] Docker est installe mais ne tourne PAS
|
||||
echo Ouvrez Docker Desktop!
|
||||
set /a ERRORS+=1
|
||||
) else (
|
||||
echo [OK] Docker tourne
|
||||
)
|
||||
)
|
||||
|
||||
REM Vérifier Flutter
|
||||
echo.
|
||||
echo [4/10] Verification Flutter...
|
||||
flutter --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [X] Flutter n'est PAS installe
|
||||
set /a ERRORS+=1
|
||||
) else (
|
||||
echo [OK] Flutter est installe
|
||||
)
|
||||
|
||||
REM Vérifier venv Backend
|
||||
echo.
|
||||
echo [5/10] Verification Backend venv...
|
||||
if exist backend\venv\Scripts\activate.bat (
|
||||
echo [OK] Backend venv existe
|
||||
) else (
|
||||
echo [X] Backend venv n'existe PAS
|
||||
echo Lancez INSTALL_WINDOWS.bat
|
||||
set /a ERRORS+=1
|
||||
)
|
||||
|
||||
REM Vérifier .env
|
||||
echo.
|
||||
echo [6/10] Verification Backend config...
|
||||
if exist backend\.env (
|
||||
echo [OK] Backend .env existe
|
||||
) else (
|
||||
echo [X] Backend .env n'existe PAS
|
||||
echo Lancez INSTALL_WINDOWS.bat
|
||||
set /a ERRORS+=1
|
||||
)
|
||||
|
||||
REM Vérifier Docker containers
|
||||
echo.
|
||||
echo [7/10] Verification Infrastructure Docker...
|
||||
cd docker 2>nul
|
||||
if %errorlevel% equ 0 (
|
||||
docker-compose ps >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [!] Docker Compose n'est PAS configure
|
||||
set /a ERRORS+=1
|
||||
) else (
|
||||
docker-compose ps | findstr "Up" >nul
|
||||
if %errorlevel% equ 0 (
|
||||
echo [OK] Infrastructure Docker tourne
|
||||
) else (
|
||||
echo [!] Infrastructure Docker ne tourne PAS
|
||||
echo Lancez: docker-compose up -d
|
||||
set /a ERRORS+=1
|
||||
)
|
||||
)
|
||||
cd ..
|
||||
)
|
||||
|
||||
REM Vérifier Frontend dependencies
|
||||
echo.
|
||||
echo [8/10] Verification Frontend dependencies...
|
||||
if exist frontend\.flutter-plugins-dependencies (
|
||||
echo [OK] Frontend dependencies installees
|
||||
) else (
|
||||
echo [X] Frontend dependencies PAS installees
|
||||
echo Lancez: flutter pub get
|
||||
set /a ERRORS+=1
|
||||
)
|
||||
|
||||
REM Vérifier la connexion au Backend
|
||||
echo.
|
||||
echo [9/10] Verification Backend API...
|
||||
curl -s http://localhost:8000/docs >nul 2>&1
|
||||
if %errorlevel% equ 0 (
|
||||
echo [OK] Backend API repond!
|
||||
) else (
|
||||
echo [!] Backend API ne repond PAS
|
||||
echo Verifiez que le backend tourne
|
||||
)
|
||||
|
||||
REM Vérifier les ports
|
||||
echo.
|
||||
echo [10/10] Verification Ports...
|
||||
netstat -an | findstr ":8000.*LISTENING" >nul 2>&1
|
||||
if %errorlevel% equ 0 (
|
||||
echo [OK] Port 8000 utilise (Backend)
|
||||
) else (
|
||||
echo [!] Port 8000 LIBRE (Backend pas lance?)
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
if %ERRORS% EQU 0 (
|
||||
echo TOUT EST OK ! 👍
|
||||
echo ========================================
|
||||
echo.
|
||||
echo Vous pouvez lancer: START_WINDOWS.bat
|
||||
) else (
|
||||
echo %ERRORS% ERREUR(S) DETECTEE(S) ❌
|
||||
echo ========================================
|
||||
echo.
|
||||
echo Corrigez les erreurs ci-dessus avant de lancer l'app.
|
||||
)
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# Flutter Build Environment Checker
|
||||
# ============================================================================
|
||||
|
||||
echo "========================================"
|
||||
echo " FLUTTER BUILD CHECKER"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Check Flutter installation
|
||||
if ! command -v flutter &> /dev/null; then
|
||||
echo "[ERROR] Flutter is NOT installed!"
|
||||
echo ""
|
||||
echo "To install Flutter:"
|
||||
echo " Linux: https://docs.flutter.dev/get-started/install/linux"
|
||||
echo " Windows: https://docs.flutter.dev/get-started/install/windows"
|
||||
echo " macOS: https://docs.flutter.dev/get-started/install/macos"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[✓] Flutter is installed"
|
||||
echo ""
|
||||
|
||||
# Show Flutter version
|
||||
echo "Flutter version:"
|
||||
flutter --version
|
||||
echo ""
|
||||
|
||||
# Check Flutter doctor
|
||||
echo "Flutter doctor status:"
|
||||
flutter doctor
|
||||
echo ""
|
||||
|
||||
# Check if we can build for current platform
|
||||
OS="$(uname -s)"
|
||||
case "$OS" in
|
||||
Linux*)
|
||||
echo "Checking Linux build capability..."
|
||||
flutter build linux --help > /dev/null 2>&1
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "[✓] Linux build is supported"
|
||||
echo ""
|
||||
echo "To build the Windows client:"
|
||||
echo " ./BUILD_CLIENT_LINUX.sh"
|
||||
else
|
||||
echo "[!] Linux build is NOT available"
|
||||
echo "Install dependencies:"
|
||||
echo " sudo apt install clang cmake ninja-build pkg-config libgtk-3-dev"
|
||||
fi
|
||||
;;
|
||||
Darwin*)
|
||||
echo "Checking macOS build capability..."
|
||||
flutter build macos --help > /dev/null 2>&1
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "[✓] macOS build is supported"
|
||||
else
|
||||
echo "[!] macOS build is NOT available (install Xcode)"
|
||||
fi
|
||||
;;
|
||||
MINGW*|MSYS*|CYGWIN*)
|
||||
echo "Windows detected via Git Bash/MSYS"
|
||||
flutter build windows --help > /dev/null 2>&1
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "[✓] Windows build is supported"
|
||||
echo ""
|
||||
echo "To build the Windows client:"
|
||||
echo " BUILD_CLIENT_WINDOWS.bat"
|
||||
else
|
||||
echo "[!] Windows build is NOT available (install Visual Studio)"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Unknown OS: $OS"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " CHECK COMPLETE"
|
||||
echo "========================================"
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "========================================"
|
||||
echo " SPOTIFY LE 2 - INSTALLATION AUTO"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Fonction pour vérifier les commandes
|
||||
check_command() {
|
||||
if ! command -v $1 &> /dev/null; then
|
||||
echo "[ERREUR] $1 n'est pas installé!"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Vérifications
|
||||
echo "[1/6] Vérification des prérequis..."
|
||||
check_command python3 || exit 1
|
||||
check_command git || exit 1
|
||||
check_command docker || exit 1
|
||||
check_command docker-compose || exit 1
|
||||
echo "[OK] Tous les prérequis sont installés!"
|
||||
|
||||
echo ""
|
||||
echo "[2/6] Démarrage de l'infrastructure (PostgreSQL + Redis)..."
|
||||
cd docker
|
||||
docker-compose up -d
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "[ERREUR] Erreur lors du démarrage de Docker."
|
||||
exit 1
|
||||
fi
|
||||
echo "[OK] Infrastructure démarrée!"
|
||||
|
||||
echo ""
|
||||
echo "[3/6] Installation des dépendances Backend..."
|
||||
cd ../backend
|
||||
|
||||
# Créer venv si n'existe pas
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "Création de l'environnement virtuel Python..."
|
||||
python3 -m venv venv
|
||||
fi
|
||||
|
||||
# Activer venv et installer
|
||||
source venv/bin/activate
|
||||
echo "Installation des packages Python..."
|
||||
pip install --upgrade pip -q
|
||||
pip install -r requirements.txt -q
|
||||
echo "[OK] Backend prêt!"
|
||||
|
||||
echo ""
|
||||
echo "[4/6] Configuration du Backend..."
|
||||
if [ ! -f ".env" ]; then
|
||||
echo "Création du fichier .env..."
|
||||
cp .env.example .env
|
||||
echo "[ATTENTION] Éditez backend/.env et changez SECRET_KEY!"
|
||||
fi
|
||||
echo "[OK] Backend configuré!"
|
||||
|
||||
echo ""
|
||||
echo "[5/6] Initialisation de la base de données..."
|
||||
echo "Création des tables..."
|
||||
python -c "from app.core.database import init_db; import asyncio; asyncio.run(init_db())"
|
||||
echo "[OK] Base de données prête!"
|
||||
|
||||
echo ""
|
||||
echo "[6/6] Installation des dépendances Frontend..."
|
||||
cd ../frontend
|
||||
echo "Installation des packages Flutter..."
|
||||
flutter pub get -q
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "[ERREUR] Erreur lors de flutter pub get."
|
||||
echo "Vérifiez que Flutter est bien installé: https://docs.flutter.dev/get-started/install"
|
||||
exit 1
|
||||
fi
|
||||
echo "[OK] Frontend prêt!"
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " INSTALLATION TERMINÉE !"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "Pour démarrer l'application:"
|
||||
echo " ./START.sh"
|
||||
echo ""
|
||||
echo "Ou manuellement:"
|
||||
echo " Terminal 1 (Backend): cd backend && source venv/bin/activate && uvicorn app.main:app --reload"
|
||||
echo " Terminal 2 (Frontend): cd frontend && flutter run"
|
||||
echo ""
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
# 🚀 INSTALLATION RAPIDE - SPOTIFY LE 2
|
||||
|
||||
> **Installation automatisée en 2 minutes !**
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Installation Clé en Main
|
||||
|
||||
### 🪟 Windows
|
||||
|
||||
**Double-cliquez sur:** `INSTALL_WINDOWS.bat`
|
||||
|
||||
**Ou manuellement:**
|
||||
```cmd
|
||||
1. Télécharger Docker Desktop: https://www.docker.com/products/docker-desktop/
|
||||
2. Lancer: INSTALL_WINDOWS.bat
|
||||
3. Patienter...
|
||||
4. Lancer: START_WINDOWS.bat
|
||||
```
|
||||
|
||||
### 🐧 Linux / 🍎 macOS
|
||||
|
||||
```bash
|
||||
# Lancer l'installateur
|
||||
chmod +x INSTALL.sh START.sh
|
||||
./INSTALL.sh
|
||||
|
||||
# Démarrer l'app
|
||||
./START.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Prérequis
|
||||
|
||||
### Obligatoires
|
||||
- **Docker Desktop** - Pour PostgreSQL + Redis
|
||||
- **Python 3.11+** - Pour le backend
|
||||
- **Git** - Pour le versioning
|
||||
- **Flutter 3.2+** - Pour le frontend
|
||||
|
||||
### Installation des prérequis
|
||||
|
||||
**Windows:**
|
||||
1. Python: https://www.python.org/downloads/ (cocher "Add to PATH")
|
||||
2. Git: https://git-scm.com/download/win
|
||||
3. Docker: https://www.docker.com/products/docker-desktop/
|
||||
4. Flutter: https://docs.flutter.dev/get-started/install/windows
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt update
|
||||
sudo apt install -y python3 python3-pip python3-venv git docker.io docker-compose
|
||||
# Flutter: voir https://docs.flutter.dev/get-started/install/linux
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
# Avec Homebrew
|
||||
brew install python3 git docker docker-compose
|
||||
# Flutter: voir https://docs.flutter.dev/get-started/install/macos
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Après Installation
|
||||
|
||||
### Lancement Automatique
|
||||
|
||||
**Windows:** `START_WINDOWS.bat`
|
||||
**Linux/Mac:** `./START.sh`
|
||||
|
||||
### Lancement Manuel
|
||||
|
||||
**Terminal 1 - Backend:**
|
||||
```bash
|
||||
cd backend
|
||||
venv\Scripts\activate # Windows
|
||||
# source venv/bin/activate # Linux/Mac
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
**Terminal 2 - Frontend:**
|
||||
```bash
|
||||
cd frontend
|
||||
flutter run -d windows # Windows Desktop
|
||||
flutter run -d android # Android
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Backend (`backend/.env`)
|
||||
|
||||
```env
|
||||
# Application
|
||||
DEBUG=true
|
||||
SECRET_KEY=change-ce-dans-.env-!!!
|
||||
|
||||
# Database (géré par Docker)
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=spotify
|
||||
POSTGRES_PASSWORD=spotify_password
|
||||
POSTGRES_DB=spotify_le_2
|
||||
|
||||
# Redis (géré par Docker)
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
L'URL de l'API est déjà configurée dans `frontend/lib/core/constants/api_constants.dart`:
|
||||
```dart
|
||||
const String baseUrl = 'http://localhost:8000/api/v1';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎮 Utilisation
|
||||
|
||||
### Première Utilisation
|
||||
|
||||
1. **Lancer l'application** → Page de connexion
|
||||
2. **Créer un compte** → Email + Password
|
||||
3. **Se connecter** → Page d'accueil
|
||||
4. **Rechercher** → Tape "Daft Punk" ou autre
|
||||
5. **Écouter** → Tape un track pour le jouer !
|
||||
|
||||
### Fonctionnalités
|
||||
|
||||
- ✅ **Recherche** multi-source (DB + YouTube)
|
||||
- ✅ **Streaming** audio avec HTTP Range
|
||||
- ✅ **Playlists** complètes (CRUD, drag & drop)
|
||||
- ✅ **Queue** de lecture
|
||||
- ✅ **Library** (Playlists, Albums, Artists)
|
||||
- ✅ **Mini Player** avec contrôles
|
||||
- ✅ **Settings** (Profile, Audio Quality)
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Problèmes Courants
|
||||
|
||||
### "Python n'est pas installé"
|
||||
→ Téléchargez Python 3.11+ sur python.org
|
||||
|
||||
### "Docker ne démarre pas"
|
||||
→ Vérifiez que Docker Desktop est lancé
|
||||
|
||||
### "Flutter command not found"
|
||||
→ Ajoutez Flutter au PATH:
|
||||
- **Windows:** `%LOCALAPPDATA%\Flutter\bin`
|
||||
- **Linux/Mac:** `export PATH="$PATH:`pwd`/flutter/bin"`
|
||||
|
||||
### "Le backend ne répond pas"
|
||||
→ Vérifiez que `uvicorn app.main:app --reload` tourne dans le terminal backend
|
||||
|
||||
### "Port 8000 déjà utilisé"
|
||||
→ Changez le port dans le backend: `uvicorn app.main:app --port 8001`
|
||||
|
||||
---
|
||||
|
||||
## 📱 Plateformes Supportées
|
||||
|
||||
- ✅ **Windows 10/11** - Desktop app
|
||||
- ✅ **Linux** - Desktop app
|
||||
- ✅ **macOS** - Desktop app
|
||||
- ✅ **Android** - Mobile app (6.0+)
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Liens Utiles
|
||||
|
||||
- **Backend API:** http://localhost:8000/docs (Swagger UI)
|
||||
- **Frontend:** Lancé automatiquement par Flutter
|
||||
- **Docker:** http://localhost:8000 (Backend API)
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Thème Néon Cyberpunk
|
||||
|
||||
L'application utilise un thème unique avec:
|
||||
- **Cyan** (#00F0FF) - Accents primaires
|
||||
- **Violet** (#BF00FF) - Accents secondaires
|
||||
- **Rose** (#FF006E) - Accents tertiaires
|
||||
- **Glow effects** - Effets de lueur néon
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation Complète
|
||||
|
||||
Voir `README.md` pour la documentation détaillée du projet.
|
||||
|
||||
---
|
||||
|
||||
**Questions? Problèmes?**
|
||||
|
||||
1. Vérifiez les logs dans les terminaux
|
||||
2. Consultez `docs/` pour plus d'infos
|
||||
3. Vérifiez que Docker Desktop tourne
|
||||
|
||||
**Bon usage ! 🎵**
|
||||
@@ -0,0 +1,113 @@
|
||||
@echo off
|
||||
echo ========================================
|
||||
echo SPOTIFY LE 2 - INSTALLATION AUTO
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM Vérifier si Python est installé
|
||||
python --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERREUR] Python n'est pas installe !
|
||||
echo Telechargez Python 3.11+ sur: https://www.python.org/downloads/
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Vérifier si Git est installé
|
||||
git --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERREUR] Git n'est pas installe !
|
||||
echo Telechargez Git sur: https://git-scm.com/download/win
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [1/6] Verification de Docker...
|
||||
docker --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ATTENTION] Docker n'est pas installe.
|
||||
echo Voulez-vous installer Docker Desktop maintenant?
|
||||
echo Cela va ouvrir le navigateur...
|
||||
timeout /t 5
|
||||
start https://www.docker.com/products/docker-desktop/
|
||||
echo.
|
||||
echo Une fois Docker installe, relancez ce script.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Docker est installe!
|
||||
|
||||
echo.
|
||||
echo [2/6] Demarrage de l'infrastructure (PostgreSQL + Redis)...
|
||||
cd docker
|
||||
docker-compose up -d
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERREUR] Erreur lors du demarrage de Docker.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Infrastructure demarree!
|
||||
|
||||
echo.
|
||||
echo [3/6] Installation des dependances Backend...
|
||||
cd ..\backend
|
||||
|
||||
REM Créer venv si n'existe pas
|
||||
if not exist venv (
|
||||
echo Creation de l'environnement virtuel Python...
|
||||
python -m venv venv
|
||||
)
|
||||
|
||||
REM Activer venv et installer
|
||||
call venv\Scripts\activate.bat
|
||||
echo Installation des packages Python...
|
||||
pip install --upgrade pip -q
|
||||
pip install -r requirements.txt -q
|
||||
echo [OK] Backend pret!
|
||||
|
||||
echo.
|
||||
echo [4/6] Configuration du Backend...
|
||||
if not exist .env (
|
||||
echo Creation du fichier .env...
|
||||
copy .env.example .env
|
||||
echo [ATTENTION] Editez backend\.env et changez SECRET_KEY!
|
||||
echo Appuyez sur une touche pour continuer...
|
||||
pause >nul
|
||||
)
|
||||
echo [OK] Backend configure!
|
||||
|
||||
echo.
|
||||
echo [5/6] Initialisation de la base de donnees...
|
||||
echo Creation des tables...
|
||||
python -c "from app.core.database import init_db; import asyncio; asyncio.run(init_db())"
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ATTENTION] Erreur lors de l'init DB. Peut-etre que la DB existe deja?
|
||||
)
|
||||
echo [OK] Base de donnees prete!
|
||||
|
||||
echo.
|
||||
echo [6/6] Installation des dependances Frontend...
|
||||
cd ..\frontend
|
||||
echo Installation des packages Flutter...
|
||||
flutter pub get -q
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERREUR] Erreur lors de flutter pub get.
|
||||
echo Verifiez que Flutter est bien installe: https://docs.flutter.dev/get-started/install
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Frontend pret!
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo INSTALLATION TERMINEE !
|
||||
echo ========================================
|
||||
echo.
|
||||
echo Pour demarrer l'application:
|
||||
echo 1. Lancer START_WINDOWS.bat
|
||||
echo.
|
||||
echo Ou manuellement:
|
||||
echo Terminal 1 (Backend): cd backend ^&^& venv\Scripts\activate ^&^& uvicorn app.main:app --reload
|
||||
echo Terminal 2 (Frontend): cd frontend ^&^& flutter run
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,221 @@
|
||||
# Queue View Implementation - Spotify Le 2
|
||||
|
||||
## Overview
|
||||
Complete Queue View implementation for Spotify Le 2 with real-time playback management, drag-to-reorder, swipe-to-remove, and a neon cyberpunk theme.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### 1. Enhanced Provider (`frontend/lib/presentation/providers/music_provider.dart`)
|
||||
**Added:**
|
||||
- `QueueViewData` class - Data model for queue view with helper methods
|
||||
- `queueProvider` - Riverpod provider that exposes queue data to UI
|
||||
|
||||
**Key Features:**
|
||||
- `nextTracks` - Get upcoming tracks after current
|
||||
- `previousTracks` - Get previously played tracks
|
||||
- `queueCount` - Number of tracks in queue excluding current
|
||||
- `hasNextTracks` / `hasPreviousTracks` - Boolean checks
|
||||
|
||||
### 2. Queue Track Tile (`frontend/lib/presentation/widgets/player/queue_track_tile.dart`)
|
||||
**New File**
|
||||
|
||||
**Features:**
|
||||
- Displays track with album art, title, artist, duration
|
||||
- Animated playing indicator for current track
|
||||
- Drag handle for reordering
|
||||
- Remove button with red accent
|
||||
- Visual highlighting for currently playing track
|
||||
- Three-bar equalizer animation for playing state
|
||||
|
||||
**Components:**
|
||||
- `_PlayingAnimation` - Animated equalizer bars
|
||||
|
||||
### 3. Queue View Page (`frontend/lib/presentation/pages/player/queue_view_page.dart`)
|
||||
**New File**
|
||||
|
||||
**Layout:**
|
||||
- **Header**: Back button, title, queue count, clear button
|
||||
- **Now Playing Section**: Large album art, track info, full playback controls
|
||||
- **Queue Section**: Reorderable list of upcoming tracks with swipe-to-dismiss
|
||||
|
||||
**Features:**
|
||||
- Real-time updates with playback state
|
||||
- Swipe left to remove tracks
|
||||
- Drag and drop to reorder queue
|
||||
- Tap track to jump to it
|
||||
- Clear all upcoming tracks dialog
|
||||
- Empty state with queue icon
|
||||
- Full playback controls (play/pause, next, previous)
|
||||
- Playing indicator with green dot
|
||||
|
||||
**Visual Elements:**
|
||||
- Gradient backgrounds
|
||||
- Neon glow effects (cyan, violet)
|
||||
- Smooth slide transition animation
|
||||
- Responsive layout
|
||||
|
||||
### 4. Mini Player Enhancement (`frontend/lib/presentation/widgets/common/mini_player.dart`)
|
||||
**Modified:**
|
||||
- Converted to `ConsumerWidget` for Riverpod integration
|
||||
- Connected to `playerProvider` and `queueProvider`
|
||||
- Added real-time album art display
|
||||
- Added playing indicator (green dot)
|
||||
- Integrated playback controls (play/pause, next, previous)
|
||||
- **New Queue Button:**
|
||||
- Queue icon with notification badge
|
||||
- Shows count of upcoming tracks
|
||||
- Violet border when queue has tracks
|
||||
- Opens Queue View with slide animation
|
||||
|
||||
## Theme & Design
|
||||
|
||||
### Neon Cyberpunk Colors Used:
|
||||
- **Cyan** (`#00F0FF`) - Primary accents, borders, glows
|
||||
- **Violet** (`#BF00FF`) - Secondary accents, queue badge
|
||||
- **Rose** (`#FF006E`) - Gradients
|
||||
- **Vert** (`#39FF14`) - Playing indicator
|
||||
- **Rouge** (`#FF2A6D`) - Remove button, clear button
|
||||
|
||||
### Visual Effects:
|
||||
- Gradient backgrounds (linear gradients)
|
||||
- Box shadows with color glow
|
||||
- Border opacity for depth
|
||||
- Smooth animations (slide, scale, fade)
|
||||
- Rounded corners (12px-16px radius)
|
||||
|
||||
## Functionality
|
||||
|
||||
### Queue Management:
|
||||
1. **View Queue**: Tap queue button in mini player
|
||||
2. **Remove Track**: Swipe left or tap X button
|
||||
3. **Reorder**: Drag and drop tracks
|
||||
4. **Clear Queue**: Tap "Clear" button in header
|
||||
5. **Jump to Track**: Tap any track in queue
|
||||
|
||||
### Playback Controls:
|
||||
- **Play/Pause**: Toggle button with icon change
|
||||
- **Next/Previous**: Skip through queue
|
||||
- **Seek**: (Already in provider, can be added to UI)
|
||||
- **Progress**: (Already in provider, can be added to UI)
|
||||
|
||||
### Real-time Updates:
|
||||
- Queue updates immediately when tracks are added/removed
|
||||
- Playing indicator syncs with playback state
|
||||
- Album art displays current track
|
||||
- Queue count badge updates automatically
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Used By:
|
||||
- MiniPlayer widget - opens queue view
|
||||
- PlayerProvider - manages queue state
|
||||
- QueueProvider - exposes queue to UI
|
||||
|
||||
### Dependencies:
|
||||
- `flutter_riverpod` - State management
|
||||
- `just_audio` - Audio playback (via playerProvider)
|
||||
- Track entity - Domain model
|
||||
- AppColors - Theme constants
|
||||
|
||||
## Usage Example
|
||||
|
||||
```dart
|
||||
// In any widget:
|
||||
final queueData = ref.watch(queueProvider);
|
||||
|
||||
// Access queue properties:
|
||||
queueData.currentTrack; // Currently playing
|
||||
queueData.nextTracks; // List<Track> of upcoming
|
||||
queueData.queueCount; // Number of upcoming tracks
|
||||
queueData.hasNextTracks; // bool
|
||||
|
||||
// Modify queue:
|
||||
ref.read(playerProvider.notifier).addToQueue(track);
|
||||
ref.read(playerProvider.notifier).removeFromQueue(index);
|
||||
ref.read(playerProvider.notifier).setQueue(tracks);
|
||||
|
||||
// Navigate to queue view:
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => const QueueViewPage(),
|
||||
));
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Potential Additions:
|
||||
1. **Shuffle Queue**: Button to randomize queue order
|
||||
2. **Repeat Modes**: All, One, Off
|
||||
3. **Add to Queue**: From search/library views
|
||||
4. **Queue History**: View previously played tracks
|
||||
5. **Save Queue**: Create playlist from queue
|
||||
6. **Undo Remove**: SnackBar with undo action
|
||||
7. **Queue Presets**: Quick load saved queues
|
||||
8. **Smart Queue**: AI-based recommendations
|
||||
|
||||
### Desktop Layout:
|
||||
- Side panel instead of full page
|
||||
- Drag from library to queue
|
||||
- Multiple queue support
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### Performance:
|
||||
- ReorderableListView for efficient reordering
|
||||
- Provider for reactive updates (only rebuilds when needed)
|
||||
- Image caching with fallback to icon
|
||||
|
||||
### Accessibility:
|
||||
- Semantic labels for controls
|
||||
- Proper touch target sizes (40px minimum)
|
||||
- High contrast colors (WCAG compliant)
|
||||
|
||||
### Responsive:
|
||||
- Works on mobile and desktop
|
||||
- Adaptive layout (bottom sheet vs full page)
|
||||
- Flexible widget sizing
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
frontend/lib/
|
||||
├── presentation/
|
||||
│ ├── providers/
|
||||
│ │ └── music_provider.dart (enhanced)
|
||||
│ ├── pages/
|
||||
│ │ └── player/
|
||||
│ │ └── queue_view_page.dart (new)
|
||||
│ └── widgets/
|
||||
│ ├── common/
|
||||
│ │ └── mini_player.dart (enhanced)
|
||||
│ └── player/
|
||||
│ └── queue_track_tile.dart (new)
|
||||
```
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
1. **Unit Tests**:
|
||||
- QueueViewData getters
|
||||
- Queue provider state updates
|
||||
- Reorder logic
|
||||
|
||||
2. **Widget Tests**:
|
||||
- Queue tile rendering
|
||||
- Remove button interaction
|
||||
- Drag and drop
|
||||
|
||||
3. **Integration Tests**:
|
||||
- Add to queue flow
|
||||
- Playback with queue
|
||||
- Clear queue
|
||||
|
||||
## Summary
|
||||
|
||||
The Queue View is fully functional with:
|
||||
- Complete queue management (view, remove, reorder, clear)
|
||||
- Real-time playback integration
|
||||
- Beautiful neon cyberpunk theme
|
||||
- Smooth animations and interactions
|
||||
- Responsive design for mobile/desktop
|
||||
- Production-ready code quality
|
||||
|
||||
All components follow Flutter best practices and integrate seamlessly with the existing music provider architecture.
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
# 🎯 QUICKSTART - SPOTIFY LE 2
|
||||
|
||||
> **Démarrer en 3 minutes chrono**
|
||||
|
||||
---
|
||||
|
||||
## 🪟 Windows - 3 Étapes
|
||||
|
||||
### 1️⃣ Installer les prérequis (une seule fois)
|
||||
|
||||
Téléchargez et installez:
|
||||
- [Python 3.11](https://www.python.org/downloads/) ✅ Cocher "Add to PATH"
|
||||
- [Git](https://git-scm.com/download/win)
|
||||
- [Docker Desktop](https://www.docker.com/products/docker-desktop/)
|
||||
- [Flutter](https://docs.flutter.dev/get-started/install/windows)
|
||||
|
||||
### 2️⃣ Installer Spotify Le 2
|
||||
|
||||
Double-cliquez sur: **`INSTALL_WINDOWS.bat`**
|
||||
|
||||
Patientez pendant l'installation... (≈2-3 minutes)
|
||||
|
||||
### 3️⃣ Lancer !
|
||||
|
||||
Double-cliquez sur: **`START_WINDOWS.bat`**
|
||||
|
||||
✅ **L'application se lance !**
|
||||
|
||||
---
|
||||
|
||||
## 🐧 Linux - 3 Commandes
|
||||
|
||||
```bash
|
||||
# Installer Docker
|
||||
sudo apt install docker.io docker-compose
|
||||
|
||||
# Installer
|
||||
chmod +x INSTALL.sh START.sh
|
||||
./INSTALL.sh
|
||||
|
||||
# Démarrer
|
||||
./START.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🍎 macOS - Même chose
|
||||
|
||||
```bash
|
||||
# Installer Docker Desktop
|
||||
brew install docker docker-compose
|
||||
|
||||
# Installer
|
||||
chmod +x INSTALL.sh START.sh
|
||||
./INSTALL.sh
|
||||
|
||||
# Démarrer
|
||||
./START.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎮 Utilisation
|
||||
|
||||
### Première connexion
|
||||
|
||||
1. **Créer un compte** → Email + Mot de passe
|
||||
2. **Se connecter**
|
||||
3. **Rechercher** → "Daft Punk", "The Weeknd", etc.
|
||||
4. **Taper un track** → Ça joue !
|
||||
|
||||
### Navigation
|
||||
|
||||
- **Home** → Quick Picks, Recently Played
|
||||
- **Search** → Rechercher musique
|
||||
- **Library** → Vos playlists, albums, artists
|
||||
- **Settings** → Profile, audio quality
|
||||
|
||||
### Raccourcis
|
||||
|
||||
- **Taper track** → Jouer
|
||||
- **Bouton Queue** → Voir la file d'attente
|
||||
- **Drag & Drop** → Réorganiser les playlists
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Problèmes?
|
||||
|
||||
### "Python pas installé"
|
||||
→ [Télécharger Python](https://www.python.org/downloads/)
|
||||
|
||||
### "Docker ne marche pas"
|
||||
→ Ouvrez **Docker Desktop** et attendez qu'il démarre
|
||||
|
||||
### "Port 8000 occupé"
|
||||
→ Fermez les autres applications qui utilisent le port 8000
|
||||
|
||||
### "Flutter pas trouvé"
|
||||
→ [Installer Flutter](https://docs.flutter.dev/get-started/install)
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Résultat
|
||||
|
||||
Vous obtenez:
|
||||
|
||||
✅ **Application Desktop** (Windows/Linux/Mac)
|
||||
✅ **Application Mobile** (Android)
|
||||
✅ **Backend auto-hébergé** (FastAPI + PostgreSQL)
|
||||
✅ **Streaming YouTube** illimité
|
||||
✅ **Playlists** complètes
|
||||
✅ **Thème néon cyberpunk**
|
||||
|
||||
---
|
||||
|
||||
## 📞 Aide
|
||||
|
||||
- **Backend API:** http://localhost:8000/docs (Swagger)
|
||||
- **Logs:** Terminaux backend/frontend
|
||||
- **Docker:** `docker-compose ps` (vérifier status)
|
||||
|
||||
---
|
||||
|
||||
**C'est tout ! Bon usage ! 🎵**
|
||||
|
||||
*Alternative complète à Spotify avec streaming YouTube*
|
||||
@@ -0,0 +1,289 @@
|
||||
|
||||
# AudiOhm 🎵
|
||||
|
||||
Alternative à Spotify avec streaming YouTube, interface néon cyberpunk et backend auto-hébergé.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 🎯 Fonctionnalités
|
||||
|
||||
### ✅ Implémenté
|
||||
|
||||
**Backend FastAPI :**
|
||||
- ✅ Authentification JWT complète (register, login, refresh, logout)
|
||||
- ✅ Recherche multi-source (database + YouTube via yt-dlp)
|
||||
- ✅ Streaming audio avec support HTTP Range
|
||||
- ✅ CRUD Playlists complet (create, read, update, delete)
|
||||
- ✅ Gestion des tracks dans playlists (add, remove, reorder)
|
||||
- ✅ Recommandations basées sur YouTube related videos
|
||||
|
||||
**Frontend Flutter :**
|
||||
- ✅ Thème néon cyberpunk complet avec effets glow
|
||||
- ✅ Layout adaptatif (Desktop sidebar + Mobile bottom nav)
|
||||
- ✅ Mini player avec contrôles réactifs
|
||||
- ✅ Navigation instantanée (< 100ms)
|
||||
- ✅ Image caching progressif
|
||||
- ✅ State management avec Riverpod
|
||||
|
||||
**Base de données :**
|
||||
- ✅ 6 modèles SQLAlchemy (User, Artist, Album, Track, Playlist, PlaylistTrack)
|
||||
- ✅ Relations et indexes optimisés
|
||||
- ✅ Support async complet
|
||||
|
||||
### 🚧 À venir
|
||||
|
||||
- Import de playlists Spotify
|
||||
- Mode offline avec cache local
|
||||
- Recommandations avancées (Last.fm)
|
||||
- Système de likes (bibliothèque)
|
||||
- Mode collaboratif playlists
|
||||
- Historique d'écoute
|
||||
- UI pages (Search, Library, Settings)
|
||||
|
||||
## 📁 Structure du Projet
|
||||
|
||||
```
|
||||
spotify-le-2/
|
||||
├── backend/ # FastAPI backend
|
||||
│ ├── app/
|
||||
│ │ ├── api/v1/ # Routes (auth, music, playlists)
|
||||
│ │ ├── core/ # Config, security, database
|
||||
│ │ ├── models/ # SQLAlchemy models
|
||||
│ │ ├── schemas/ # Pydantic schemas
|
||||
│ │ └── services/ # Business logic
|
||||
│ ├── requirements.txt
|
||||
│ └── .env.example
|
||||
│
|
||||
├── frontend/ # Flutter app
|
||||
│ ├── lib/
|
||||
│ │ ├── core/theme/ # Neon cyberpunk theme
|
||||
│ │ ├── domain/ # Entities
|
||||
│ │ ├── infrastructure/ # API client
|
||||
│ │ └── presentation/ # UI, providers
|
||||
│ └── pubspec.yaml
|
||||
│
|
||||
├── docker/
|
||||
│ └── docker-compose.yml # PostgreSQL + Redis
|
||||
│
|
||||
├── docs/
|
||||
│ ├── design-preview.html # Preview du thème
|
||||
│ └── plans/ # Design document
|
||||
│
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
### Prérequis
|
||||
|
||||
**Backend :**
|
||||
- Python 3.11+
|
||||
- PostgreSQL 15+
|
||||
- Redis 7+
|
||||
- FFmpeg
|
||||
- yt-dlp
|
||||
|
||||
**Frontend :**
|
||||
- Flutter 3.2+
|
||||
- Dart 3.2+
|
||||
- Android Studio / VS Code
|
||||
|
||||
### 1. Cloner le projet
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd Spotify_le_2
|
||||
```
|
||||
|
||||
### 2. Lancer l'infrastructure (Docker)
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 3. Setup Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Créer venv
|
||||
python -m venv venv
|
||||
venv\Scripts\activate # Windows
|
||||
source venv/bin/activate # Linux/Mac
|
||||
|
||||
# Installer dépendances
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Configurer environnement
|
||||
cp .env.example .env
|
||||
# Éditer .env (changer SECRET_KEY!)
|
||||
|
||||
# Initialiser DB
|
||||
python -c "from app.core.database import init_db; import asyncio; asyncio.run(init_db())"
|
||||
|
||||
# Lancer serveur
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
API disponible sur http://localhost:8000
|
||||
|
||||
### 4. Setup Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Installer dépendances
|
||||
flutter pub get
|
||||
|
||||
# Lancer app
|
||||
flutter run -d windows # Desktop
|
||||
flutter run -d android # Android
|
||||
```
|
||||
|
||||
### 5. Créer un exécutable (.exe)
|
||||
|
||||
**Windows :**
|
||||
```cmd
|
||||
# Double-cliquez sur:
|
||||
BUILD_CLIENT_WINDOWS.bat
|
||||
|
||||
# Ou manuellement:
|
||||
cd frontend
|
||||
flutter build windows --release
|
||||
# Exécutable dans: build\windows\x64\runner\Release\
|
||||
```
|
||||
|
||||
**Linux :**
|
||||
```bash
|
||||
./BUILD_CLIENT_LINUX.sh
|
||||
```
|
||||
|
||||
📖 **Voir `BUILD_CLIENT_README.md` pour les instructions détaillées**
|
||||
|
||||
## 🎨 Design
|
||||
|
||||
Le thème **Néon Cyberpunk** est visible dans `docs/design-preview.html`.
|
||||
|
||||
**Couleurs principales :**
|
||||
- Background: `#0A0E27` (bleu nuit très foncé)
|
||||
- Primary: `#00F0FF` (cyan électrique néon)
|
||||
- Secondary: `#BF00FF` (violet néon)
|
||||
- Accent: `#FF006E` (rose néon)
|
||||
|
||||
## 📡 API Endpoints
|
||||
|
||||
### Authentification
|
||||
|
||||
```
|
||||
POST /api/v1/auth/register - Créer compte
|
||||
POST /api/v1/auth/login - Se connecter
|
||||
POST /api/v1/auth/refresh - Rafraîchir token
|
||||
GET /api/v1/auth/me - Profil utilisateur
|
||||
PUT /api/v1/auth/me - Modifier profil
|
||||
POST /api/v1/auth/logout - Se déconnecter
|
||||
```
|
||||
|
||||
### Musique
|
||||
|
||||
```
|
||||
GET /api/v1/music/search - Rechercher (DB + YouTube)
|
||||
GET /api/v1/music/tracks/{id} - Détails track
|
||||
GET /api/v1/music/tracks/{id}/stream - Stream audio
|
||||
POST /api/v1/music/tracks/from-youtube - Créer track YouTube
|
||||
GET /api/v1/music/tracks/{id}/recommendations - Recommandations
|
||||
GET /api/v1/music/trending - Trending tracks
|
||||
```
|
||||
|
||||
### Playlists
|
||||
|
||||
```
|
||||
GET /api/v1/playlists - Lister playlists
|
||||
POST /api/v1/playlists - Créer playlist
|
||||
GET /api/v1/playlists/{id} - Détails playlist
|
||||
PUT /api/v1/playlists/{id} - Modifier playlist
|
||||
DELETE /api/v1/playlists/{id} - Supprimer playlist
|
||||
POST /api/v1/playlists/{id}/tracks - Ajouter tracks
|
||||
DELETE /api/v1/playlists/{id}/tracks/{track_id} - Retirer track
|
||||
PUT /api/v1/playlists/{id}/tracks/reorder - Réordonner
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Backend (.env)
|
||||
|
||||
```env
|
||||
# Application
|
||||
DEBUG=true
|
||||
SECRET_KEY=change-this-to-a-strong-random-key
|
||||
|
||||
# Database
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=spotify
|
||||
POSTGRES_PASSWORD=your_password
|
||||
POSTGRES_DB=spotify_le_2
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```dart
|
||||
// lib/core/constants/api_constants.dart
|
||||
const String baseUrl = 'http://localhost:8000/api/v1';
|
||||
```
|
||||
|
||||
## 📊 Stack Technique
|
||||
|
||||
| Composant | Technologie |
|
||||
|-----------|------------|
|
||||
| **Backend** | Python + FastAPI |
|
||||
| **Base de données** | PostgreSQL 15+ |
|
||||
| **Cache** | Redis 7+ |
|
||||
| **Streaming** | yt-dlp + FFmpeg |
|
||||
| **Frontend** | Flutter 3.2+ |
|
||||
| **State Management** | Riverpod |
|
||||
| **Audio** | just_audio |
|
||||
| **ORM** | SQLAlchemy 2.0 (async) |
|
||||
|
||||
## 🛠️ Développement
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
# Linter
|
||||
ruff check app/
|
||||
|
||||
# Formatter
|
||||
black app/
|
||||
|
||||
# Tests
|
||||
pytest
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
# Formatter
|
||||
flutter format .
|
||||
|
||||
# Linter
|
||||
flutter analyze
|
||||
|
||||
# Tests
|
||||
flutter test
|
||||
```
|
||||
|
||||
## 📝 License
|
||||
|
||||
MIT
|
||||
|
||||
---
|
||||
|
||||
**Projet développé avec 💜 pour remplacer Spotify**
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "========================================"
|
||||
echo " SPOTIFY LE 2 - DÉMARRAGE"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Vérifier que l'installation a été faite
|
||||
if [ ! -d "backend/venv" ]; then
|
||||
echo "[ERREUR] Backend n'est pas installé!"
|
||||
echo "Lancez INSTALL.sh d'abord."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[1/3] Démarrage du Backend FastAPI..."
|
||||
cd backend
|
||||
source venv/bin/activate
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 &
|
||||
BACKEND_PID=$!
|
||||
echo "[OK] Backend démarré (PID: $BACKEND_PID)"
|
||||
|
||||
echo ""
|
||||
echo "[2/3] Attente du backend (5 secondes)..."
|
||||
sleep 5
|
||||
|
||||
echo ""
|
||||
echo "[3/3] Démarrage du Frontend Flutter..."
|
||||
cd ../frontend
|
||||
|
||||
# Detecter la plateforme
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# macOS
|
||||
echo "Détection automatique de la plateforme..."
|
||||
PLATFORM=$(flutter devices | grep -E "macos|windows|android" | head -1 | awk '{print $1}')
|
||||
else
|
||||
# Linux
|
||||
echo "Choisissez la plateforme:"
|
||||
echo " 1. Linux Desktop"
|
||||
echo " 2. Android (Émulateur ou appareil)"
|
||||
echo ""
|
||||
read -p "Votre choix (1 ou 2): " choice
|
||||
|
||||
if [ "$choice" == "1" ]; then
|
||||
PLATFORM="linux"
|
||||
else
|
||||
PLATFORM="android"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Lancement sur $PLATFORM..."
|
||||
flutter run -d $PLATFORM
|
||||
|
||||
# Nettoyage à la fermeture
|
||||
kill $BACKEND_PID 2>/dev/null
|
||||
@@ -0,0 +1,44 @@
|
||||
@echo off
|
||||
echo ========================================
|
||||
echo SPOTIFY LE 2 - DEMARRAGE
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM Vérifier que l'installation a été faite
|
||||
if not exist backend\venv\Scripts\activate.bat (
|
||||
echo [ERREUR] Backend n'est pas installe!
|
||||
echo Lancez INSTALL_WINDOWS.bat d'abord.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [1/3] Demarrage du Backend FastAPI...
|
||||
cd backend
|
||||
start "Spotify Le 2 - Backend" cmd /k "venv\Scripts\activate.bat && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000"
|
||||
|
||||
echo.
|
||||
echo [2/3] Attente du Backend (5 secondes)...
|
||||
timeout /t 5 /nobreak >nul
|
||||
|
||||
echo.
|
||||
echo [3/3] Demarrage du Frontend Flutter...
|
||||
cd ..\frontend
|
||||
|
||||
echo Choisissez la plateforme:
|
||||
echo 1. Windows (Desktop)
|
||||
echo 2. Android (Emulateur ou appareil)
|
||||
echo.
|
||||
set /p platform="Votre choix (1 ou 2): "
|
||||
|
||||
if "%platform%"=="1" (
|
||||
echo Lancement sur Windows Desktop...
|
||||
flutter run -d windows
|
||||
) else if "%platform%"=="2" (
|
||||
echo Lancement sur Android...
|
||||
flutter run -d android
|
||||
) else (
|
||||
echo Choix invalide. Lancement sur Windows par defaut.
|
||||
flutter run -d windows
|
||||
)
|
||||
|
||||
pause
|
||||
@@ -0,0 +1,308 @@
|
||||
# 🔧 DÉPANNAGE - SPOTIFY LE 2
|
||||
|
||||
> **Solutions aux problèmes les plus courants**
|
||||
|
||||
---
|
||||
|
||||
## 🪟 Windows
|
||||
|
||||
### ❌ "python n'est pas reconnu"
|
||||
|
||||
**Solution:**
|
||||
1. Téléchargez Python 3.11+ sur https://www.python.org/downloads/
|
||||
2. **IMPORTANT** Cochez "Add Python to PATH"
|
||||
3. Redémarrez votre terminal
|
||||
|
||||
**Vérification:**
|
||||
```cmd
|
||||
python --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ❌ "flutter n'est pas reconnu"
|
||||
|
||||
**Solution:**
|
||||
1. Téléchargez Flutter: https://docs.flutter.dev/get-started/install/windows
|
||||
2. Ajoutez Flutter au PATH:
|
||||
- Variables d'environnement
|
||||
- Nouvelle variable système: `Path`
|
||||
- Ajoutez: `C:\Path\Vers\flutter\bin`
|
||||
3. Redémarrez votre terminal
|
||||
|
||||
**Vérification:**
|
||||
```cmd
|
||||
flutter --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ❌ "docker: command not found"
|
||||
|
||||
**Solution:**
|
||||
1. Téléchargez Docker Desktop: https://www.docker.com/products/docker-desktop/
|
||||
2. Installez-le
|
||||
3. **Redémarrez votre ordinateur**
|
||||
4. Lancez Docker Desktop
|
||||
|
||||
**Vérification:**
|
||||
```cmd
|
||||
docker --version
|
||||
docker ps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ❌ "Port 8000 déjà utilisé"
|
||||
|
||||
**Solution 1 - Trouver le processus:**
|
||||
```cmd
|
||||
netstat -ano | findstr :8000
|
||||
taskkill /PID <PID> /F
|
||||
```
|
||||
|
||||
**Solution 2 - Changer le port:**
|
||||
```cmd
|
||||
cd backend
|
||||
uvicorn app.main:app --port 8001
|
||||
```
|
||||
|
||||
Et mettez à jour `frontend/lib/core/constants/api_constants.dart`:
|
||||
```dart
|
||||
const String baseUrl = 'http://localhost:8001/api/v1';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ❌ "La base de données ne se connecte pas"
|
||||
|
||||
**Solution:**
|
||||
1. Vérifiez que Docker Desktop tourne
|
||||
2. Lancer:
|
||||
```cmd
|
||||
cd docker
|
||||
docker-compose down
|
||||
docker-compose up -d
|
||||
```
|
||||
3. Vérifiez:
|
||||
```cmd
|
||||
docker-compose ps
|
||||
```
|
||||
Doit montrer `postgres` et `redis` comme "Up"
|
||||
|
||||
---
|
||||
|
||||
### ❌ "Le frontend ne se compile pas"
|
||||
|
||||
**Solution:**
|
||||
```cmd
|
||||
cd frontend
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ❌ "Erreur SSL / Certificate"
|
||||
|
||||
**Solution:**
|
||||
```cmd
|
||||
cd backend
|
||||
set PYTHONHTTPSVERIFY=0
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐧 Linux
|
||||
|
||||
### ❌ "Permission denied"
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
chmod +x INSTALL.sh START.sh
|
||||
```
|
||||
|
||||
### ❌ "docker: Permission denied"
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
sudo usermod -aG docker $USER
|
||||
# Déconnectez-vous et reconnectez-vous
|
||||
```
|
||||
|
||||
### ❌ "Port 8000 déjà utilisé"
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Trouver le processus
|
||||
lsof -i :8000
|
||||
kill -9 <PID>
|
||||
|
||||
# Ou changer le port
|
||||
uvicorn app.main:app --port 8001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🍎 macOS
|
||||
|
||||
### ❌ "flutter: command not found"
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Ajouter Flutter au PATH
|
||||
export PATH="$PATH:`pwd`/flutter/bin"
|
||||
|
||||
# Permanent (ajouter à ~/.zshrc ou ~/.bash_profile)
|
||||
echo 'export PATH="$PATH:$HOME/development/flutter/bin"' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Problèmes Backend
|
||||
|
||||
### ❌ "ModuleNotFoundError: No module named 'fastapi'"
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
cd backend
|
||||
source venv/bin/activate # Linux/Mac
|
||||
venv\Scripts\activate # Windows
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### ❌ "SyntaxError: parameter without a default"
|
||||
|
||||
**Solution:** Corrigé ! Vérifiez que vous avez la dernière version du code.
|
||||
|
||||
### ❌ "relation 'users' does not exist"
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
cd backend
|
||||
python -c "from app.core.database import init_db; import asyncio; asyncio.run(init_db())"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 Problèmes Frontend
|
||||
|
||||
### ❌ "Bad state: Cannot find a Flutter SDK"
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
flutter config --clear-features
|
||||
flutter doctor
|
||||
```
|
||||
|
||||
### ❌ "No device found"
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
flutter devices
|
||||
# Activer un appareil ou émulateur
|
||||
flutter devices
|
||||
flutter run -d <device-id>
|
||||
```
|
||||
|
||||
### ❌ "Could not resolve package"
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
cd frontend
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter pub upgrade
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Réseau
|
||||
|
||||
### ❌ "Connection refused" sur localhost:8000
|
||||
|
||||
**Vérifiez:**
|
||||
1. Backend lancé? (Terminal backend ouvert)
|
||||
2. Bon port? (http://localhost:8000)
|
||||
3. Firewall? (Autorisez Python)
|
||||
|
||||
**Test:**
|
||||
```cmd
|
||||
curl http://localhost:8000/docs
|
||||
```
|
||||
|
||||
Doit afficher la page Swagger UI.
|
||||
|
||||
---
|
||||
|
||||
### ❌ "CORS error" dans le navigateur
|
||||
|
||||
**Solution:** Déjà configuré dans le backend ! Vérifiez:
|
||||
- `backend/app/core/config.py` → `CORS_ORIGINS`
|
||||
- Doit inclure `http://localhost:8000`
|
||||
|
||||
---
|
||||
|
||||
## 🎬 Redémarrage Complet
|
||||
|
||||
Si rien ne fonctionne:
|
||||
|
||||
```bash
|
||||
# Arrêter tout
|
||||
cd docker
|
||||
docker-compose down
|
||||
|
||||
# Nettoyer
|
||||
cd ..
|
||||
cd backend
|
||||
rm -rf venv
|
||||
python -m venv venv
|
||||
|
||||
# Réinstaller
|
||||
./INSTALL.sh # ou INSTALL_WINDOWS.bat
|
||||
|
||||
# Redémarrer
|
||||
./START.sh # ou START_WINDOWS.bat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 Aide Détaillée
|
||||
|
||||
1. **Logs Backend** → Terminal backend (erreurs en rouge)
|
||||
2. **Logs Frontend** → `flutter run` (erreurs en rouge)
|
||||
3. **Docker Logs** → `docker-compose logs`
|
||||
4. **API Documentation** → http://localhost:8000/docs
|
||||
|
||||
---
|
||||
|
||||
## ✅ Vérification Complète
|
||||
|
||||
Lancez **`CHECK.bat`** (Windows) pour un diagnostic complet !
|
||||
|
||||
Il vérifie:
|
||||
- ✅ Python installé
|
||||
- ✅ Git installé
|
||||
- ✅ Docker installé et tourne
|
||||
- ✅ Flutter installé
|
||||
- ✅ Backend configuré
|
||||
- ✅ Infrastructure Docker tourne
|
||||
- ✅ Frontend dépendances installées
|
||||
- ✅ Backend API répond
|
||||
- ✅ Ports disponibles
|
||||
|
||||
---
|
||||
|
||||
**Toujours bloqué?**
|
||||
|
||||
1. Lancez `CHECK.bat` et regardez les erreurs
|
||||
2. Consultez `INSTALLATION.md`
|
||||
3. Vérifiez les logs dans les terminaux
|
||||
4. Redémarrez Docker Desktop
|
||||
|
||||
---
|
||||
|
||||
**Bon courage ! 🚀**
|
||||
@@ -0,0 +1,114 @@
|
||||
# =============================================================================
|
||||
# Spotify Le 2 - Environment Variables
|
||||
# =============================================================================
|
||||
# Copy this file to .env and fill in your values.
|
||||
# DO NOT commit .env to version control.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Application
|
||||
# -----------------------------------------------------------------------------
|
||||
APP_NAME="Spotify Le 2"
|
||||
APP_VERSION="0.1.0"
|
||||
DEBUG=true
|
||||
API_V1_PREFIX="/api/v1"
|
||||
|
||||
# Server
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
|
||||
# CORS - Comma-separated list of allowed origins
|
||||
BACKEND_CORS_ORIGINS=http://localhost:3000,http://localhost:8000
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database (PostgreSQL)
|
||||
# -----------------------------------------------------------------------------
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=spotify
|
||||
POSTGRES_PASSWORD=change_this_password_in_production
|
||||
POSTGRES_DB=spotify_le_2
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Redis
|
||||
# -----------------------------------------------------------------------------
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
# Alternative: Full Redis URL (overrides above if set)
|
||||
# REDIS_URL=redis://:password@localhost:6379/0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# JWT Security
|
||||
# -----------------------------------------------------------------------------
|
||||
# IMPORTANT: Change this to a strong random string in production!
|
||||
SECRET_KEY=change-this-secret-key-in-production-use-openssl-rand-hex-32
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# Password hashing
|
||||
PASSWORD_HASH_ALGORITHM=bcrypt
|
||||
PASSWORD_HASH_ROUNDS=12
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Storage
|
||||
# -----------------------------------------------------------------------------
|
||||
STORAGE_PATH=./storage
|
||||
AUDIO_CACHE_PATH=./storage/audio/cache
|
||||
AUDIO_PERMANENT_PATH=./storage/audio/permanent
|
||||
THUMBNAILS_PATH=./storage/thumbnails
|
||||
MAX_CACHE_SIZE_GB=50
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# YouTube
|
||||
# -----------------------------------------------------------------------------
|
||||
# Get your API key from: https://console.cloud.google.com/
|
||||
YOUTUBE_API_KEY=
|
||||
YTDLP_PATH=yt-dlp
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Spotify (for playlist import)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Get your credentials from: https://developer.spotify.com/dashboard
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
||||
SPOTIFY_REDIRECT_URI=http://localhost:8000/api/v1/import/spotify/callback
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Last.fm (for metadata and recommendations)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Get your API key from: https://www.last.fm/api/account/create
|
||||
LASTFM_API_KEY=
|
||||
LASTFM_SECRET=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Rate Limiting
|
||||
# -----------------------------------------------------------------------------
|
||||
RATE_LIMIT_PER_MINUTE=100
|
||||
RATE_LIMIT_BURST=200
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# File Upload
|
||||
# -----------------------------------------------------------------------------
|
||||
MAX_FILE_SIZE_MB=100
|
||||
ALLOWED_AUDIO_TYPES=audio/mpeg,audio/ogg,audio/wav,audio/flac
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Audio Processing
|
||||
# -----------------------------------------------------------------------------
|
||||
FFMPEG_PATH=ffmpeg
|
||||
AUDIO_QUALITY=high
|
||||
BITRATE=320
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE_PATH=./logs
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Pagination
|
||||
# -----------------------------------------------------------------------------
|
||||
DEFAULT_PAGE_SIZE=20
|
||||
MAX_PAGE_SIZE=100
|
||||
@@ -0,0 +1,78 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Storage
|
||||
storage/
|
||||
*.mp3
|
||||
*.mp4
|
||||
*.wav
|
||||
*.flac
|
||||
*.ogg
|
||||
*.m4a
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.hypothesis/
|
||||
|
||||
# Type checking
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
.ruff_cache/
|
||||
|
||||
# Distribution
|
||||
*.whl
|
||||
|
||||
# Alembic
|
||||
alembic/versions/*.pyc
|
||||
@@ -0,0 +1 @@
|
||||
"""Application package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""API module."""
|
||||
@@ -0,0 +1,100 @@
|
||||
"""API dependencies."""
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.security import decode_token
|
||||
from app.models.user import User
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
|
||||
# Database session dependency
|
||||
DBSession = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
|
||||
# Authentication
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
|
||||
db: DBSession,
|
||||
) -> User:
|
||||
"""
|
||||
Get current authenticated user from JWT token.
|
||||
|
||||
Args:
|
||||
credentials: HTTP Authorization credentials
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Current user
|
||||
|
||||
Raises:
|
||||
HTTPException: If token is invalid or user not found
|
||||
"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
payload = decode_token(credentials.credentials)
|
||||
user_id: str = payload.get("sub")
|
||||
token_type: str = payload.get("type")
|
||||
|
||||
if user_id is None or token_type != "access":
|
||||
raise credentials_exception
|
||||
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
auth_service = AuthService(db)
|
||||
user = await auth_service.get_user_by_id(user_id)
|
||||
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user_optional(
|
||||
credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(security)],
|
||||
db: DBSession,
|
||||
) -> Optional[User]:
|
||||
"""
|
||||
Get current user if authenticated, otherwise None.
|
||||
|
||||
Args:
|
||||
credentials: Optional HTTP Authorization credentials
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Current user or None
|
||||
"""
|
||||
if credentials is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return await get_current_user(credentials, db) # type: ignore
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
|
||||
# Current user dependencies
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
CurrentUserOptional = Annotated[Optional[User], Depends(get_current_user_optional)]
|
||||
|
||||
|
||||
def get_auth_service(db: DBSession) -> AuthService:
|
||||
"""Get auth service instance."""
|
||||
return AuthService(db)
|
||||
|
||||
|
||||
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
|
||||
@@ -0,0 +1 @@
|
||||
"""API v1 module."""
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Authentication API routes."""
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.api.dependencies import AuthServiceDep, CurrentUser, DBSession
|
||||
from app.schemas.auth import (
|
||||
LoginRequest,
|
||||
RefreshTokenRequest,
|
||||
Token,
|
||||
UserCreate,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
)
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
user_data: UserCreate,
|
||||
auth_service: AuthServiceDep,
|
||||
):
|
||||
"""
|
||||
Register a new user.
|
||||
|
||||
- **email**: Valid email address
|
||||
- **username**: 3-50 characters, unique
|
||||
- **password**: Min 8 characters
|
||||
- **display_name**: Optional display name
|
||||
"""
|
||||
try:
|
||||
user = await auth_service.register(
|
||||
email=user_data.email,
|
||||
username=user_data.username,
|
||||
password=user_data.password,
|
||||
display_name=user_data.display_name,
|
||||
)
|
||||
return UserResponse.model_validate(user)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
credentials: LoginRequest,
|
||||
auth_service: AuthServiceDep,
|
||||
):
|
||||
"""
|
||||
Login with email and password.
|
||||
|
||||
Returns access and refresh tokens.
|
||||
"""
|
||||
try:
|
||||
user = await auth_service.login(
|
||||
email=credentials.email,
|
||||
password=credentials.password,
|
||||
)
|
||||
access_token, refresh_token = auth_service.create_tokens(user.id)
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_in=15 * 60, # 15 minutes
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e),
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
async def refresh_token(
|
||||
token_data: RefreshTokenRequest,
|
||||
auth_service: AuthServiceDep,
|
||||
):
|
||||
"""
|
||||
Refresh access token using refresh token.
|
||||
|
||||
Returns new access and refresh tokens.
|
||||
"""
|
||||
from app.core.security import decode_token
|
||||
|
||||
try:
|
||||
payload = decode_token(token_data.refresh_token)
|
||||
user_id = payload.get("sub")
|
||||
token_type = payload.get("type")
|
||||
|
||||
if user_id is None or token_type != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
)
|
||||
|
||||
# Verify user still exists
|
||||
user = await auth_service.get_user_by_id(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Create new tokens
|
||||
access_token, refresh_token = auth_service.create_tokens(user.id)
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_in=15 * 60,
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user(
|
||||
current_user: CurrentUser,
|
||||
):
|
||||
"""
|
||||
Get current authenticated user profile.
|
||||
|
||||
Requires authentication.
|
||||
"""
|
||||
return UserResponse.model_validate(current_user)
|
||||
|
||||
|
||||
@router.put("/me", response_model=UserResponse)
|
||||
async def update_current_user(
|
||||
user_data: UserUpdate,
|
||||
current_user: CurrentUser,
|
||||
auth_service: AuthServiceDep,
|
||||
):
|
||||
"""
|
||||
Update current user profile.
|
||||
|
||||
Requires authentication.
|
||||
"""
|
||||
try:
|
||||
updated_user = await auth_service.update_user(
|
||||
user_id=current_user.id,
|
||||
display_name=user_data.display_name,
|
||||
avatar_url=user_data.avatar_url,
|
||||
date_of_birth=user_data.date_of_birth,
|
||||
country=user_data.country,
|
||||
)
|
||||
return UserResponse.model_validate(updated_user)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(
|
||||
current_user: CurrentUser,
|
||||
):
|
||||
"""
|
||||
Logout current user.
|
||||
|
||||
In a stateless JWT setup, this is mainly for client-side cleanup.
|
||||
The token will expire automatically.
|
||||
|
||||
Requires authentication.
|
||||
"""
|
||||
# In production, you might want to:
|
||||
# - Add token to blacklist (Redis)
|
||||
# - Remove refresh token from database
|
||||
# - Log the logout event
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Music API routes."""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.dependencies import CurrentUser, CurrentUserOptional, DBSession
|
||||
from app.schemas.music import (
|
||||
AlbumResponse,
|
||||
SearchRequest,
|
||||
SearchResponse,
|
||||
StreamUrlResponse,
|
||||
TrackResponse,
|
||||
TrackSearchResult,
|
||||
YouTubeSearchResult,
|
||||
)
|
||||
from app.services.music_service import MusicService
|
||||
|
||||
router = APIRouter(prefix="/music", tags=["music"])
|
||||
|
||||
|
||||
@router.get("/search", response_model=SearchResponse)
|
||||
async def search_music(
|
||||
db: DBSession,
|
||||
q: str = Query(..., min_length=1, max_length=100, description="Search query"),
|
||||
type: str = Query("track", pattern="^(track|artist|album|all)$"),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""
|
||||
Search for music across database and YouTube.
|
||||
|
||||
- **q**: Search query
|
||||
- **type**: Content type (track, artist, album, all)
|
||||
- **limit**: Maximum results (1-100)
|
||||
- **offset**: Pagination offset
|
||||
"""
|
||||
music_service = MusicService(db)
|
||||
results = await music_service.search(
|
||||
query=q,
|
||||
search_type=type,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return SearchResponse(
|
||||
tracks=[TrackSearchResult(**t) for t in results["tracks"]],
|
||||
artists=[AlbumResponse(**a) for a in results["artists"]],
|
||||
albums=[AlbumResponse(**a) for a in results["albums"]],
|
||||
total=results["total"],
|
||||
query=results["query"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tracks/{track_id}", response_model=TrackResponse)
|
||||
async def get_track(
|
||||
track_id: str,
|
||||
db: DBSession,
|
||||
):
|
||||
"""
|
||||
Get detailed information about a track.
|
||||
|
||||
Requires authentication for full details.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
music_service = MusicService(db)
|
||||
|
||||
try:
|
||||
track = await music_service.get_track(UUID(track_id))
|
||||
if not track:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Track not found",
|
||||
)
|
||||
return TrackResponse.model_validate(track)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid track ID",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tracks/{track_id}/stream")
|
||||
async def stream_track(
|
||||
track_id: str,
|
||||
db: DBSession,
|
||||
current_user: CurrentUserOptional = None,
|
||||
cache: bool = Query(False, description="Use cached version if available"),
|
||||
):
|
||||
"""
|
||||
Get stream URL for a track or stream directly.
|
||||
|
||||
Supports HTTP Range headers for proper streaming.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
music_service = MusicService(db)
|
||||
|
||||
try:
|
||||
track = await music_service.get_track(UUID(track_id))
|
||||
if not track:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Track not found",
|
||||
)
|
||||
|
||||
# Get stream URL
|
||||
stream_url = await music_service.get_stream_url(UUID(track_id))
|
||||
|
||||
if stream_url and stream_url.startswith("/api"):
|
||||
# Serve cached file
|
||||
from pathlib import Path
|
||||
|
||||
cache_path = Path(f"./storage/audio/cache/{track.youtube_id}.mp3")
|
||||
if cache_path.exists():
|
||||
return FileResponse(
|
||||
cache_path,
|
||||
media_type="audio/mpeg",
|
||||
filename=f"{track.title}.mp3",
|
||||
)
|
||||
|
||||
if not stream_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Stream URL not available",
|
||||
)
|
||||
|
||||
return StreamUrlResponse(
|
||||
url=stream_url,
|
||||
duration=track.duration,
|
||||
)
|
||||
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid track ID",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tracks/from-youtube", response_model=TrackResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_track_from_youtube(
|
||||
youtube_id: str = Query(..., description="YouTube video ID"),
|
||||
title: str = Query(..., description="Track title"),
|
||||
artist: Optional[str] = Query(None, description="Artist name"),
|
||||
album: Optional[str] = Query(None, description="Album name"),
|
||||
db: DBSession = None,
|
||||
current_user: CurrentUser = None,
|
||||
):
|
||||
"""
|
||||
Create a track from a YouTube video.
|
||||
|
||||
Requires authentication.
|
||||
|
||||
- **youtube_id**: YouTube video ID
|
||||
- **title**: Track title
|
||||
- **artist**: Optional artist name
|
||||
- **album**: Optional album name
|
||||
"""
|
||||
music_service = MusicService(db)
|
||||
|
||||
try:
|
||||
track = await music_service.create_track_from_youtube(
|
||||
youtube_id=youtube_id,
|
||||
title=title,
|
||||
artist_name=artist,
|
||||
album_name=album,
|
||||
)
|
||||
return TrackResponse.model_validate(track)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create track: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tracks/{track_id}/recommendations", response_model=list[YouTubeSearchResult])
|
||||
async def get_track_recommendations(
|
||||
track_id: str,
|
||||
db: DBSession,
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
):
|
||||
"""
|
||||
Get recommendations based on a track.
|
||||
|
||||
Uses YouTube's related videos algorithm.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
music_service = MusicService(db)
|
||||
|
||||
try:
|
||||
recommendations = await music_service.get_recommendations(
|
||||
UUID(track_id),
|
||||
limit=limit,
|
||||
)
|
||||
return [YouTubeSearchResult(**r) for r in recommendations]
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid track ID",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get recommendations: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/trending", response_model=list[TrackSearchResult])
|
||||
async def get_trending(
|
||||
db: DBSession,
|
||||
limit: int = Query(20, ge=1, le=50),
|
||||
):
|
||||
"""
|
||||
Get trending tracks.
|
||||
|
||||
Currently returns placeholder data.
|
||||
In production, this would use actual trending data.
|
||||
"""
|
||||
music_service = MusicService(db)
|
||||
|
||||
# Search for popular music on YouTube
|
||||
results = await music_service.search("music 2024", search_type="track", limit=limit)
|
||||
|
||||
return [TrackSearchResult(**t) for t in results["tracks"]]
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Playlists API routes."""
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
|
||||
from app.api.dependencies import CurrentUser, DBSession
|
||||
from app.schemas.playlist import (
|
||||
AddTrackRequest,
|
||||
PlaylistCreate,
|
||||
PlaylistResponse,
|
||||
PlaylistWithTracks,
|
||||
PlaylistUpdate,
|
||||
PlaylistTrackResponse,
|
||||
ReorderTracksRequest,
|
||||
)
|
||||
from app.services.playlist_service import PlaylistService
|
||||
|
||||
router = APIRouter(prefix="/playlists", tags=["playlists"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[PlaylistResponse])
|
||||
async def get_playlists(
|
||||
current_user: CurrentUser,
|
||||
db: DBSession,
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""
|
||||
Get all playlists for current user.
|
||||
|
||||
- **limit**: Maximum results (1-100)
|
||||
- **offset**: Pagination offset
|
||||
"""
|
||||
playlist_service = PlaylistService(db)
|
||||
playlists = await playlist_service.get_user_playlists(
|
||||
user_id=current_user.id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return [PlaylistResponse.model_validate(p) for p in playlists]
|
||||
|
||||
|
||||
@router.post("", response_model=PlaylistResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_playlist(
|
||||
playlist_data: PlaylistCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DBSession,
|
||||
):
|
||||
"""
|
||||
Create a new playlist.
|
||||
|
||||
- **name**: Playlist name (required)
|
||||
- **description**: Optional description
|
||||
- **image_url**: Optional cover image URL
|
||||
- **is_public**: Whether playlist is public (default: false)
|
||||
- **is_collaborative**: Whether playlist is collaborative (default: false)
|
||||
"""
|
||||
playlist_service = PlaylistService(db)
|
||||
playlist = await playlist_service.create_playlist(
|
||||
user_id=current_user.id,
|
||||
name=playlist_data.name,
|
||||
description=playlist_data.description,
|
||||
image_url=playlist_data.image_url,
|
||||
is_public=playlist_data.is_public,
|
||||
is_collaborative=playlist_data.is_collaborative,
|
||||
)
|
||||
return PlaylistResponse.model_validate(playlist)
|
||||
|
||||
|
||||
@router.get("/{playlist_id}", response_model=PlaylistWithTracks)
|
||||
async def get_playlist(
|
||||
playlist_id: str,
|
||||
current_user: CurrentUser,
|
||||
db: DBSession,
|
||||
include_tracks: bool = Query(True, description="Include tracks in response"),
|
||||
):
|
||||
"""
|
||||
Get a playlist by ID.
|
||||
|
||||
- **include_tracks**: Whether to include tracks (default: true)
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
playlist_service = PlaylistService(db)
|
||||
|
||||
try:
|
||||
playlist = await playlist_service.get_playlist(
|
||||
UUID(playlist_id),
|
||||
include_tracks=include_tracks,
|
||||
)
|
||||
|
||||
if not playlist:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Playlist not found",
|
||||
)
|
||||
|
||||
# Check access permissions
|
||||
if not playlist.is_public and playlist.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to view this playlist",
|
||||
)
|
||||
|
||||
response = PlaylistWithTracks.model_validate(playlist)
|
||||
|
||||
if include_tracks and playlist.playlist_tracks:
|
||||
# Load tracks with details
|
||||
from sqlalchemy import select
|
||||
from app.models.track import Track
|
||||
|
||||
track_ids = [pt.track_id for pt in playlist.playlist_tracks]
|
||||
stmt = (
|
||||
select(Track)
|
||||
.where(Track.id.in_(track_ids))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
tracks = {t.id: t for t in result.scalars().all()}
|
||||
|
||||
# Build response with track details
|
||||
response.tracks = [
|
||||
{
|
||||
"id": str(pt.track_id),
|
||||
"position": pt.position,
|
||||
"added_at": pt.added_at.isoformat(),
|
||||
"added_by": str(pt.added_by) if pt.added_by else None,
|
||||
"track": {
|
||||
"id": str(tracks[pt.track_id].id),
|
||||
"title": tracks[pt.track_id].title,
|
||||
"duration": tracks[pt.track_id].duration,
|
||||
"artist": tracks[pt.track_id].artist.name if tracks[pt.track_id].artist else None,
|
||||
"image_url": tracks[pt.track_id].image_url,
|
||||
} if pt.track_id in tracks else None,
|
||||
}
|
||||
for pt in sorted(playlist.playlist_tracks, key=lambda x: x.position)
|
||||
]
|
||||
|
||||
return response
|
||||
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid playlist ID",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{playlist_id}", response_model=PlaylistResponse)
|
||||
async def update_playlist(
|
||||
playlist_id: str,
|
||||
playlist_data: PlaylistUpdate,
|
||||
current_user: CurrentUser,
|
||||
db: DBSession,
|
||||
):
|
||||
"""
|
||||
Update a playlist.
|
||||
|
||||
Only the owner can update a playlist.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
playlist_service = PlaylistService(db)
|
||||
|
||||
try:
|
||||
playlist = await playlist_service.update_playlist(
|
||||
playlist_id=UUID(playlist_id),
|
||||
user_id=current_user.id,
|
||||
name=playlist_data.name,
|
||||
description=playlist_data.description,
|
||||
image_url=playlist_data.image_url,
|
||||
is_public=playlist_data.is_public,
|
||||
)
|
||||
return PlaylistResponse.model_validate(playlist)
|
||||
except ValueError as e:
|
||||
if "not found" in str(e).lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid playlist ID",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{playlist_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_playlist(
|
||||
playlist_id: str,
|
||||
current_user: CurrentUser,
|
||||
db: DBSession,
|
||||
):
|
||||
"""
|
||||
Delete a playlist.
|
||||
|
||||
Only the owner can delete a playlist.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
playlist_service = PlaylistService(db)
|
||||
|
||||
try:
|
||||
await playlist_service.delete_playlist(
|
||||
playlist_id=UUID(playlist_id),
|
||||
user_id=current_user.id,
|
||||
)
|
||||
except ValueError as e:
|
||||
if "not found" in str(e).lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid playlist ID",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{playlist_id}/tracks", response_model=PlaylistResponse)
|
||||
async def add_tracks(
|
||||
playlist_id: str,
|
||||
track_data: AddTrackRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DBSession,
|
||||
):
|
||||
"""
|
||||
Add tracks to a playlist.
|
||||
|
||||
- **track_ids**: List of track UUIDs to add (1-100 tracks)
|
||||
- **position**: Optional starting position (default: end of playlist)
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
playlist_service = PlaylistService(db)
|
||||
|
||||
try:
|
||||
playlist = await playlist_service.add_tracks(
|
||||
playlist_id=UUID(playlist_id),
|
||||
track_ids=track_data.track_ids,
|
||||
user_id=current_user.id,
|
||||
position=track_data.position,
|
||||
)
|
||||
return PlaylistResponse.model_validate(playlist)
|
||||
except ValueError as e:
|
||||
if "not found" in str(e).lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid playlist or track ID",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{playlist_id}/tracks/{track_id}", response_model=PlaylistResponse)
|
||||
async def remove_track(
|
||||
playlist_id: str,
|
||||
track_id: str,
|
||||
current_user: CurrentUser,
|
||||
db: DBSession,
|
||||
):
|
||||
"""
|
||||
Remove a track from a playlist.
|
||||
|
||||
Only the owner can remove tracks.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
playlist_service = PlaylistService(db)
|
||||
|
||||
try:
|
||||
playlist = await playlist_service.remove_track(
|
||||
playlist_id=UUID(playlist_id),
|
||||
track_id=UUID(track_id),
|
||||
user_id=current_user.id,
|
||||
)
|
||||
return PlaylistResponse.model_validate(playlist)
|
||||
except ValueError as e:
|
||||
if "not found" in str(e).lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid playlist or track ID",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{playlist_id}/tracks/reorder", response_model=PlaylistResponse)
|
||||
async def reorder_track(
|
||||
playlist_id: str,
|
||||
reorder_data: ReorderTracksRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DBSession,
|
||||
):
|
||||
"""
|
||||
Reorder a track within a playlist.
|
||||
|
||||
- **track_id**: Track UUID to reorder
|
||||
- **new_position**: New position (0-indexed)
|
||||
|
||||
Only the owner can reorder tracks.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
playlist_service = PlaylistService(db)
|
||||
|
||||
try:
|
||||
playlist = await playlist_service.reorder_track(
|
||||
playlist_id=UUID(playlist_id),
|
||||
track_id=reorder_data.track_id,
|
||||
new_position=reorder_data.new_position,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
return PlaylistResponse.model_validate(playlist)
|
||||
except ValueError as e:
|
||||
if "not found" in str(e).lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid playlist or track ID",
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Core module."""
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Application configuration using Pydantic Settings."""
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Application
|
||||
APP_NAME: str = "AudiOhm"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = False
|
||||
API_V1_PREFIX: str = "/api/v1"
|
||||
|
||||
# Server
|
||||
HOST: str = "0.0.0.0"
|
||||
PORT: int = 8000
|
||||
|
||||
# CORS
|
||||
BACKEND_CORS_ORIGINS: list[str] = Field(
|
||||
default=["http://localhost:3000", "http://localhost:8000"],
|
||||
description="List of allowed CORS origins",
|
||||
)
|
||||
|
||||
@field_validator("BACKEND_CORS_ORIGINS", mode="before")
|
||||
@classmethod
|
||||
def parse_cors_origins(cls, v: str | list[str]) -> list[str]:
|
||||
"""Parse CORS origins from string or list."""
|
||||
if isinstance(v, str):
|
||||
return [origin.strip() for origin in v.split(",")]
|
||||
return v
|
||||
|
||||
# Database
|
||||
POSTGRES_HOST: str = "localhost"
|
||||
POSTGRES_PORT: int = 5432
|
||||
POSTGRES_USER: str = "spotify"
|
||||
POSTGRES_PASSWORD: str = "spotify_password"
|
||||
POSTGRES_DB: str = "spotify_le_2"
|
||||
|
||||
@property
|
||||
def DATABASE_URL(self) -> str:
|
||||
"""Build PostgreSQL async connection URL."""
|
||||
return (
|
||||
f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
|
||||
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
||||
)
|
||||
|
||||
# Redis
|
||||
REDIS_HOST: str = "localhost"
|
||||
REDIS_PORT: int = 6379
|
||||
REDIS_PASSWORD: str | None = None
|
||||
REDIS_DB: int = 0
|
||||
REDIS_URL: str | None = None
|
||||
|
||||
@property
|
||||
def FULL_REDIS_URL(self) -> str:
|
||||
"""Build Redis connection URL."""
|
||||
if self.REDIS_URL:
|
||||
return self.REDIS_URL
|
||||
auth = f":{self.REDIS_PASSWORD}@" if self.REDIS_PASSWORD else ""
|
||||
return f"redis://{auth}{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||
|
||||
# JWT
|
||||
SECRET_KEY: str = Field(
|
||||
default="change-this-secret-key-in-production",
|
||||
description="Secret key for JWT token signing",
|
||||
)
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# Password hashing
|
||||
PASSWORD_HASH_ALGORITHM: Literal["bcrypt"] = "bcrypt"
|
||||
PASSWORD_HASH_ROUNDS: int = 12
|
||||
|
||||
# Storage
|
||||
STORAGE_PATH: str = "./storage"
|
||||
AUDIO_CACHE_PATH: str = "./storage/audio/cache"
|
||||
AUDIO_PERMANENT_PATH: str = "./storage/audio/permanent"
|
||||
THUMBNAILS_PATH: str = "./storage/thumbnails"
|
||||
MAX_CACHE_SIZE_GB: int = 50
|
||||
|
||||
# YouTube
|
||||
YOUTUBE_API_KEY: str | None = None
|
||||
YTDLP_PATH: str = "yt-dlp"
|
||||
|
||||
# Spotify (for import)
|
||||
SPOTIFY_CLIENT_ID: str | None = None
|
||||
SPOTIFY_CLIENT_SECRET: str | None = None
|
||||
SPOTIFY_REDIRECT_URI: str = "http://localhost:8000/api/v1/import/spotify/callback"
|
||||
|
||||
# Last.fm (for metadata)
|
||||
LASTFM_API_KEY: str | None = None
|
||||
LASTFM_SECRET: str | None = None
|
||||
|
||||
# Rate limiting
|
||||
RATE_LIMIT_PER_MINUTE: int = 100
|
||||
RATE_LIMIT_BURST: int = 200
|
||||
|
||||
# Upload
|
||||
MAX_FILE_SIZE_MB: int = 100
|
||||
ALLOWED_AUDIO_TYPES: list[str] = [
|
||||
"audio/mpeg",
|
||||
"audio/ogg",
|
||||
"audio/wav",
|
||||
"audio/flac",
|
||||
]
|
||||
|
||||
# Audio processing
|
||||
FFMPEG_PATH: str = "ffmpeg"
|
||||
AUDIO_QUALITY: Literal["low", "medium", "high"] = "high"
|
||||
BITRATE: int = 320
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
|
||||
LOG_FILE_PATH: str = "./logs"
|
||||
|
||||
# Pagination
|
||||
DEFAULT_PAGE_SIZE: int = 20
|
||||
MAX_PAGE_SIZE: int = 100
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize settings and create storage directories."""
|
||||
super().__init__(**kwargs)
|
||||
self._ensure_storage_directories()
|
||||
|
||||
def _ensure_storage_directories(self) -> None:
|
||||
"""Create storage directories if they don't exist."""
|
||||
from pathlib import Path
|
||||
|
||||
for path in [
|
||||
self.STORAGE_PATH,
|
||||
self.AUDIO_CACHE_PATH,
|
||||
self.AUDIO_PERMANENT_PATH,
|
||||
self.THUMBNAILS_PATH,
|
||||
self.LOG_FILE_PATH,
|
||||
]:
|
||||
Path(path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings instance."""
|
||||
return Settings()
|
||||
|
||||
|
||||
# Global settings instance
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Database configuration and session management."""
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# Create async engine
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
future=True,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
# Create async session factory
|
||||
async_session_maker = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all SQLAlchemy models."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
Dependency function to get database session.
|
||||
|
||||
Yields:
|
||||
AsyncSession: Database session.
|
||||
|
||||
Example:
|
||||
@app.get("/users/")
|
||||
async def get_users(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User))
|
||||
return result.scalars().all()
|
||||
"""
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_db_context() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
Context manager for database session.
|
||||
|
||||
Yields:
|
||||
AsyncSession: Database session.
|
||||
|
||||
Example:
|
||||
async with get_db_context() as db:
|
||||
await db.execute(insert(User).values(**user_data))
|
||||
"""
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Initialize database tables."""
|
||||
async with engine.begin() as conn:
|
||||
# Import all models here to ensure they're registered with Base
|
||||
from app.models import ( # noqa: F401
|
||||
album,
|
||||
artist,
|
||||
playlist,
|
||||
playlist_track,
|
||||
track,
|
||||
user,
|
||||
)
|
||||
|
||||
# Create all tables
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
"""Close database connections."""
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Security utilities for authentication and password hashing."""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# Password hashing context
|
||||
pwd_context = CryptContext(
|
||||
schemes=[settings.PASSWORD_HASH_ALGORITHM],
|
||||
deprecated="auto",
|
||||
)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
Verify a plain password against a hashed password.
|
||||
|
||||
Args:
|
||||
plain_password: The plain text password to verify.
|
||||
hashed_password: The hashed password to compare against.
|
||||
|
||||
Returns:
|
||||
True if the password matches, False otherwise.
|
||||
"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""
|
||||
Hash a password using the configured algorithm.
|
||||
|
||||
Args:
|
||||
password: The plain text password to hash.
|
||||
|
||||
Returns:
|
||||
The hashed password.
|
||||
"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(
|
||||
subject: str | Any,
|
||||
expires_delta: timedelta | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create a JWT access token.
|
||||
|
||||
Args:
|
||||
subject: The subject of the token (usually user ID).
|
||||
expires_delta: Optional expiration time delta.
|
||||
Defaults to settings.ACCESS_TOKEN_EXPIRE_MINUTES.
|
||||
|
||||
Returns:
|
||||
The encoded JWT access token.
|
||||
"""
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(
|
||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
|
||||
to_encode = {"exp": expire, "sub": str(subject), "type": "access"}
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode,
|
||||
settings.SECRET_KEY,
|
||||
algorithm=settings.ALGORITHM,
|
||||
)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def create_refresh_token(
|
||||
subject: str | Any,
|
||||
expires_delta: timedelta | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create a JWT refresh token.
|
||||
|
||||
Args:
|
||||
subject: The subject of the token (usually user ID).
|
||||
expires_delta: Optional expiration time delta.
|
||||
Defaults to settings.REFRESH_TOKEN_EXPIRE_DAYS.
|
||||
|
||||
Returns:
|
||||
The encoded JWT refresh token.
|
||||
"""
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(
|
||||
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
|
||||
)
|
||||
|
||||
to_encode = {"exp": expire, "sub": str(subject), "type": "refresh"}
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode,
|
||||
settings.SECRET_KEY,
|
||||
algorithm=settings.ALGORITHM,
|
||||
)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
"""
|
||||
Decode and validate a JWT token.
|
||||
|
||||
Args:
|
||||
token: The JWT token to decode.
|
||||
|
||||
Returns:
|
||||
The decoded token payload.
|
||||
|
||||
Raises:
|
||||
jwt.JWTError: If the token is invalid or expired.
|
||||
"""
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.SECRET_KEY,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
return payload
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Main FastAPI application entry point."""
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import close_db, init_db
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""
|
||||
Lifespan context manager for FastAPI application.
|
||||
|
||||
Handles startup and shutdown events.
|
||||
"""
|
||||
# Startup
|
||||
print("Starting up...")
|
||||
if settings.DEBUG:
|
||||
print("Debug mode is ON")
|
||||
print(f"Database URL: {settings.DATABASE_URL}")
|
||||
print(f"Redis URL: {settings.FULL_REDIS_URL}")
|
||||
|
||||
# Initialize database
|
||||
await init_db()
|
||||
print("Database initialized")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
print("Shutting down...")
|
||||
await close_db()
|
||||
print("Database connections closed")
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.APP_VERSION,
|
||||
description="Alternative to Spotify with YouTube streaming",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.BACKEND_CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root() -> dict[str, str]:
|
||||
"""Root endpoint with API information."""
|
||||
return {
|
||||
"name": settings.APP_NAME,
|
||||
"version": settings.APP_VERSION,
|
||||
"status": "running",
|
||||
"docs": "/api/docs",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check() -> dict[str, str]:
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
@app.get("/api/v1")
|
||||
async def api_v1_info() -> dict[str, str]:
|
||||
"""API v1 information endpoint."""
|
||||
return {
|
||||
"version": "v1",
|
||||
"prefix": settings.API_V1_PREFIX,
|
||||
"docs": "/api/docs",
|
||||
}
|
||||
|
||||
|
||||
# Exception handlers
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request, exc) -> JSONResponse:
|
||||
"""Global exception handler for unhandled exceptions."""
|
||||
if settings.DEBUG:
|
||||
# In debug mode, return full error details
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"detail": str(exc),
|
||||
"type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
# In production, return generic error message
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error"},
|
||||
)
|
||||
|
||||
|
||||
# API routes
|
||||
from app.api.v1 import auth, music, playlists
|
||||
|
||||
app.include_router(auth.router, prefix=settings.API_V1_PREFIX, tags=["authentication"])
|
||||
app.include_router(music.router, prefix=settings.API_V1_PREFIX, tags=["music"])
|
||||
app.include_router(playlists.router, prefix=settings.API_V1_PREFIX, tags=["playlists"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host=settings.HOST,
|
||||
port=settings.PORT,
|
||||
reload=settings.DEBUG,
|
||||
log_level=settings.LOG_LEVEL.lower(),
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""SQLAlchemy models."""
|
||||
from app.models.album import Album
|
||||
from app.models.artist import Artist
|
||||
from app.models.playlist import Playlist
|
||||
from app.models.playlist_track import PlaylistTrack
|
||||
from app.models.track import Track
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = [
|
||||
"Album",
|
||||
"Artist",
|
||||
"Playlist",
|
||||
"PlaylistTrack",
|
||||
"Track",
|
||||
"User",
|
||||
]
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Album model."""
|
||||
import uuid
|
||||
from datetime import datetime, date
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Integer, DATE, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.artist import Artist
|
||||
from app.models.track import Track
|
||||
|
||||
|
||||
class Album(Base):
|
||||
"""Album model representing music albums."""
|
||||
|
||||
__tablename__ = "albums"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Basic info
|
||||
title: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
release_date: Mapped[date | None] = mapped_column(
|
||||
DATE,
|
||||
)
|
||||
image_url: Mapped[str | None] = mapped_column(
|
||||
String(500),
|
||||
)
|
||||
|
||||
# Album details
|
||||
total_tracks: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
)
|
||||
genre: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
)
|
||||
|
||||
# Foreign key to artist
|
||||
artist_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("artists.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# External IDs
|
||||
spotify_id: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
youtube_playlist_id: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Additional metadata stored as JSON
|
||||
extra_metadata: Mapped[dict] = mapped_column(
|
||||
"metadata",
|
||||
JSONB,
|
||||
default=dict,
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
artist: Mapped["Artist"] = relationship(
|
||||
"Artist",
|
||||
back_populates="albums",
|
||||
lazy="selectin",
|
||||
)
|
||||
tracks: Mapped[list["Track"]] = relationship(
|
||||
"Track",
|
||||
back_populates="album",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Album {self.title}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert album model to dictionary."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"title": self.title,
|
||||
"release_date": self.release_date.isoformat() if self.release_date else None,
|
||||
"image_url": self.image_url,
|
||||
"total_tracks": self.total_tracks,
|
||||
"genre": self.genre,
|
||||
"artist_id": str(self.artist_id) if self.artist_id else None,
|
||||
"spotify_id": self.spotify_id,
|
||||
"youtube_playlist_id": self.youtube_playlist_id,
|
||||
"metadata": self.extra_metadata,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Artist model."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, ARRAY, Integer
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.album import Album
|
||||
from app.models.track import Track
|
||||
|
||||
|
||||
class Artist(Base):
|
||||
"""Artist model representing music artists."""
|
||||
|
||||
__tablename__ = "artists"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Basic info
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
image_url: Mapped[str | None] = mapped_column(
|
||||
String(500),
|
||||
)
|
||||
bio: Mapped[str | None] = mapped_column(
|
||||
String(2000),
|
||||
)
|
||||
|
||||
# Genres as array
|
||||
genres: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(String(100)),
|
||||
default=list,
|
||||
)
|
||||
|
||||
# Popularity score (0-100)
|
||||
popularity: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
)
|
||||
|
||||
# External IDs
|
||||
spotify_id: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
youtube_id: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Additional metadata stored as JSON
|
||||
extra_metadata: Mapped[dict] = mapped_column(
|
||||
"metadata",
|
||||
JSONB,
|
||||
default=dict,
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
albums: Mapped[list["Album"]] = relationship(
|
||||
"Album",
|
||||
back_populates="artist",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
tracks: Mapped[list["Track"]] = relationship(
|
||||
"Track",
|
||||
back_populates="artist",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Artist {self.name}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert artist model to dictionary."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"name": self.name,
|
||||
"image_url": self.image_url,
|
||||
"bio": self.bio,
|
||||
"genres": self.genres,
|
||||
"popularity": self.popularity,
|
||||
"spotify_id": self.spotify_id,
|
||||
"youtube_id": self.youtube_id,
|
||||
"metadata": self.extra_metadata,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Playlist model."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Integer, Text, Boolean, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
from app.models.playlist_track import PlaylistTrack
|
||||
|
||||
|
||||
class Playlist(Base):
|
||||
"""Playlist model representing user playlists."""
|
||||
|
||||
__tablename__ = "playlists"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Foreign key to user
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Basic info
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
description: Mapped[str | None] = mapped_column(
|
||||
Text,
|
||||
)
|
||||
image_url: Mapped[str | None] = mapped_column(
|
||||
String(500),
|
||||
)
|
||||
|
||||
# Playlist flags
|
||||
is_public: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
index=True,
|
||||
)
|
||||
is_collaborative: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
)
|
||||
is_smart: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
comment="True for rules-based smart playlists",
|
||||
)
|
||||
|
||||
# Smart playlist rules stored as JSON
|
||||
smart_rules: Mapped[dict] = mapped_column(
|
||||
JSONB,
|
||||
default=dict,
|
||||
comment="Rules for smart playlists",
|
||||
)
|
||||
|
||||
# Playlist stats
|
||||
track_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
)
|
||||
total_duration: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
comment="Total duration in seconds",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="playlists",
|
||||
lazy="selectin",
|
||||
)
|
||||
playlist_tracks: Mapped[list["PlaylistTrack"]] = relationship(
|
||||
"PlaylistTrack",
|
||||
back_populates="playlist",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
order_by="PlaylistTrack.position",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Playlist {self.name}>"
|
||||
|
||||
def to_dict(self, include_tracks: bool = False) -> dict:
|
||||
"""Convert playlist model to dictionary."""
|
||||
data = {
|
||||
"id": str(self.id),
|
||||
"user_id": str(self.user_id),
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"image_url": self.image_url,
|
||||
"is_public": self.is_public,
|
||||
"is_collaborative": self.is_collaborative,
|
||||
"is_smart": self.is_smart,
|
||||
"smart_rules": self.smart_rules,
|
||||
"track_count": self.track_count,
|
||||
"total_duration": self.total_duration,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
if include_tracks:
|
||||
data["tracks"] = [pt.track.to_dict() for pt in self.playlist_tracks]
|
||||
|
||||
return data
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Playlist-Track association model."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Integer, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.playlist import Playlist
|
||||
from app.models.track import Track
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class PlaylistTrack(Base):
|
||||
"""Association model for Playlist-Track many-to-many relationship."""
|
||||
|
||||
__tablename__ = "playlist_tracks"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("playlist_id", "position", name="uq_playlist_position"),
|
||||
)
|
||||
|
||||
# Primary key
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Foreign keys
|
||||
playlist_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("playlists.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
track_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tracks.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Position in playlist (starts at 0)
|
||||
position: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# User who added this track to playlist
|
||||
added_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Timestamp when track was added
|
||||
added_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
playlist: Mapped["Playlist"] = relationship(
|
||||
"Playlist",
|
||||
back_populates="playlist_tracks",
|
||||
lazy="selectin",
|
||||
)
|
||||
track: Mapped["Track"] = relationship(
|
||||
"Track",
|
||||
lazy="selectin",
|
||||
)
|
||||
added_by_user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="added_playlist_tracks",
|
||||
lazy="selectin",
|
||||
foreign_keys=[added_by],
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<PlaylistTrack playlist={self.playlist_id} track={self.track_id} pos={self.position}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert playlist-track model to dictionary."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"playlist_id": str(self.playlist_id),
|
||||
"track_id": str(self.track_id),
|
||||
"position": self.position,
|
||||
"added_by": str(self.added_by) if self.added_by else None,
|
||||
"added_at": self.added_at.isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Track model."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Integer, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.artist import Artist
|
||||
from app.models.album import Album
|
||||
|
||||
|
||||
class Track(Base):
|
||||
"""Track model representing music tracks."""
|
||||
|
||||
__tablename__ = "tracks"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Basic info
|
||||
title: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
duration: Mapped[int | None] = mapped_column(
|
||||
Integer,
|
||||
comment="Duration in seconds",
|
||||
)
|
||||
|
||||
# Track position
|
||||
track_number: Mapped[int | None] = mapped_column(
|
||||
Integer,
|
||||
)
|
||||
disc_number: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=1,
|
||||
)
|
||||
|
||||
# Cover art
|
||||
image_url: Mapped[str | None] = mapped_column(
|
||||
String(500),
|
||||
)
|
||||
|
||||
# Audio URLs
|
||||
audio_url: Mapped[str | None] = mapped_column(
|
||||
String(500),
|
||||
comment="Cached audio URL",
|
||||
)
|
||||
audio_quality: Mapped[str | None] = mapped_column(
|
||||
String(20),
|
||||
comment="low, medium, high",
|
||||
)
|
||||
|
||||
# Genre and mood
|
||||
genre: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
)
|
||||
mood: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
)
|
||||
|
||||
# Play count
|
||||
play_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
)
|
||||
|
||||
# Foreign keys
|
||||
artist_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("artists.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
album_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("albums.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# External IDs (unique indices)
|
||||
spotify_id: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
youtube_id: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
soundcloud_id: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Additional metadata stored as JSON
|
||||
extra_metadata: Mapped[dict] = mapped_column(
|
||||
"metadata",
|
||||
JSONB,
|
||||
default=dict,
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
artist: Mapped["Artist"] = relationship(
|
||||
"Artist",
|
||||
back_populates="tracks",
|
||||
lazy="selectin",
|
||||
)
|
||||
album: Mapped["Album"] = relationship(
|
||||
"Album",
|
||||
back_populates="tracks",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Track {self.title}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert track model to dictionary."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"title": self.title,
|
||||
"duration": self.duration,
|
||||
"track_number": self.track_number,
|
||||
"disc_number": self.disc_number,
|
||||
"image_url": self.image_url,
|
||||
"audio_url": self.audio_url,
|
||||
"audio_quality": self.audio_quality,
|
||||
"genre": self.genre,
|
||||
"mood": self.mood,
|
||||
"play_count": self.play_count,
|
||||
"artist_id": str(self.artist_id) if self.artist_id else None,
|
||||
"album_id": str(self.album_id) if self.album_id else None,
|
||||
"spotify_id": self.spotify_id,
|
||||
"youtube_id": self.youtube_id,
|
||||
"soundcloud_id": self.soundcloud_id,
|
||||
"metadata": self.extra_metadata,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"""User model."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DATE, String, Boolean, JSON
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.playlist import Playlist
|
||||
from app.models.playlist_track import PlaylistTrack
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User model for authentication and user management."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Authentication fields
|
||||
email: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
password_hash: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Profile fields
|
||||
username: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
display_name: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
)
|
||||
avatar_url: Mapped[str | None] = mapped_column(
|
||||
String(500),
|
||||
)
|
||||
date_of_birth: Mapped[datetime | None] = mapped_column(
|
||||
DATE,
|
||||
)
|
||||
country: Mapped[str | None] = mapped_column(
|
||||
String(2),
|
||||
)
|
||||
|
||||
# Preferences and settings stored as JSON
|
||||
preferences: Mapped[dict] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
)
|
||||
|
||||
# Premium status (for future use)
|
||||
is_premium: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
last_login: Mapped[datetime | None] = mapped_column(
|
||||
default=None,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
playlists: Mapped[list["Playlist"]] = relationship(
|
||||
"Playlist",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
added_playlist_tracks: Mapped[list["PlaylistTrack"]] = relationship(
|
||||
"PlaylistTrack",
|
||||
back_populates="added_by_user",
|
||||
foreign_keys="PlaylistTrack.added_by",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.username} ({self.email})>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert user model to dictionary (excluding sensitive data)."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"email": self.email,
|
||||
"username": self.username,
|
||||
"display_name": self.display_name,
|
||||
"avatar_url": self.avatar_url,
|
||||
"date_of_birth": self.date_of_birth.isoformat() if self.date_of_birth else None,
|
||||
"country": self.country,
|
||||
"preferences": self.preferences,
|
||||
"is_premium": self.is_premium,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"last_login": self.last_login.isoformat() if self.last_login else None,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Schemas module."""
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Authentication schemas."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
"""Base user schema."""
|
||||
|
||||
email: EmailStr
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
display_name: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""Schema for creating a new user."""
|
||||
|
||||
password: str = Field(..., min_length=8, max_length=100)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Schema for updating user profile."""
|
||||
|
||||
display_name: Optional[str] = Field(None, max_length=100)
|
||||
avatar_url: Optional[str] = None
|
||||
date_of_birth: Optional[datetime] = None
|
||||
country: Optional[str] = Field(None, max_length=2)
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
"""Schema for user response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
display_name: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
is_premium: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Schema for JWT token response."""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int # seconds
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
"""Schema for JWT token payload."""
|
||||
|
||||
sub: str # user id
|
||||
exp: int # expiration timestamp
|
||||
type: str # "access" or "refresh"
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Schema for login request."""
|
||||
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""Schema for token refresh request."""
|
||||
|
||||
refresh_token: str
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Music schemas."""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class ArtistBase(BaseModel):
|
||||
"""Base artist schema."""
|
||||
|
||||
name: str
|
||||
image_url: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
genres: List[str] = Field(default_factory=list)
|
||||
popularity: int = 0
|
||||
|
||||
|
||||
class ArtistResponse(ArtistBase):
|
||||
"""Schema for artist response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
spotify_id: Optional[str] = None
|
||||
youtube_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AlbumBase(BaseModel):
|
||||
"""Base album schema."""
|
||||
|
||||
title: str
|
||||
release_date: Optional[datetime] = None
|
||||
image_url: Optional[str] = None
|
||||
total_tracks: int = 0
|
||||
genre: Optional[str] = None
|
||||
|
||||
|
||||
class AlbumResponse(AlbumBase):
|
||||
"""Schema for album response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
artist_id: Optional[UUID] = None
|
||||
spotify_id: Optional[str] = None
|
||||
youtube_playlist_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TrackBase(BaseModel):
|
||||
"""Base track schema."""
|
||||
|
||||
title: str
|
||||
duration: Optional[int] = Field(None, description="Duration in seconds")
|
||||
track_number: Optional[int] = None
|
||||
disc_number: int = 1
|
||||
image_url: Optional[str] = None
|
||||
genre: Optional[str] = None
|
||||
mood: Optional[str] = None
|
||||
|
||||
|
||||
class TrackResponse(TrackBase):
|
||||
"""Schema for track response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
artist_id: Optional[UUID] = None
|
||||
album_id: Optional[UUID] = None
|
||||
artist: Optional[ArtistResponse] = None
|
||||
album: Optional[AlbumResponse] = None
|
||||
audio_url: Optional[str] = None
|
||||
audio_quality: Optional[str] = None
|
||||
play_count: int = 0
|
||||
spotify_id: Optional[str] = None
|
||||
youtube_id: Optional[str] = None
|
||||
soundcloud_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TrackSearchResult(BaseModel):
|
||||
"""Schema for track search result."""
|
||||
|
||||
id: UUID
|
||||
title: str
|
||||
duration: Optional[int] = None
|
||||
image_url: Optional[str] = None
|
||||
artist: Optional[str] = None
|
||||
album: Optional[str] = None
|
||||
audio_url: Optional[str] = None
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
"""Schema for search request."""
|
||||
|
||||
query: str = Field(..., min_length=1, max_length=100)
|
||||
type: Optional[str] = Field("track", pattern="^(track|artist|album|all)$")
|
||||
limit: int = Field(20, ge=1, le=100)
|
||||
offset: int = Field(0, ge=0)
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
"""Schema for search response."""
|
||||
|
||||
tracks: List[TrackSearchResult] = Field(default_factory=list)
|
||||
artists: List[ArtistResponse] = Field(default_factory=list)
|
||||
albums: List[AlbumResponse] = Field(default_factory=list)
|
||||
total: int
|
||||
query: str
|
||||
|
||||
|
||||
class StreamUrlResponse(BaseModel):
|
||||
"""Schema for stream URL response."""
|
||||
|
||||
url: str
|
||||
format: str = "audio/mpeg"
|
||||
duration: Optional[int] = None
|
||||
|
||||
|
||||
class YouTubeSearchResult(BaseModel):
|
||||
"""Schema for YouTube search result."""
|
||||
|
||||
youtube_id: str
|
||||
title: str
|
||||
artist: Optional[str] = None
|
||||
duration: Optional[int] = None
|
||||
thumbnail: Optional[str] = None
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Playlist schemas."""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class PlaylistBase(BaseModel):
|
||||
"""Base playlist schema."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
is_public: bool = False
|
||||
is_collaborative: bool = False
|
||||
|
||||
|
||||
class PlaylistCreate(PlaylistBase):
|
||||
"""Schema for creating a playlist."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PlaylistUpdate(BaseModel):
|
||||
"""Schema for updating a playlist."""
|
||||
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
is_public: Optional[bool] = None
|
||||
is_collaborative: Optional[bool] = None
|
||||
|
||||
|
||||
class PlaylistResponse(PlaylistBase):
|
||||
"""Schema for playlist response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
track_count: int
|
||||
total_duration: int
|
||||
is_smart: bool
|
||||
smart_rules: dict
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PlaylistWithTracks(PlaylistResponse):
|
||||
"""Schema for playlist with tracks."""
|
||||
|
||||
tracks: List[dict] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AddTrackRequest(BaseModel):
|
||||
"""Schema for adding tracks to playlist."""
|
||||
|
||||
track_ids: List[UUID] = Field(..., min_length=1, max_length=100)
|
||||
position: Optional[int] = None
|
||||
|
||||
|
||||
class ReorderTracksRequest(BaseModel):
|
||||
"""Schema for reordering tracks in playlist."""
|
||||
|
||||
track_id: UUID
|
||||
new_position: int = Field(..., ge=0)
|
||||
|
||||
|
||||
class PlaylistTrackResponse(BaseModel):
|
||||
"""Schema for playlist track response."""
|
||||
|
||||
id: UUID
|
||||
playlist_id: UUID
|
||||
track_id: UUID
|
||||
position: int
|
||||
added_at: datetime
|
||||
added_by: Optional[UUID] = None
|
||||
track: Optional[dict] = None
|
||||
@@ -0,0 +1 @@
|
||||
"""Services module."""
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Authentication service."""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
)
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Service for authentication operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def register(
|
||||
self,
|
||||
email: str,
|
||||
username: str,
|
||||
password: str,
|
||||
display_name: Optional[str] = None,
|
||||
) -> User:
|
||||
"""
|
||||
Register a new user.
|
||||
|
||||
Args:
|
||||
email: User email
|
||||
username: Username
|
||||
password: Plain text password
|
||||
display_name: Optional display name
|
||||
|
||||
Returns:
|
||||
Created user
|
||||
|
||||
Raises:
|
||||
ValueError: If email or username already exists
|
||||
"""
|
||||
# Check if email exists
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.email == email)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise ValueError("Email already registered")
|
||||
|
||||
# Check if username exists
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.username == username)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise ValueError("Username already taken")
|
||||
|
||||
# Create user
|
||||
user = User(
|
||||
email=email,
|
||||
username=username,
|
||||
password_hash=get_password_hash(password),
|
||||
display_name=display_name or username,
|
||||
)
|
||||
|
||||
self.db.add(user)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
async def login(self, email: str, password: str) -> User:
|
||||
"""
|
||||
Authenticate user with email and password.
|
||||
|
||||
Args:
|
||||
email: User email
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
Authenticated user
|
||||
|
||||
Raises:
|
||||
ValueError: If credentials are invalid
|
||||
"""
|
||||
# Find user by email
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.email == email)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise ValueError("Invalid email or password")
|
||||
|
||||
# Verify password
|
||||
if not verify_password(password, user.password_hash):
|
||||
raise ValueError("Invalid email or password")
|
||||
|
||||
# Update last login
|
||||
user.last_login = datetime.utcnow()
|
||||
await self.db.commit()
|
||||
|
||||
return user
|
||||
|
||||
async def get_user_by_id(self, user_id: UUID) -> Optional[User]:
|
||||
"""
|
||||
Get user by ID.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
|
||||
Returns:
|
||||
User or None
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def update_user(
|
||||
self,
|
||||
user_id: UUID,
|
||||
display_name: Optional[str] = None,
|
||||
avatar_url: Optional[str] = None,
|
||||
date_of_birth: Optional[datetime] = None,
|
||||
country: Optional[str] = None,
|
||||
) -> User:
|
||||
"""
|
||||
Update user profile.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
display_name: Optional display name
|
||||
avatar_url: Optional avatar URL
|
||||
date_of_birth: Optional date of birth
|
||||
country: Optional country code
|
||||
|
||||
Returns:
|
||||
Updated user
|
||||
|
||||
Raises:
|
||||
ValueError: If user not found
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# Update fields
|
||||
if display_name is not None:
|
||||
user.display_name = display_name
|
||||
if avatar_url is not None:
|
||||
user.avatar_url = avatar_url
|
||||
if date_of_birth is not None:
|
||||
user.date_of_birth = date_of_birth
|
||||
if country is not None:
|
||||
user.country = country
|
||||
|
||||
user.updated_at = datetime.utcnow()
|
||||
await self.db.commit()
|
||||
await self.db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
def create_tokens(self, user_id: UUID) -> tuple[str, str]:
|
||||
"""
|
||||
Create access and refresh tokens for user.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
|
||||
Returns:
|
||||
Tuple of (access_token, refresh_token)
|
||||
"""
|
||||
access_token = create_access_token(subject=str(user_id))
|
||||
refresh_token = create_refresh_token(subject=str(user_id))
|
||||
return access_token, refresh_token
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Music service."""
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.track import Track
|
||||
from app.models.artist import Artist
|
||||
from app.models.album import Album
|
||||
from app.services.youtube_service import YouTubeService
|
||||
|
||||
|
||||
class MusicService:
|
||||
"""Service for music operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.youtube = YouTubeService()
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
search_type: str = "all",
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> dict:
|
||||
"""
|
||||
Search for music across database and YouTube.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
search_type: Type of content (track, artist, album, all)
|
||||
limit: Maximum results
|
||||
offset: Pagination offset
|
||||
|
||||
Returns:
|
||||
Search results with tracks, artists, albums
|
||||
"""
|
||||
results = {
|
||||
"tracks": [],
|
||||
"artists": [],
|
||||
"albums": [],
|
||||
"total": 0,
|
||||
"query": query,
|
||||
}
|
||||
|
||||
# Search database first
|
||||
if search_type in ["track", "all"]:
|
||||
results["tracks"] = await self._search_tracks(query, limit)
|
||||
results["total"] += len(results["tracks"])
|
||||
|
||||
if search_type in ["artist", "all"]:
|
||||
results["artists"] = await self._search_artists(query, limit)
|
||||
results["total"] += len(results["artists"])
|
||||
|
||||
if search_type in ["album", "all"]:
|
||||
results["albums"] = await self._search_albums(query, limit)
|
||||
results["total"] += len(results["albums"])
|
||||
|
||||
# If no local results, search YouTube
|
||||
if results["total"] == 0:
|
||||
yt_results = await self.youtube.search(query, max_results=limit)
|
||||
results["tracks"] = yt_results[:limit]
|
||||
|
||||
return results
|
||||
|
||||
async def _search_tracks(self, query: str, limit: int) -> List[dict]:
|
||||
"""Search tracks in database."""
|
||||
stmt = (
|
||||
select(Track)
|
||||
.options(selectinload(Track.artist), selectinload(Track.album))
|
||||
.where(
|
||||
or_(
|
||||
Track.title.ilike(f"%{query}%"),
|
||||
)
|
||||
)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
tracks = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(track.id),
|
||||
"title": track.title,
|
||||
"duration": track.duration,
|
||||
"image_url": track.image_url,
|
||||
"artist": track.artist.name if track.artist else None,
|
||||
"album": track.album.title if track.album else None,
|
||||
"youtube_id": track.youtube_id,
|
||||
}
|
||||
for track in tracks
|
||||
]
|
||||
|
||||
async def _search_artists(self, query: str, limit: int) -> List[dict]:
|
||||
"""Search artists in database."""
|
||||
stmt = (
|
||||
select(Artist)
|
||||
.where(Artist.name.ilike(f"%{query}%"))
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
artists = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(artist.id),
|
||||
"name": artist.name,
|
||||
"image_url": artist.image_url,
|
||||
"genres": artist.genres,
|
||||
"popularity": artist.popularity,
|
||||
}
|
||||
for artist in artists
|
||||
]
|
||||
|
||||
async def _search_albums(self, query: str, limit: int) -> List[dict]:
|
||||
"""Search albums in database."""
|
||||
stmt = (
|
||||
select(Album)
|
||||
.options(selectinload(Album.artist))
|
||||
.where(Album.title.ilike(f"%{query}%"))
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
albums = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(album.id),
|
||||
"title": album.title,
|
||||
"image_url": album.image_url,
|
||||
"artist": album.artist.name if album.artist else None,
|
||||
"total_tracks": album.total_tracks,
|
||||
"release_date": album.release_date.isoformat() if album.release_date else None,
|
||||
}
|
||||
for album in albums
|
||||
]
|
||||
|
||||
async def get_track(self, track_id: UUID) -> Optional[Track]:
|
||||
"""
|
||||
Get track by ID.
|
||||
|
||||
Args:
|
||||
track_id: Track UUID
|
||||
|
||||
Returns:
|
||||
Track or None
|
||||
"""
|
||||
stmt = (
|
||||
select(Track)
|
||||
.options(selectinload(Track.artist), selectinload(Track.album))
|
||||
.where(Track.id == track_id)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_stream_url(
|
||||
self,
|
||||
track_id: UUID,
|
||||
quality: str = "high",
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get stream URL for a track.
|
||||
|
||||
Args:
|
||||
track_id: Track UUID
|
||||
quality: Audio quality
|
||||
|
||||
Returns:
|
||||
Stream URL or None
|
||||
"""
|
||||
track = await self.get_track(track_id)
|
||||
if not track or not track.youtube_id:
|
||||
return None
|
||||
|
||||
# Try to get direct stream URL from YouTube
|
||||
stream_url = await self.youtube.get_stream_url(track.youtube_id)
|
||||
if stream_url:
|
||||
return stream_url
|
||||
|
||||
# Fallback: download and serve locally
|
||||
cache_path = await self.youtube.download_audio(track.youtube_id, quality)
|
||||
if cache_path:
|
||||
# In production, you'd serve this through a dedicated endpoint
|
||||
return f"/api/v1/music/tracks/{track_id}/stream?cache=true"
|
||||
|
||||
return None
|
||||
|
||||
async def create_track_from_youtube(
|
||||
self,
|
||||
youtube_id: str,
|
||||
title: str,
|
||||
artist_name: Optional[str] = None,
|
||||
album_name: Optional[str] = None,
|
||||
) -> Track:
|
||||
"""
|
||||
Create a track from YouTube video ID.
|
||||
|
||||
Args:
|
||||
youtube_id: YouTube video ID
|
||||
title: Track title
|
||||
artist_name: Optional artist name
|
||||
album_name: Optional album name
|
||||
|
||||
Returns:
|
||||
Created track
|
||||
"""
|
||||
# Get video info from YouTube
|
||||
video_info = await self.youtube.get_video_info(youtube_id)
|
||||
if video_info:
|
||||
title = video_info.get("title", title)
|
||||
artist_name = artist_name or video_info.get("artist")
|
||||
duration = video_info.get("duration")
|
||||
thumbnail = video_info.get("thumbnail")
|
||||
else:
|
||||
duration = None
|
||||
thumbnail = None
|
||||
|
||||
# Find or create artist
|
||||
artist = None
|
||||
if artist_name:
|
||||
stmt = select(Artist).where(Artist.name == artist_name)
|
||||
result = await self.db.execute(stmt)
|
||||
artist = result.scalar_one_or_none()
|
||||
|
||||
if not artist:
|
||||
artist = Artist(
|
||||
name=artist_name,
|
||||
image_url=thumbnail,
|
||||
)
|
||||
self.db.add(artist)
|
||||
await self.db.flush()
|
||||
|
||||
# Create track
|
||||
track = Track(
|
||||
title=title,
|
||||
youtube_id=youtube_id,
|
||||
artist_id=artist.id if artist else None,
|
||||
duration=duration,
|
||||
image_url=thumbnail,
|
||||
)
|
||||
|
||||
self.db.add(track)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(track)
|
||||
|
||||
return track
|
||||
|
||||
async def get_recommendations(
|
||||
self,
|
||||
track_id: UUID,
|
||||
limit: int = 10,
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Get recommendations based on a track.
|
||||
|
||||
Args:
|
||||
track_id: Seed track UUID
|
||||
limit: Number of recommendations
|
||||
|
||||
Returns:
|
||||
List of recommended tracks
|
||||
"""
|
||||
track = await self.get_track(track_id)
|
||||
if not track or not track.youtube_id:
|
||||
return []
|
||||
|
||||
# Get related videos from YouTube
|
||||
related = await self.youtube.get_related_videos(track.youtube_id, max_results=limit)
|
||||
|
||||
return related[:limit]
|
||||
@@ -0,0 +1,402 @@
|
||||
"""Playlist service."""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.playlist import Playlist
|
||||
from app.models.playlist_track import PlaylistTrack
|
||||
from app.models.track import Track
|
||||
|
||||
|
||||
class PlaylistService:
|
||||
"""Service for playlist operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def create_playlist(
|
||||
self,
|
||||
user_id: UUID,
|
||||
name: str,
|
||||
description: Optional[str] = None,
|
||||
image_url: Optional[str] = None,
|
||||
is_public: bool = False,
|
||||
is_collaborative: bool = False,
|
||||
) -> Playlist:
|
||||
"""
|
||||
Create a new playlist.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
name: Playlist name
|
||||
description: Optional description
|
||||
image_url: Optional cover image URL
|
||||
is_public: Whether playlist is public
|
||||
is_collaborative: Whether playlist is collaborative
|
||||
|
||||
Returns:
|
||||
Created playlist
|
||||
"""
|
||||
playlist = Playlist(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
description=description,
|
||||
image_url=image_url,
|
||||
is_public=is_public,
|
||||
is_collaborative=is_collaborative,
|
||||
track_count=0,
|
||||
total_duration=0,
|
||||
)
|
||||
|
||||
self.db.add(playlist)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
async def get_playlist(
|
||||
self,
|
||||
playlist_id: UUID,
|
||||
include_tracks: bool = False,
|
||||
) -> Optional[Playlist]:
|
||||
"""
|
||||
Get playlist by ID.
|
||||
|
||||
Args:
|
||||
playlist_id: Playlist UUID
|
||||
include_tracks: Whether to include tracks
|
||||
|
||||
Returns:
|
||||
Playlist or None
|
||||
"""
|
||||
stmt = select(Playlist).where(Playlist.id == playlist_id)
|
||||
|
||||
if include_tracks:
|
||||
stmt = stmt.options(selectinload(Playlist.playlist_tracks))
|
||||
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_user_playlists(
|
||||
self,
|
||||
user_id: UUID,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> List[Playlist]:
|
||||
"""
|
||||
Get all playlists for a user.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
limit: Maximum results
|
||||
offset: Pagination offset
|
||||
|
||||
Returns:
|
||||
List of playlists
|
||||
"""
|
||||
stmt = (
|
||||
select(Playlist)
|
||||
.where(Playlist.user_id == user_id)
|
||||
.order_by(Playlist.updated_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def update_playlist(
|
||||
self,
|
||||
playlist_id: UUID,
|
||||
user_id: UUID,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
image_url: Optional[str] = None,
|
||||
is_public: Optional[bool] = None,
|
||||
) -> Playlist:
|
||||
"""
|
||||
Update playlist.
|
||||
|
||||
Args:
|
||||
playlist_id: Playlist UUID
|
||||
user_id: User UUID (for ownership check)
|
||||
name: New name
|
||||
description: New description
|
||||
image_url: New image URL
|
||||
is_public: New public status
|
||||
|
||||
Returns:
|
||||
Updated playlist
|
||||
|
||||
Raises:
|
||||
ValueError: If playlist not found or user not owner
|
||||
"""
|
||||
playlist = await self.get_playlist(playlist_id)
|
||||
if not playlist:
|
||||
raise ValueError("Playlist not found")
|
||||
|
||||
if playlist.user_id != user_id:
|
||||
raise ValueError("Not authorized to update this playlist")
|
||||
|
||||
if name is not None:
|
||||
playlist.name = name
|
||||
if description is not None:
|
||||
playlist.description = description
|
||||
if image_url is not None:
|
||||
playlist.image_url = image_url
|
||||
if is_public is not None:
|
||||
playlist.is_public = is_public
|
||||
|
||||
playlist.updated_at = datetime.utcnow()
|
||||
await self.db.commit()
|
||||
await self.db.refresh(playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
async def delete_playlist(
|
||||
self,
|
||||
playlist_id: UUID,
|
||||
user_id: UUID,
|
||||
) -> None:
|
||||
"""
|
||||
Delete a playlist.
|
||||
|
||||
Args:
|
||||
playlist_id: Playlist UUID
|
||||
user_id: User UUID (for ownership check)
|
||||
|
||||
Raises:
|
||||
ValueError: If playlist not found or user not owner
|
||||
"""
|
||||
playlist = await self.get_playlist(playlist_id)
|
||||
if not playlist:
|
||||
raise ValueError("Playlist not found")
|
||||
|
||||
if playlist.user_id != user_id:
|
||||
raise ValueError("Not authorized to delete this playlist")
|
||||
|
||||
await self.db.delete(playlist)
|
||||
await self.db.commit()
|
||||
|
||||
async def add_tracks(
|
||||
self,
|
||||
playlist_id: UUID,
|
||||
track_ids: List[UUID],
|
||||
user_id: UUID,
|
||||
position: Optional[int] = None,
|
||||
) -> Playlist:
|
||||
"""
|
||||
Add tracks to a playlist.
|
||||
|
||||
Args:
|
||||
playlist_id: Playlist UUID
|
||||
track_ids: List of track UUIDs
|
||||
user_id: User UUID adding the tracks
|
||||
position: Optional starting position
|
||||
|
||||
Returns:
|
||||
Updated playlist
|
||||
|
||||
Raises:
|
||||
ValueError: If playlist not found
|
||||
"""
|
||||
playlist = await self.get_playlist(playlist_id)
|
||||
if not playlist:
|
||||
raise ValueError("Playlist not found")
|
||||
|
||||
# Get current max position
|
||||
stmt = (
|
||||
select(PlaylistTrack)
|
||||
.where(PlaylistTrack.playlist_id == playlist_id)
|
||||
.order_by(PlaylistTrack.position.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
last_track = result.scalar_one_or_none()
|
||||
max_position = last_track.position if last_track else -1
|
||||
|
||||
# Determine starting position
|
||||
if position is None:
|
||||
position = max_position + 1
|
||||
|
||||
# Add tracks
|
||||
current_position = position
|
||||
for track_id in track_ids:
|
||||
# Verify track exists
|
||||
track_stmt = select(Track).where(Track.id == track_id)
|
||||
track_result = await self.db.execute(track_stmt)
|
||||
track = track_result.scalar_one_or_none()
|
||||
|
||||
if not track:
|
||||
continue
|
||||
|
||||
# Create playlist track
|
||||
playlist_track = PlaylistTrack(
|
||||
playlist_id=playlist_id,
|
||||
track_id=track_id,
|
||||
position=current_position,
|
||||
added_by=user_id,
|
||||
)
|
||||
self.db.add(playlist_track)
|
||||
current_position += 1
|
||||
|
||||
# Update playlist stats
|
||||
playlist.track_count += len(track_ids)
|
||||
playlist.total_duration = await self._calculate_playlist_duration(playlist_id)
|
||||
playlist.updated_at = datetime.utcnow()
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
async def remove_track(
|
||||
self,
|
||||
playlist_id: UUID,
|
||||
track_id: UUID,
|
||||
user_id: UUID,
|
||||
) -> Playlist:
|
||||
"""
|
||||
Remove a track from a playlist.
|
||||
|
||||
Args:
|
||||
playlist_id: Playlist UUID
|
||||
track_id: Track UUID to remove
|
||||
user_id: User UUID (for ownership check)
|
||||
|
||||
Returns:
|
||||
Updated playlist
|
||||
|
||||
Raises:
|
||||
ValueError: If playlist or track not found
|
||||
"""
|
||||
playlist = await self.get_playlist(playlist_id)
|
||||
if not playlist:
|
||||
raise ValueError("Playlist not found")
|
||||
|
||||
if playlist.user_id != user_id:
|
||||
raise ValueError("Not authorized to modify this playlist")
|
||||
|
||||
# Find and remove the track
|
||||
stmt = select(PlaylistTrack).where(
|
||||
PlaylistTrack.playlist_id == playlist_id,
|
||||
PlaylistTrack.track_id == track_id,
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
playlist_track = result.scalar_one_or_none()
|
||||
|
||||
if not playlist_track:
|
||||
raise ValueError("Track not in playlist")
|
||||
|
||||
# Remove track
|
||||
await self.db.delete(playlist_track)
|
||||
|
||||
# Reorder remaining tracks
|
||||
tracks_stmt = (
|
||||
select(PlaylistTrack)
|
||||
.where(PlaylistTrack.playlist_id == playlist_id)
|
||||
.order_by(PlaylistTrack.position)
|
||||
)
|
||||
tracks_result = await self.db.execute(tracks_stmt)
|
||||
tracks = tracks_result.scalars().all()
|
||||
|
||||
for index, track in enumerate(tracks):
|
||||
track.position = index
|
||||
|
||||
# Update playlist stats
|
||||
playlist.track_count -= 1
|
||||
playlist.total_duration = await self._calculate_playlist_duration(playlist_id)
|
||||
playlist.updated_at = datetime.utcnow()
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
async def reorder_track(
|
||||
self,
|
||||
playlist_id: UUID,
|
||||
track_id: UUID,
|
||||
new_position: int,
|
||||
user_id: UUID,
|
||||
) -> Playlist:
|
||||
"""
|
||||
Reorder a track within a playlist.
|
||||
|
||||
Args:
|
||||
playlist_id: Playlist UUID
|
||||
track_id: Track UUID to reorder
|
||||
new_position: New position (0-indexed)
|
||||
user_id: User UUID (for ownership check)
|
||||
|
||||
Returns:
|
||||
Updated playlist
|
||||
|
||||
Raises:
|
||||
ValueError: If playlist or track not found
|
||||
"""
|
||||
playlist = await self.get_playlist(playlist_id)
|
||||
if not playlist:
|
||||
raise ValueError("Playlist not found")
|
||||
|
||||
if playlist.user_id != user_id:
|
||||
raise ValueError("Not authorized to modify this playlist")
|
||||
|
||||
# Get all tracks in playlist
|
||||
stmt = (
|
||||
select(PlaylistTrack)
|
||||
.where(PlaylistTrack.playlist_id == playlist_id)
|
||||
.order_by(PlaylistTrack.position)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
tracks = list(result.scalars().all())
|
||||
|
||||
# Find the track to move
|
||||
track_to_move = None
|
||||
for track in tracks:
|
||||
if track.track_id == track_id:
|
||||
track_to_move = track
|
||||
break
|
||||
|
||||
if not track_to_move:
|
||||
raise ValueError("Track not in playlist")
|
||||
|
||||
# Reorder
|
||||
old_position = track_to_move.position
|
||||
if old_position < new_position:
|
||||
# Moving down: shift tracks between old+1 and new up by 1
|
||||
for track in tracks:
|
||||
if old_position < track.position <= new_position:
|
||||
track.position -= 1
|
||||
else:
|
||||
# Moving up: shift tracks between new and old-1 down by 1
|
||||
for track in tracks:
|
||||
if new_position <= track.position < old_position:
|
||||
track.position += 1
|
||||
|
||||
# Set new position
|
||||
track_to_move.position = new_position
|
||||
|
||||
playlist.updated_at = datetime.utcnow()
|
||||
await self.db.commit()
|
||||
await self.db.refresh(playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
async def _calculate_playlist_duration(self, playlist_id: UUID) -> int:
|
||||
"""Calculate total duration of a playlist in seconds."""
|
||||
stmt = (
|
||||
select(Track)
|
||||
.join(PlaylistTrack, Track.id == PlaylistTrack.track_id)
|
||||
.where(PlaylistTrack.playlist_id == playlist_id)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
tracks = result.scalars().all()
|
||||
|
||||
total_duration = sum(
|
||||
track.duration for track in tracks if track.duration is not None
|
||||
)
|
||||
return total_duration
|
||||
@@ -0,0 +1,295 @@
|
||||
"""YouTube service using yt-dlp."""
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class YouTubeService:
|
||||
"""Service for YouTube operations using yt-dlp."""
|
||||
|
||||
def __init__(self):
|
||||
self.ytdlp_path = settings.YTDLP_PATH
|
||||
self.cache_dir = Path(settings.AUDIO_CACHE_PATH)
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
max_results: int = 20,
|
||||
search_type: str = "videos",
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search YouTube for videos.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
max_results: Maximum number of results
|
||||
search_type: Type of search (videos, playlists, etc.)
|
||||
|
||||
Returns:
|
||||
List of search results
|
||||
"""
|
||||
cmd = [
|
||||
self.ytdlp_path,
|
||||
"ytsearch" + str(max_results) + ":" + query,
|
||||
"--dump-json",
|
||||
"--flat-playlist",
|
||||
"--skip-download",
|
||||
"--no-warnings",
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
error_msg = stderr.decode() if stderr else "Unknown error"
|
||||
print(f"yt-dlp search error: {error_msg}")
|
||||
return []
|
||||
|
||||
# Parse JSON output (one line per video)
|
||||
results = []
|
||||
for line in stdout.decode().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
results.append(self._parse_search_result(data))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error searching YouTube: {e}")
|
||||
return []
|
||||
|
||||
def _parse_search_result(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Parse yt-dlp search result."""
|
||||
return {
|
||||
"youtube_id": data.get("id", ""),
|
||||
"title": data.get("title", ""),
|
||||
"artist": data.get("artist", data.get("uploader", "")),
|
||||
"duration": self._parse_duration(data.get("duration")),
|
||||
"thumbnail": data.get("thumbnail"),
|
||||
"url": f"https://www.youtube.com/watch?v={data.get('id', '')}",
|
||||
}
|
||||
|
||||
def _parse_duration(self, duration: Optional[int]) -> Optional[int]:
|
||||
"""Parse duration in seconds."""
|
||||
if duration is None:
|
||||
return None
|
||||
return int(duration)
|
||||
|
||||
async def get_video_info(self, video_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get detailed information about a video.
|
||||
|
||||
Args:
|
||||
video_id: YouTube video ID
|
||||
|
||||
Returns:
|
||||
Video information or None
|
||||
"""
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
cmd = [
|
||||
self.ytdlp_path,
|
||||
url,
|
||||
"--dump-json",
|
||||
"--skip-download",
|
||||
"--no-warnings",
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
return None
|
||||
|
||||
data = json.loads(stdout.decode())
|
||||
return self._parse_video_info(data)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting video info: {e}")
|
||||
return None
|
||||
|
||||
def _parse_video_info(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Parse yt-dlp video info."""
|
||||
return {
|
||||
"youtube_id": data.get("id", ""),
|
||||
"title": data.get("title", ""),
|
||||
"artist": data.get("artist", data.get("uploader", "")),
|
||||
"album": data.get("album", ""),
|
||||
"duration": self._parse_duration(data.get("duration")),
|
||||
"thumbnail": data.get("thumbnail"),
|
||||
"description": data.get("description"),
|
||||
"genres": data.get("genres", []),
|
||||
"upload_date": data.get("upload_date"),
|
||||
}
|
||||
|
||||
async def get_stream_url(self, video_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get direct stream URL for a video.
|
||||
|
||||
Args:
|
||||
video_id: YouTube video ID
|
||||
|
||||
Returns:
|
||||
Stream URL or None
|
||||
"""
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
cmd = [
|
||||
self.ytdlp_path,
|
||||
url,
|
||||
"--get-url",
|
||||
"--format",
|
||||
"bestaudio[ext=m4a]/bestaudio/best",
|
||||
"--no-warnings",
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
return None
|
||||
|
||||
stream_url = stdout.decode().strip()
|
||||
return stream_url if stream_url else None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting stream URL: {e}")
|
||||
return None
|
||||
|
||||
async def download_audio(
|
||||
self,
|
||||
video_id: str,
|
||||
quality: str = "high",
|
||||
) -> Optional[Path]:
|
||||
"""
|
||||
Download audio from YouTube and cache it.
|
||||
|
||||
Args:
|
||||
video_id: YouTube video ID
|
||||
quality: Audio quality (low, medium, high)
|
||||
|
||||
Returns:
|
||||
Path to downloaded file or None
|
||||
"""
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
cache_path = self.cache_dir / f"{video_id}.mp3"
|
||||
|
||||
# Check if already cached
|
||||
if cache_path.exists():
|
||||
return cache_path
|
||||
|
||||
# Determine format based on quality
|
||||
if quality == "high":
|
||||
audio_format = "320"
|
||||
elif quality == "medium":
|
||||
audio_format = "192"
|
||||
else:
|
||||
audio_format = "128"
|
||||
|
||||
cmd = [
|
||||
self.ytdlp_path,
|
||||
url,
|
||||
"--extract-audio",
|
||||
"--audio-format",
|
||||
"mp3",
|
||||
"--audio-quality",
|
||||
audio_format,
|
||||
"--output",
|
||||
str(cache_path),
|
||||
"--no-warnings",
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
error_msg = stderr.decode() if stderr else "Unknown error"
|
||||
print(f"Error downloading audio: {error_msg}")
|
||||
return None
|
||||
|
||||
return cache_path if cache_path.exists() else None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error downloading audio: {e}")
|
||||
return None
|
||||
|
||||
async def get_related_videos(self, video_id: str, max_results: int = 10) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get related videos for a video.
|
||||
|
||||
Args:
|
||||
video_id: YouTube video ID
|
||||
max_results: Maximum number of results
|
||||
|
||||
Returns:
|
||||
List of related videos
|
||||
"""
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
cmd = [
|
||||
self.ytdlp_path,
|
||||
url,
|
||||
"--flat-playlist",
|
||||
"--playlist-end",
|
||||
str(max_results),
|
||||
"--dump-json",
|
||||
"--no-warnings",
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
return []
|
||||
|
||||
results = []
|
||||
for line in stdout.decode().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
if data.get("id") != video_id: # Exclude the video itself
|
||||
results.append(self._parse_search_result(data))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting related videos: {e}")
|
||||
return []
|
||||
@@ -0,0 +1,52 @@
|
||||
# FastAPI and server
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
python-multipart==0.0.6
|
||||
|
||||
# Database
|
||||
sqlalchemy==2.0.25
|
||||
asyncpg==0.30.0
|
||||
alembic==1.13.1
|
||||
|
||||
# Cache
|
||||
redis==5.2.1
|
||||
hiredis==3.1.0
|
||||
|
||||
# Validation and settings
|
||||
pydantic==2.10.6
|
||||
pydantic-settings==2.7.1
|
||||
email-validator==2.1.1
|
||||
|
||||
# Security
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-dotenv==1.0.0
|
||||
|
||||
# YouTube and streaming
|
||||
yt-dlp==2023.12.30
|
||||
|
||||
# HTTP client
|
||||
httpx==0.26.0
|
||||
|
||||
# Background tasks
|
||||
celery==5.3.6
|
||||
flower==2.0.1
|
||||
|
||||
# OAuth
|
||||
authlib==1.3.0
|
||||
|
||||
# Utils
|
||||
python-dateutil==2.8.2
|
||||
|
||||
# Development
|
||||
pytest==7.4.4
|
||||
pytest-asyncio==0.23.3
|
||||
black==24.1.1
|
||||
ruff==0.1.14
|
||||
mypy==1.8.0
|
||||
|
||||
# Spotify API (for import)
|
||||
spotipy==2.23.0
|
||||
|
||||
# Last.fm API (optional, for metadata)
|
||||
pylast==5.2.0
|
||||
@@ -0,0 +1,62 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: spotify_le_2_postgres
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-spotify}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-spotify_password}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-spotify_le_2}
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-spotify}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- spotify_network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: spotify_le_2_redis
|
||||
command: redis-server --appendonly yes
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- spotify_network
|
||||
|
||||
# Optional: Adminer for database management
|
||||
# Uncomment if you want a web-based database admin interface
|
||||
# adminer:
|
||||
# image: adminer:latest
|
||||
# container_name: spotify_le_2_adminer
|
||||
# ports:
|
||||
# - "8080:8080"
|
||||
# depends_on:
|
||||
# postgres:
|
||||
# condition: service_healthy
|
||||
# networks:
|
||||
# - spotify_network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
spotify_network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,724 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Spotify Le 2 - Design Preview Neon Cyberpunk</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-primary: #0A0E27;
|
||||
--bg-surface: #1A1F3A;
|
||||
--bg-surface-variant: #252B4A;
|
||||
--color-primary: #00F0FF;
|
||||
--color-secondary: #BF00FF;
|
||||
--color-accent: #FF006E;
|
||||
--color-success: #39FF14;
|
||||
--color-warning: #FFD600;
|
||||
--color-error: #FF2A6D;
|
||||
--text-on-bg: #E0E6FF;
|
||||
--text-on-surface: #B0B8D4;
|
||||
--text-muted: #6A7294;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-on-bg);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Scanlines overlay effect */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 240, 255, 0.03) 0px,
|
||||
rgba(0, 240, 255, 0.03) 1px,
|
||||
transparent 1px,
|
||||
transparent 2px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 60px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-shadow: 0 0 60px rgba(0, 240, 255, 0.5);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.section {
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 30px;
|
||||
color: var(--color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 30px;
|
||||
background: linear-gradient(180deg, var(--color-primary), var(--color-secondary));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Color Palette Grid */
|
||||
.color-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.color-card {
|
||||
background: var(--bg-surface);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(0, 240, 255, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 40px rgba(0, 240, 255, 0.2);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.color-swatch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.1) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
.color-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.color-hex {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.button-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 14px 28px;
|
||||
border-radius: 8px;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
|
||||
color: var(--bg-primary);
|
||||
box-shadow: 0 4px 20px rgba(0, 240, 255, 0.4);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 6px 30px rgba(0, 240, 255, 0.6);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
border: 2px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(0, 240, 255, 0.1);
|
||||
box-shadow: 0 0 20px rgba(0, 240, 255, 0.3);
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
box-shadow: 0 4px 20px rgba(255, 0, 110, 0.4);
|
||||
}
|
||||
|
||||
.btn-accent:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 6px 30px rgba(255, 0, 110, 0.6);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 25px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 240, 255, 0.15);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 15px 50px rgba(0, 240, 255, 0.2);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.card-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-image::after {
|
||||
content: '►';
|
||||
font-size: 48px;
|
||||
color: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
font-size: 12px;
|
||||
color: var(--text-on-surface);
|
||||
}
|
||||
|
||||
/* Player Mini */
|
||||
.player-preview {
|
||||
background: var(--bg-surface);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(0, 240, 255, 0.2);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.player-preview::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--color-primary), var(--color-secondary), var(--color-accent));
|
||||
}
|
||||
|
||||
.player-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.player-art {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, var(--color-secondary), var(--color-accent));
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
box-shadow: 0 8px 30px rgba(191, 0, 255, 0.4);
|
||||
}
|
||||
|
||||
.player-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.player-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.player-artist {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.player-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-surface-variant);
|
||||
border: none;
|
||||
color: var(--text-on-bg);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: var(--color-primary);
|
||||
color: var(--bg-primary);
|
||||
box-shadow: 0 0 20px rgba(0, 240, 255, 0.5);
|
||||
}
|
||||
|
||||
.control-btn.play {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
|
||||
font-size: 24px;
|
||||
box-shadow: 0 4px 25px rgba(0, 240, 255, 0.5);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--bg-surface-variant);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
width: 35%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--color-primary), var(--color-secondary));
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 10px var(--color-primary);
|
||||
}
|
||||
|
||||
/* Input Fields */
|
||||
.input-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
width: 100%;
|
||||
padding: 16px 20px;
|
||||
background: var(--bg-surface);
|
||||
border: 2px solid rgba(0, 240, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
color: var(--text-on-bg);
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 20px rgba(0, 240, 255, 0.3);
|
||||
}
|
||||
|
||||
.input-field::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Search Bar */
|
||||
.search-preview {
|
||||
position: relative;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 18px 60px 18px 24px;
|
||||
background: var(--bg-surface);
|
||||
border: 2px solid rgba(0, 240, 255, 0.2);
|
||||
border-radius: 50px;
|
||||
color: var(--text-on-bg);
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 30px rgba(0, 240, 255, 0.4);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-primary);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
.tags-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid rgba(0, 240, 255, 0.3);
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--color-primary);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
background: rgba(0, 240, 255, 0.1);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 15px rgba(0, 240, 255, 0.3);
|
||||
}
|
||||
|
||||
.tag.active {
|
||||
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
|
||||
color: var(--bg-primary);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 0 20px rgba(0, 240, 255, 0.4); }
|
||||
50% { box-shadow: 0 0 40px rgba(0, 240, 255, 0.8); }
|
||||
}
|
||||
|
||||
.glow-pulse {
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient-shift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(90deg, var(--color-primary), var(--color-secondary), var(--color-accent), var(--color-primary));
|
||||
background-size: 300% 100%;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
animation: gradient-shift 3s infinite;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h1 class="gradient-text">Spotify Le 2</h1>
|
||||
<p>Design System - Néon Cyberpunk Theme</p>
|
||||
</div>
|
||||
|
||||
<!-- Color Palette -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">Palette de Couleurs</h2>
|
||||
<div class="color-grid">
|
||||
<div class="color-card">
|
||||
<div class="color-swatch" style="background: #0A0E27;"></div>
|
||||
<div class="color-name">Background Primary</div>
|
||||
<div class="color-hex">#0A0E27</div>
|
||||
</div>
|
||||
<div class="color-card">
|
||||
<div class="color-swatch" style="background: #1A1F3A;"></div>
|
||||
<div class="color-name">Background Surface</div>
|
||||
<div class="color-hex">#1A1F3A</div>
|
||||
</div>
|
||||
<div class="color-card">
|
||||
<div class="color-swatch" style="background: #00F0FF;"></div>
|
||||
<div class="color-name">Primary (Cyan)</div>
|
||||
<div class="color-hex">#00F0FF</div>
|
||||
</div>
|
||||
<div class="color-card">
|
||||
<div class="color-swatch" style="background: #BF00FF;"></div>
|
||||
<div class="color-name">Secondary (Violet)</div>
|
||||
<div class="color-hex">#BF00FF</div>
|
||||
</div>
|
||||
<div class="color-card">
|
||||
<div class="color-swatch" style="background: #FF006E;"></div>
|
||||
<div class="color-name">Accent (Rose)</div>
|
||||
<div class="color-hex">#FF006E</div>
|
||||
</div>
|
||||
<div class="color-card">
|
||||
<div class="color-swatch" style="background: #39FF14;"></div>
|
||||
<div class="color-name">Success (Vert)</div>
|
||||
<div class="color-hex">#39FF14</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">Boutons</h2>
|
||||
<div class="button-grid">
|
||||
<button class="btn btn-primary glow-pulse">Primary Button</button>
|
||||
<button class="btn btn-secondary">Secondary Button</button>
|
||||
<button class="btn btn-accent">Accent Button</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cards -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">Cards (Tracks/Albums)</h2>
|
||||
<div class="card-grid">
|
||||
<div class="card">
|
||||
<div class="card-image"></div>
|
||||
<div class="card-content">
|
||||
<div class="card-title">Midnight City</div>
|
||||
<div class="card-subtitle">M83</div>
|
||||
<div class="card-meta">
|
||||
<span>4:03</span>
|
||||
<span>•</span>
|
||||
<span>Electronic</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-image" style="background: linear-gradient(135deg, #FF006E, #BF00FF);"></div>
|
||||
<div class="card-content">
|
||||
<div class="card-title">Blinding Lights</div>
|
||||
<div class="card-subtitle">The Weeknd</div>
|
||||
<div class="card-meta">
|
||||
<span>3:20</span>
|
||||
<span>•</span>
|
||||
<span>Synthwave</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-image" style="background: linear-gradient(135deg, #39FF14, #00F0FF);"></div>
|
||||
<div class="card-content">
|
||||
<div class="card-title">Nightcall</div>
|
||||
<div class="card-subtitle">Kavinsky</div>
|
||||
<div class="card-meta">
|
||||
<span>4:18</span>
|
||||
<span>•</span>
|
||||
<span>Retrowave</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">Mini Player</h2>
|
||||
<div class="player-preview">
|
||||
<div class="player-info">
|
||||
<div class="player-art">🎵</div>
|
||||
<div class="player-text">
|
||||
<div class="player-title">Resonance</div>
|
||||
<div class="player-artist">Home</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="player-controls">
|
||||
<button class="control-btn">⏮</button>
|
||||
<button class="control-btn play">▶</button>
|
||||
<button class="control-btn">⏭</button>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">Barre de Recherche</h2>
|
||||
<div class="search-preview">
|
||||
<input type="text" class="search-input" placeholder="Rechercher des tracks, artistes, albums...">
|
||||
<span class="search-icon">🔍</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Fields -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">Champs de Saisie</h2>
|
||||
<div class="input-grid">
|
||||
<div class="input-group">
|
||||
<input type="text" class="input-field" placeholder="Email">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<input type="password" class="input-field" placeholder="Password">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">Tags / Genres</h2>
|
||||
<div class="tags-container">
|
||||
<span class="tag">Electronic</span>
|
||||
<span class="tag active">Synthwave</span>
|
||||
<span class="tag">Retrowave</span>
|
||||
<span class="tag">Cyberpunk</span>
|
||||
<span class="tag">Dark Synth</span>
|
||||
<span class="tag">Dream Pop</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Typography Demo -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">Typographie</h2>
|
||||
<div style="color: var(--text-on-bg);">
|
||||
<h1 style="font-size: 32px; margin-bottom: 10px;">Heading 1 - 32px Bold</h1>
|
||||
<h2 style="font-size: 24px; margin-bottom: 10px;">Heading 2 - 24px SemiBold</h2>
|
||||
<h3 style="font-size: 20px; margin-bottom: 10px;">Heading 3 - 20px SemiBold</h3>
|
||||
<p style="font-size: 16px; margin-bottom: 10px; color: var(--text-on-bg);">Body Large - 16px Regular - Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
|
||||
<p style="font-size: 14px; margin-bottom: 10px; color: var(--text-on-surface);">Body - 14px Regular - Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
|
||||
<p style="font-size: 12px; color: var(--text-muted);">Caption - 12px Regular - Texte secondaire ou métadonnées.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Interactive demo - button clicks
|
||||
document.querySelectorAll('.control-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
this.style.transform = 'scale(0.95)';
|
||||
setTimeout(() => {
|
||||
this.style.transform = '';
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
// Tags toggle
|
||||
document.querySelectorAll('.tag').forEach(tag => {
|
||||
tag.addEventListener('click', function() {
|
||||
this.classList.toggle('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Cards hover effect enhancement
|
||||
document.querySelectorAll('.card').forEach(card => {
|
||||
card.addEventListener('mouseenter', function() {
|
||||
this.style.borderColor = 'var(--color-primary)';
|
||||
});
|
||||
card.addEventListener('mouseleave', function() {
|
||||
this.style.borderColor = 'rgba(0, 240, 255, 0.15)';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,273 @@
|
||||
# Spotify Le 2 - UI/UX Design Document
|
||||
|
||||
**Date:** 2025-01-18
|
||||
**Thème:** Néon Cyberpunk
|
||||
**Priorité:** Interface fluide (navigation instantanée, contrôles réactifs, chargement progressif)
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture Globale
|
||||
|
||||
### Stack Technique
|
||||
- **Frontend:** Flutter (Dart 3.2+) avec Riverpod pour state management
|
||||
- **Backend:** Python + FastAPI
|
||||
- **Base de données:** PostgreSQL 15+
|
||||
- **Cache:** Redis 7+
|
||||
|
||||
### Layout Adaptatif
|
||||
|
||||
**Desktop (Windows/Linux):**
|
||||
- Sidebar navigation à gauche (240px fixe)
|
||||
- Zone de contenu principale avec AdaptiveScaffold
|
||||
- Player persistant en bas de l'écran (mini player)
|
||||
- Modal fullscreen pour player complet
|
||||
|
||||
**Mobile (Android):**
|
||||
- Bottom navigation bar (4 onglets)
|
||||
- Content zone avec AppBars dynamiques
|
||||
- Mini player en bas (sticky)
|
||||
- Fullscreen player avec swipe-up
|
||||
|
||||
### State Management (Riverpod)
|
||||
|
||||
```dart
|
||||
- StateProvider → UI simple (current page, theme)
|
||||
- StateNotifierProvider → Player state (contrôles réactifs)
|
||||
- AsyncNotifierProvider → API data avec cache automatique
|
||||
- StreamProvider → Player progress (60fps)
|
||||
```
|
||||
|
||||
### Optimisations Fluidité
|
||||
|
||||
1. **Preloading** - Données page suivante en arrière-plan
|
||||
2. **Image caching** - cached_network_image avec cache disque infini
|
||||
3. **ListView builder** - Rendu progressif longues listes
|
||||
4. **Isolates** - Traitement lourd dans isolates séparés
|
||||
|
||||
---
|
||||
|
||||
## 2. Thème Néon Cyberpunk
|
||||
|
||||
### Palette de Couleurs
|
||||
|
||||
```dart
|
||||
class AppColors {
|
||||
// Backgrounds
|
||||
static const primary = Color(0xFF0A0E27); // Bleu nuit très foncé
|
||||
static const surface = Color(0xFF1A1F3A); // Bleu nuit
|
||||
static const surfaceVariant = Color(0xFF252B4A);
|
||||
|
||||
// Accent colors
|
||||
static const cyan = Color(0xFF00F0FF); // Cyan électrique néon
|
||||
static const violet = Color(0xFFBF00FF); // Violet/magenta néon
|
||||
static const rose = Color(0xFFFF006E); // Rose néon vif
|
||||
static const vert = Color(0xFF39FF14); // Vert néon matrix
|
||||
static const jaune = Color(0xFFFFD600); // Jaune néon
|
||||
static const rouge = Color(0xFFFF2A6D); // Rouge néon
|
||||
|
||||
// Text
|
||||
static const onBg = Color(0xFFE0E6FF); // Blanc bleuté
|
||||
static const onSurface = Color(0xFFB0B8D4); // Bleu gris clair
|
||||
static const muted = Color(0xFF6A7294); // Bleu gris désaturé
|
||||
}
|
||||
```
|
||||
|
||||
### Typographie
|
||||
|
||||
```dart
|
||||
Font family: 'Outfit' (Google Fonts)
|
||||
|
||||
Sizes:
|
||||
- H1: 32px/700 (titres pages)
|
||||
- H2: 24px/600 (sections)
|
||||
- H3: 20px/600 (cards headers)
|
||||
- Body large: 16px/400
|
||||
- Body: 14px/400
|
||||
- Caption: 12px/400
|
||||
```
|
||||
|
||||
### Effets Visuels
|
||||
|
||||
1. **Glow effects**
|
||||
```dart
|
||||
BoxShadow(
|
||||
color: AppColors.cyan.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 2,
|
||||
)
|
||||
```
|
||||
|
||||
2. **Gradient borders** - Dégradés cyan→violet sur cards importantes
|
||||
|
||||
3. **Glassmorphism** - Surfaces semi-transparentes avec blur
|
||||
|
||||
4. **Scanlines** - Overlay subtil sur backgrounds (style retro)
|
||||
|
||||
### Animation Standards
|
||||
|
||||
- Durée: 200ms (rapide pour réactivité)
|
||||
- Courbe: curves.easeOutCubic
|
||||
- Hover: scale(1.02) + glow intensifié
|
||||
- Press: scale(0.98) immédiat
|
||||
|
||||
---
|
||||
|
||||
## 3. Composants UI Critiques
|
||||
|
||||
### Mini Player
|
||||
|
||||
**Priorité:** Contrôles réactifs (réponse < 50ms)
|
||||
|
||||
```dart
|
||||
- Stream 60fps pour progress bar
|
||||
- setState synchrone pour mises à jour
|
||||
- Slider sans animation de transition
|
||||
- Contrôles avec feedback immédiat
|
||||
```
|
||||
|
||||
### Infinite Scroll
|
||||
|
||||
```dart
|
||||
- ListView.builder pour rendu progressif
|
||||
- Préchargement 5 items avant fin
|
||||
- PaginatedAsyncNotifier
|
||||
- Shimmer placeholder pendant chargement
|
||||
```
|
||||
|
||||
### Image Caching
|
||||
|
||||
```dart
|
||||
- CachedNetworkImage avec cache infini
|
||||
- Placeholder gradient animé
|
||||
- Fallback gradient on error
|
||||
- FadeIn 200ms
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Navigation
|
||||
|
||||
### Desktop Layout
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Sidebar │ Main Content │ Right Panel │
|
||||
│ (240px) │ │ (320px) │
|
||||
│ │ │ │
|
||||
│ - Home │ Top Bar │ Queue / Now Playing │
|
||||
│ - Search│──────────────│ │
|
||||
│ - Lib │ Page Content │ │
|
||||
│ - Play │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
├─────────┴───────────────┴───────────────────────────┤
|
||||
│ Mini Player (persistent) │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Mobile Layout
|
||||
|
||||
```
|
||||
┌────────────────────┐
|
||||
│ Top Bar │
|
||||
├────────────────────┤
|
||||
│ │
|
||||
│ Page Content │
|
||||
│ │
|
||||
│ │
|
||||
├────────────────────┤
|
||||
│ Mini Player │
|
||||
├────────────────────┤
|
||||
│ Bottom Nav (56px) │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
### Pages Principales
|
||||
|
||||
- Home (Sections: Recommanded, Recently Played, New Releases)
|
||||
- Search (Barre recherche + Résultats)
|
||||
- Library (Playlists, Albums, Artists)
|
||||
- Settings
|
||||
|
||||
---
|
||||
|
||||
## 5. Structure du Projet Flutter
|
||||
|
||||
```
|
||||
lib/
|
||||
├── main.dart
|
||||
├── core/
|
||||
│ ├── constants/
|
||||
│ │ ├── app_constants.dart
|
||||
│ │ └── api_constants.dart
|
||||
│ ├── theme/
|
||||
│ │ ├── app_theme.dart
|
||||
│ │ ├── colors.dart
|
||||
│ │ └── text_styles.dart
|
||||
│ └── utils/
|
||||
│ ├── validators.dart
|
||||
│ └── formatters.dart
|
||||
├── domain/
|
||||
│ ├── entities/
|
||||
│ │ ├── user.dart
|
||||
│ │ ├── track.dart
|
||||
│ │ ├── playlist.dart
|
||||
│ │ └── artist.dart
|
||||
│ └── repositories/
|
||||
│ └── (repository interfaces)
|
||||
├── infrastructure/
|
||||
│ ├── datasources/
|
||||
│ │ ├── local/
|
||||
│ │ └── remote/
|
||||
│ └── repositories/
|
||||
│ └── (repository implementations)
|
||||
└── presentation/
|
||||
├── providers/
|
||||
│ ├── auth_provider.dart
|
||||
│ ├── music_provider.dart
|
||||
│ └── navigation_provider.dart
|
||||
├── pages/
|
||||
│ ├── desktop/
|
||||
│ ├── mobile/
|
||||
│ ├── home/
|
||||
│ ├── search/
|
||||
│ └── library/
|
||||
└── widgets/
|
||||
├── common/
|
||||
├── player/
|
||||
└── adaptive/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Critères de Succès
|
||||
|
||||
### Performance
|
||||
- Navigation < 100ms entre pages
|
||||
- Contrôles player < 50ms de latence
|
||||
- Scroll 60fps constant
|
||||
- Images chargées progressivement sans blocking
|
||||
|
||||
### UX
|
||||
- Interface adaptative seamless
|
||||
- Feedback visuel immédiat sur toutes interactions
|
||||
- États de chargement clairs
|
||||
- Gestion d'erreur gracieuse
|
||||
|
||||
### Design
|
||||
- Cohérence visuelle néon cyberpunk
|
||||
- Hiérarchie visuelle claire
|
||||
- Accessibilité (contrast ratios)
|
||||
- Animations fluides (200ms)
|
||||
|
||||
---
|
||||
|
||||
## Prototype
|
||||
|
||||
Un preview HTML du design est disponible dans: `docs/design-preview.html`
|
||||
|
||||
Ouvrir dans un navigateur pour voir:
|
||||
- Palette de couleurs interactive
|
||||
- Composants UI stylisés
|
||||
- Effets néon cyberpunk
|
||||
- Animations et interactions
|
||||
@@ -0,0 +1,27 @@
|
||||
# Search Feature
|
||||
|
||||
## Overview
|
||||
Real-time search with debouncing across tracks, artists, and albums.
|
||||
|
||||
## Usage
|
||||
- Access from sidebar (desktop) or bottom nav (mobile)
|
||||
- Type to search with 500ms debounce
|
||||
- Results grouped by type
|
||||
- Tap track to play immediately
|
||||
- Tap artist/album for details (TODO)
|
||||
|
||||
## Components
|
||||
- `SearchProvider` - State management with debouncing
|
||||
- `SearchPage` - Adaptive UI (desktop/mobile)
|
||||
- `SearchTrackCard`, `SearchArtistCard`, `SearchAlbumCard` - Result cards
|
||||
- `CachedNetworkImageWithFallback` - Reusable image widget
|
||||
|
||||
## Architecture
|
||||
- Uses typed entities (Track, Artist, Album) from domain layer
|
||||
- Integrates with MusicApiService for backend calls
|
||||
- Connects to PlayerNotifier for playback
|
||||
|
||||
## Testing
|
||||
Test stubs created at:
|
||||
- `test/presentation/providers/search_provider_test.dart`
|
||||
- `test/presentation/pages/search/search_page_test.dart`
|
||||
@@ -0,0 +1,55 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# VSCode related
|
||||
.vscode/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
|
||||
# Generated files
|
||||
*.freezed.dart
|
||||
*.g.dart
|
||||
*.mocks.dart
|
||||
@@ -0,0 +1,211 @@
|
||||
# Artist Details Page - Implementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Complete Artist Details Page for Spotify Le 2 with neon cyberpunk theme, featuring adaptive layouts for mobile and desktop.
|
||||
|
||||
## Files Created
|
||||
|
||||
### 1. Provider
|
||||
- **`frontend/lib/presentation/providers/artist_provider.dart`**
|
||||
- `ArtistState` - Contains artist data, tracks, albums, and loading states
|
||||
- `ArtistNotifier` - Manages artist data fetching and state
|
||||
- `artistProvider` - Riverpod StateNotifierProvider
|
||||
- `artistDataProvider` - Family provider for specific artist IDs
|
||||
|
||||
### 2. API Endpoints Added
|
||||
- **`frontend/lib/core/constants/api_constants.dart`**
|
||||
- Added `/music/artists` and `/music/albums` endpoints
|
||||
|
||||
- **`frontend/lib/infrastructure/datasources/remote/music_api_service.dart`**
|
||||
- `getArtist(String artistId)` - Fetch artist details
|
||||
- `getArtistTopTracks(String artistId)` - Fetch artist's top tracks
|
||||
- `getArtistAlbums(String artistId)` - Fetch artist's albums
|
||||
- `getAlbum(String albumId)` - Fetch album details
|
||||
- `getAlbumTracks(String albumId)` - Fetch album tracks
|
||||
|
||||
### 3. Widgets
|
||||
- **`frontend/lib/presentation/widgets/artist/artist_track_tile.dart`**
|
||||
- Displays track with number, title, duration, and play count
|
||||
- Animated playing indicator for currently playing track
|
||||
- Add to queue button
|
||||
- Neon glow effect for active track
|
||||
|
||||
- **`frontend/lib/presentation/widgets/artist/artist_album_card.dart`**
|
||||
- Displays album art, title, release year, and track count
|
||||
- Gradient border with violet accent
|
||||
- Horizontal scrollable layout
|
||||
|
||||
### 4. Pages
|
||||
- **`frontend/lib/presentation/pages/artist/artist_details_page.dart`**
|
||||
- Adaptive entry point (mobile/desktop)
|
||||
|
||||
- **`frontend/lib/presentation/pages/artist/artist_mobile_page.dart`**
|
||||
- Vertical scrolling layout
|
||||
- Hero image with gradient overlay
|
||||
- Play All button
|
||||
- Popular tracks section
|
||||
- Horizontal scrolling albums
|
||||
- Related tracks section
|
||||
|
||||
- **`frontend/lib/presentation/pages/artist/artist_desktop_page.dart`**
|
||||
- Two-column layout
|
||||
- Larger hero image (220x220)
|
||||
- Play All button
|
||||
- Popular tracks in left column
|
||||
- Albums and related tracks in right column
|
||||
|
||||
## Features
|
||||
|
||||
### Visual Design
|
||||
- **Neon Cyberpunk Theme**: Cyan, violet, and rose accent colors
|
||||
- **Hero Header**: Large artist image with gradient overlay
|
||||
- **Glow Effects**: Neon glow on active/hovered elements
|
||||
- **Smooth Animations**: Playing indicator, hover states
|
||||
- **Genre Tags**: Styled badges for artist genres
|
||||
|
||||
### Functionality
|
||||
- **Load All Data**: Fetches artist, tracks, albums, and recommendations in parallel
|
||||
- **Play All**: Plays all tracks from the artist
|
||||
- **Play Track**: Plays specific track and sets queue
|
||||
- **Add to Queue**: Add individual tracks to queue
|
||||
- **Error Handling**: Loading states, error messages, retry functionality
|
||||
- **Responsive Layout**: Adapts between mobile (<800px) and desktop (>=800px)
|
||||
|
||||
### Performance
|
||||
- **Parallel Loading**: All artist data fetched simultaneously
|
||||
- **State Management**: Efficient Riverpod state handling
|
||||
- **Lazy Loading**: Related tracks loaded on demand
|
||||
- **Cached Images**: Uses cached_network_image for album art
|
||||
|
||||
## Usage Example
|
||||
|
||||
### Navigation
|
||||
|
||||
```dart
|
||||
// Navigate to artist details
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ArtistDetailsPage(
|
||||
artistId: 'artist-id-here',
|
||||
),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
### Provider Access
|
||||
|
||||
```dart
|
||||
// Watch artist state
|
||||
final artistState = ref.watch(artistProvider);
|
||||
|
||||
// Access artist data
|
||||
final artist = artistState.artist;
|
||||
final topTracks = artistState.topTracks;
|
||||
final albums = artistState.albums;
|
||||
|
||||
// Load artist data
|
||||
ref.read(artistProvider.notifier).loadAllArtistData('artist-id');
|
||||
|
||||
// Load specific data
|
||||
ref.read(artistProvider.notifier).loadArtist('artist-id');
|
||||
ref.read(artistProvider.notifier).loadTopTracks('artist-id');
|
||||
ref.read(artistProvider.notifier).loadAlbums('artist-id');
|
||||
```
|
||||
|
||||
### Integration with Search
|
||||
|
||||
Update `search_desktop_page.dart` or `search_mobile_page.dart`:
|
||||
|
||||
```dart
|
||||
void _showArtistDetails(Artist artist) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ArtistDetailsPage(
|
||||
artistId: artist.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Requirements
|
||||
|
||||
The backend API should support these endpoints:
|
||||
|
||||
```
|
||||
GET /api/v1/music/artists/{id}
|
||||
Response: {
|
||||
"id": "string",
|
||||
"name": "string",
|
||||
"image_url": "string?",
|
||||
"bio": "string?",
|
||||
"genres": ["string"],
|
||||
"popularity": int,
|
||||
...
|
||||
}
|
||||
|
||||
GET /api/v1/music/artists/{id}/tracks?limit=10
|
||||
Response: [
|
||||
{
|
||||
"id": "string",
|
||||
"title": "string",
|
||||
"duration": int?,
|
||||
"image_url": "string?",
|
||||
"play_count": int?,
|
||||
...
|
||||
}
|
||||
]
|
||||
|
||||
GET /api/v1/music/artists/{id}/albums?limit=20
|
||||
Response: [
|
||||
{
|
||||
"id": "string",
|
||||
"title": "string",
|
||||
"release_date": "string?",
|
||||
"image_url": "string?",
|
||||
"total_tracks": int,
|
||||
...
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Theme Integration
|
||||
|
||||
The page uses these theme colors from `AppColors`:
|
||||
|
||||
- `AppColors.primary` - Main background (#0A0E27)
|
||||
- `AppColors.surface` - Card background (#1A1F3A)
|
||||
- `AppColors.cyan` - Primary accent (#00F0FF)
|
||||
- `AppColors.violet` - Secondary accent (#BF00FF)
|
||||
- `AppColors.rose` - Tertiary accent (#FF006E)
|
||||
- `AppColors.onBackground` - Primary text (#E0E6FF)
|
||||
- `AppColors.onSurface` - Secondary text (#B0B8D4)
|
||||
- `AppColors.muted` - Muted text (#6A7294)
|
||||
|
||||
## Dependencies
|
||||
|
||||
Ensure these packages are in `pubspec.yaml`:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_riverpod: ^2.3.6
|
||||
cached_network_image: ^3.2.3
|
||||
equatable: ^2.0.5
|
||||
dio: ^5.3.2
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Biography section with expandable text
|
||||
- [ ] Artist followers count
|
||||
- [ ] Similar artists section
|
||||
- [ ] Concert/tour dates
|
||||
- [ ] Social media links
|
||||
- [ ] Playlists featuring this artist
|
||||
- [ ] Share functionality
|
||||
- [ ] Follow/unfollow artist
|
||||
- [ ] Track duration sorting
|
||||
- [ ] Album filtering by type (single, EP, album)
|
||||
@@ -0,0 +1,298 @@
|
||||
# Settings Page Integration Checklist
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### 1. Install Dependencies
|
||||
```bash
|
||||
cd frontend
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
Required dependencies (already added to pubspec.yaml):
|
||||
- ✅ `package_info_plus: ^5.0.1`
|
||||
- ✅ `image_picker: ^1.0.7`
|
||||
- ✅ `shared_preferences: ^2.2.2` (already present)
|
||||
- ✅ `path_provider: ^2.1.2` (already present)
|
||||
|
||||
### 2. Platform Configuration
|
||||
|
||||
#### Android (android/app/src/main/AndroidManifest.xml)
|
||||
Add these permissions inside `<manifest>` tag:
|
||||
|
||||
```xml
|
||||
<!-- For image picker -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" android:minSdkVersion="33"/>
|
||||
|
||||
<!-- For cache management -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
```
|
||||
|
||||
#### iOS (ios/Runner/Info.plist)
|
||||
Add these keys:
|
||||
|
||||
```xml
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>We need access to your photo library to let you select a profile picture.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>We need access to save photos to your library.</string>
|
||||
```
|
||||
|
||||
### 3. Import the Settings Page
|
||||
|
||||
```dart
|
||||
import 'package:spotify_le_2/presentation/pages/settings/settings_page.dart';
|
||||
```
|
||||
|
||||
### 4. Add Navigation Route
|
||||
|
||||
#### Option A: Direct Navigation
|
||||
```dart
|
||||
// In your home page, profile button, etc.
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SettingsPage(),
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Option B: Go Router
|
||||
```dart
|
||||
// In router configuration
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
builder: (context, state) => const SettingsPage(),
|
||||
),
|
||||
```
|
||||
|
||||
#### Option C: Bottom Navigation
|
||||
```dart
|
||||
NavigationBar(
|
||||
destinations: const [
|
||||
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
|
||||
NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
|
||||
NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'),
|
||||
],
|
||||
onDestinationSelected: (index) {
|
||||
if (index == 2) { // Settings tab
|
||||
// Navigate to settings or show as current page
|
||||
}
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### 5. Provider Setup (Already Done)
|
||||
|
||||
The `settingsProvider` is already set up in:
|
||||
`frontend/lib/presentation/providers/settings_provider.dart`
|
||||
|
||||
It automatically:
|
||||
- Initializes SharedPreferences
|
||||
- Loads settings on app start
|
||||
- Watches AuthApiService for user data
|
||||
- Persists all settings changes
|
||||
|
||||
### 6. Test the Integration
|
||||
|
||||
#### Manual Testing Checklist
|
||||
|
||||
**Profile Section:**
|
||||
- [ ] Avatar displays correctly (default or from URL)
|
||||
- [ ] Display name and email show
|
||||
- [ ] Premium badge appears for premium users
|
||||
- [ ] Edit Profile button opens dialog
|
||||
- [ ] Display name can be edited
|
||||
- [ ] Image picker opens (requires device/emulator)
|
||||
|
||||
**Audio Quality:**
|
||||
- [ ] All four quality options display
|
||||
- [ ] Selection works correctly
|
||||
- [ ] Premium lock shows for non-premium users
|
||||
- [ ] Settings persist after app restart
|
||||
|
||||
**Playback Settings:**
|
||||
- [ ] Crossfade toggle works
|
||||
- [ ] Duration slider appears when enabled
|
||||
- [ ] Gapless playback toggle works
|
||||
- [ ] Normalize volume toggle works
|
||||
- [ ] All settings persist
|
||||
|
||||
**Downloads:**
|
||||
- [ ] Mobile data toggle works
|
||||
- [ ] Explicit content toggle works
|
||||
- [ ] Settings persist after restart
|
||||
|
||||
**Cache Management:**
|
||||
- [ ] Cache size displays (may show "0 MB" initially)
|
||||
- [ ] Clear cache button works
|
||||
- [ ] Confirmation dialog appears
|
||||
- [ ] Success snackbar shows
|
||||
- [ ] Cache size updates after clearing
|
||||
|
||||
**About Section:**
|
||||
- [ ] App version displays correctly
|
||||
- [ ] Licenses page opens
|
||||
- [ ] License information loads
|
||||
|
||||
**Logout:**
|
||||
- [ ] Logout button works
|
||||
- [ ] Confirmation dialog appears
|
||||
- [ ] User is logged out
|
||||
- [ ] Redirected to login page
|
||||
|
||||
**Error Handling:**
|
||||
- [ ] Network errors display
|
||||
- [ ] Error messages are clear
|
||||
- [ ] Error dismiss button works
|
||||
- [ ] Retry possible
|
||||
|
||||
### 7. Optional Enhancements
|
||||
|
||||
#### Add Settings Icon to App Bar
|
||||
```dart
|
||||
AppBar(
|
||||
title: Text('Home'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.settings),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const SettingsPage()),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
#### Add Settings to Drawer Menu
|
||||
```dart
|
||||
Drawer(
|
||||
child: ListView(
|
||||
children: [
|
||||
DrawerHeader(...),
|
||||
ListTile(
|
||||
leading: Icon(Icons.settings),
|
||||
title: Text('Settings'),
|
||||
onTap: () {
|
||||
Navigator.pop(context); // Close drawer
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const SettingsPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
#### Add to User Profile Menu
|
||||
```dart
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
if (value == 'settings') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const SettingsPage()),
|
||||
);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(value: 'settings', child: Text('Settings')),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
### 8. Verification Commands
|
||||
|
||||
```bash
|
||||
# Check if all files exist
|
||||
ls -la frontend/lib/presentation/providers/settings_provider.dart
|
||||
ls -la frontend/lib/presentation/pages/settings/settings_page.dart
|
||||
ls -la frontend/lib/presentation/widgets/settings/*.dart
|
||||
|
||||
# Verify dependencies
|
||||
flutter pub deps | grep -E "(package_info_plus|image_picker)"
|
||||
|
||||
# Run the app
|
||||
flutter run
|
||||
|
||||
# Build for testing
|
||||
flutter build apk --debug
|
||||
flutter build ios --debug
|
||||
```
|
||||
|
||||
### 9. Common Issues & Solutions
|
||||
|
||||
**Issue: Image picker doesn't open**
|
||||
- Solution: Add permissions to AndroidManifest.xml and Info.plist
|
||||
- Real device required (doesn't work on some emulators)
|
||||
|
||||
**Issue: Cache size shows "Unknown"**
|
||||
- Solution: Normal on first launch or if no cache exists
|
||||
- Cache will accumulate as app is used
|
||||
|
||||
**Issue: Settings don't persist**
|
||||
- Solution: Ensure SharedPreferences is initialized
|
||||
- Check for storage permissions on older Android versions
|
||||
|
||||
**Issue: Premium features not unlocking**
|
||||
- Solution: Ensure backend correctly sets `is_premium` flag
|
||||
- Check user data is loaded from API
|
||||
|
||||
**Issue: Avatar upload doesn't work**
|
||||
- Solution: Server-side upload endpoint required
|
||||
- Current implementation only selects local image
|
||||
- Implement multipart/form-data upload on backend
|
||||
|
||||
### 10. Next Steps
|
||||
|
||||
1. **Backend**: Implement avatar upload endpoint
|
||||
2. **Testing**: Test on real devices (iOS and Android)
|
||||
3. **Polish**: Add loading skeletons during initial load
|
||||
4. **Analytics**: Track settings changes
|
||||
5. **A/B Testing**: Test default settings values
|
||||
6. **Documentation**: Add user-facing help text
|
||||
7. **Localization**: Add translations for all text
|
||||
|
||||
## Support Files Created
|
||||
|
||||
- ✅ `SETTINGS_PAGE_README.md` - Complete implementation guide
|
||||
- ✅ `SETTINGS_PREVIEW.md` - Visual design documentation
|
||||
- ✅ `settings_page_example.dart` - Integration examples
|
||||
- ✅ `INTEGRATION_CHECKLIST.md` - This file
|
||||
|
||||
## Quick Reference
|
||||
|
||||
**Files Created:**
|
||||
- `frontend/lib/presentation/providers/settings_provider.dart`
|
||||
- `frontend/lib/presentation/pages/settings/settings_page.dart`
|
||||
- `frontend/lib/presentation/widgets/settings/settings_tile.dart`
|
||||
- `frontend/lib/presentation/widgets/settings/profile_section.dart`
|
||||
- `frontend/lib/presentation/widgets/settings/audio_quality_selector.dart`
|
||||
- `frontend/lib/presentation/widgets/settings/cache_management_tile.dart`
|
||||
- `frontend/lib/presentation/widgets/settings/edit_profile_dialog.dart`
|
||||
|
||||
**Import Path:**
|
||||
```dart
|
||||
import 'package:spotify_le_2/presentation/pages/settings/settings_page.dart';
|
||||
```
|
||||
|
||||
**Provider Access:**
|
||||
```dart
|
||||
final settingsState = ref.watch(settingsProvider);
|
||||
final settingsNotifier = ref.read(settingsProvider.notifier);
|
||||
```
|
||||
|
||||
**Navigation:**
|
||||
```dart
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsPage()));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**All files created and ready for integration! 🚀**
|
||||
@@ -0,0 +1,247 @@
|
||||
# Spotify Le 2 - Frontend Flutter
|
||||
|
||||
Application Flutter cross-platform (Desktop + Android) avec thème néon cyberpunk.
|
||||
|
||||
## Stack Technique
|
||||
|
||||
- **Flutter** 3.2+ (Dart 3.2+)
|
||||
- **Riverpod** 2.4+ - State management
|
||||
- **Dio** 5.4+ - HTTP client
|
||||
- **just_audio** 0.9+ - Audio playback
|
||||
- **drift** 2.14+ - Local database
|
||||
- **cached_network_image** 3.3+ - Image caching
|
||||
|
||||
## Structure du Projet
|
||||
|
||||
```
|
||||
lib/
|
||||
├── main.dart # Entry point
|
||||
├── core/ # Configuration partagée
|
||||
│ ├── constants/
|
||||
│ │ └── api_constants.dart
|
||||
│ └── theme/
|
||||
│ ├── colors.dart # Palette néon cyberpunk
|
||||
│ ├── text_styles.dart # Typographie
|
||||
│ └── app_theme.dart # Thème Material
|
||||
├── domain/ # Business logic
|
||||
│ └── entities/
|
||||
│ ├── user.dart
|
||||
│ ├── track.dart
|
||||
│ └── playlist.dart
|
||||
├── infrastructure/ # External dependencies
|
||||
│ └── datasources/
|
||||
│ ├── local/ # Database locale
|
||||
│ └── remote/ # API client
|
||||
├── presentation/ # UI layer
|
||||
│ ├── providers/
|
||||
│ │ └── navigation_provider.dart
|
||||
│ ├── adaptive/
|
||||
│ │ └── adaptive_layout.dart # Desktop vs Mobile
|
||||
│ ├── pages/
|
||||
│ │ ├── desktop/
|
||||
│ │ │ └── home_page.dart
|
||||
│ │ └── mobile/
|
||||
│ │ └── mobile_home_page.dart
|
||||
│ └── widgets/
|
||||
│ ├── common/
|
||||
│ │ └── mini_player.dart
|
||||
│ └── desktop/
|
||||
│ ├── desktop_sidebar.dart
|
||||
│ └── desktop_top_bar.dart
|
||||
└── l10n/ # Internationalization
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### Prérequis
|
||||
|
||||
- Flutter 3.2+
|
||||
- Dart 3.2+
|
||||
- Android Studio / VS Code
|
||||
- Android SDK (pour Android)
|
||||
|
||||
### 1. Cloner le projet
|
||||
|
||||
```bash
|
||||
cd Spotify_le_2/frontend
|
||||
```
|
||||
|
||||
### 2. Installer les dépendances
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
### 3. Générer le code (Riverpod generators)
|
||||
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
### 4. Lancer l'app
|
||||
|
||||
```bash
|
||||
# Desktop
|
||||
flutter run -d windows
|
||||
|
||||
# Android
|
||||
flutter run -d android
|
||||
|
||||
# Linux
|
||||
flutter run -d linux
|
||||
```
|
||||
|
||||
## Thème Néon Cyberpunk
|
||||
|
||||
### Palette de Couleurs
|
||||
|
||||
```dart
|
||||
// Backgrounds
|
||||
primary: #0A0E27 // Bleu nuit très foncé
|
||||
surface: #1A1F3A // Bleu nuit
|
||||
surfaceVariant: #252B4A
|
||||
|
||||
// Accent néon
|
||||
cyan: #00F0FF // Cyan électrique
|
||||
violet: #BF00FF // Violet néon
|
||||
rose: #FF006E // Rose néon
|
||||
vert: #39FF14 // Vert néon
|
||||
```
|
||||
|
||||
### Utilisation du Thème
|
||||
|
||||
```dart
|
||||
// Import
|
||||
import 'package:spotify_le_2/core/theme/colors.dart';
|
||||
|
||||
// Utiliser les couleurs
|
||||
Container(
|
||||
color: AppColors.surface,
|
||||
child: Text(
|
||||
'Hello',
|
||||
style: TextStyle(color: AppColors.cyan),
|
||||
),
|
||||
)
|
||||
|
||||
// Gradients
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
),
|
||||
)
|
||||
|
||||
// Effets glow
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: AppColors.cyanGlow,
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
## Layout Adaptatif
|
||||
|
||||
L'application utilise deux layouts distincts selon la largeur de l'écran :
|
||||
|
||||
**Desktop (≥ 800px) :**
|
||||
- Sidebar navigation à gauche (240px)
|
||||
- Top bar avec recherche
|
||||
- Contenu principal scrollable
|
||||
- Mini player persistant en bas
|
||||
|
||||
**Mobile (< 800px) :**
|
||||
- Top bar
|
||||
- Contenu principal
|
||||
- Mini player sticky
|
||||
- Bottom navigation bar (4 onglets)
|
||||
|
||||
## Navigation
|
||||
|
||||
```dart
|
||||
// Naviguer vers une page
|
||||
ref.read(navigationProvider.notifier).navigateTo('search');
|
||||
|
||||
// Watcher la page courante
|
||||
final currentPage = ref.watch(currentPageProvider);
|
||||
```
|
||||
|
||||
## Développement
|
||||
|
||||
### Formatter
|
||||
|
||||
```bash
|
||||
flutter format .
|
||||
```
|
||||
|
||||
### Linter
|
||||
|
||||
```bash
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
# Tous les tests
|
||||
flutter test
|
||||
|
||||
# Tests avec coverage
|
||||
flutter test --coverage
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
flutter build windows
|
||||
|
||||
# Android APK
|
||||
flutter build apk
|
||||
|
||||
# Android App Bundle
|
||||
flutter build appbundle
|
||||
|
||||
# Linux
|
||||
flutter build linux
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
### Optimisations implémentées
|
||||
|
||||
1. **Streaming 60fps** - StreamSubscription pour progress bar
|
||||
2. **Infinite scroll** - ListView.builder avec préchargement
|
||||
3. **Image caching** - cached_network_image avec cache infini
|
||||
4. **Animations optimisées** - 200ms avec easeOutCubic
|
||||
|
||||
### Profiling
|
||||
|
||||
```bash
|
||||
# DevTools
|
||||
flutter pub global activate devtools
|
||||
flutter pub global run devtools
|
||||
|
||||
# Puis lancer l'app avec profiling
|
||||
flutter run --profile
|
||||
```
|
||||
|
||||
## Configuration API
|
||||
|
||||
Modifier `lib/core/constants/api_constants.dart` :
|
||||
|
||||
```dart
|
||||
class ApiConstants {
|
||||
static const String baseUrl = 'http://localhost:8000/api/v1';
|
||||
static const String wsUrl = 'ws://localhost:8000';
|
||||
static const Duration connectionTimeout = Duration(seconds: 30);
|
||||
}
|
||||
```
|
||||
|
||||
## Ressources
|
||||
|
||||
- [Flutter Documentation](https://flutter.dev/docs)
|
||||
- [Riverpod Documentation](https://riverpod.dev)
|
||||
- [Design Preview](../docs/design-preview.html) - Aperçu HTML du thème
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,239 @@
|
||||
# Settings Page Implementation - Spotify Le 2
|
||||
|
||||
## Overview
|
||||
Complete Settings Page implementation with neon cyberpunk theme for user profile management, audio settings, and app preferences.
|
||||
|
||||
## Files Created
|
||||
|
||||
### 1. Provider Layer
|
||||
**File:** `frontend/lib/presentation/providers/settings_provider.dart`
|
||||
|
||||
Features:
|
||||
- **SettingsState**: Manages user data, audio quality, download preferences, and cache
|
||||
- **SettingsNotifier**: Handles all settings operations
|
||||
- Load user profile from API
|
||||
- Update profile (display name, avatar URL)
|
||||
- Audio quality management (Low/Medium/High/Lossless)
|
||||
- Cache calculation and clearing
|
||||
- Persistent settings with SharedPreferences
|
||||
- **AudioQuality Enum**: low, medium, high, lossless
|
||||
|
||||
### 2. Widgets Layer
|
||||
|
||||
#### `frontend/lib/presentation/widgets/settings/settings_tile.dart`
|
||||
Reusable components:
|
||||
- **SettingsTile**: Basic settings item with title, subtitle, icon, and trailing widget
|
||||
- **SettingsToggleTile**: Settings item with toggle switch
|
||||
- **SettingsSectionHeader**: Section title with uppercase styling
|
||||
- **SettingsCard**: Container with neon glow border
|
||||
|
||||
#### `frontend/lib/presentation/widgets/settings/profile_section.dart`
|
||||
Features:
|
||||
- User avatar with gradient glow
|
||||
- Display name, username, and email
|
||||
- Premium badge indicator
|
||||
- Edit Profile button
|
||||
- Gradient background with cyberpunk styling
|
||||
|
||||
#### `frontend/lib/presentation/widgets/settings/audio_quality_selector.dart`
|
||||
Features:
|
||||
- Four audio quality options (Low/Medium/High/Lossless)
|
||||
- Bitrate display (96/160/320 kbps / FLAC)
|
||||
- Premium lock for Lossless quality
|
||||
- Visual selection indicator
|
||||
- Description for each quality level
|
||||
|
||||
#### `frontend/lib/presentation/widgets/settings/cache_management_tile.dart`
|
||||
Features:
|
||||
- Cache size calculation and display
|
||||
- Format bytes (B/KB/MB/GB)
|
||||
- Clear cache button with confirmation dialog
|
||||
- Loading state during cache clearing
|
||||
- Success/error snackbar notifications
|
||||
|
||||
#### `frontend/lib/presentation/widgets/settings/edit_profile_dialog.dart`
|
||||
Features:
|
||||
- Edit display name
|
||||
- Avatar image picker
|
||||
- Image preview
|
||||
- Save/Cancel buttons
|
||||
- Validation and error handling
|
||||
- Note about server-side avatar upload
|
||||
|
||||
### 3. Page Layer
|
||||
|
||||
**File:** `frontend/lib/presentation/pages/settings/settings_page.dart`
|
||||
|
||||
Complete settings page with sections:
|
||||
- **Profile Section**: User info with avatar
|
||||
- **Audio Section**: Audio quality selector
|
||||
- **Playback Section**:
|
||||
- Crossfade toggle with duration slider
|
||||
- Gapless playback toggle
|
||||
- Normalize volume toggle
|
||||
- **Downloads Section**:
|
||||
- Download on mobile data toggle
|
||||
- Show explicit content toggle
|
||||
- **Storage Section**: Cache management
|
||||
- **About Section**:
|
||||
- App version (with package_info_plus)
|
||||
- Licenses page
|
||||
- **Logout**: Confirmation dialog
|
||||
|
||||
## Dependencies Added
|
||||
|
||||
```yaml
|
||||
package_info_plus: ^5.0.1 # For app version info
|
||||
image_picker: ^1.0.7 # For avatar image selection
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Persistent Storage
|
||||
All settings are persisted using SharedPreferences:
|
||||
- Audio quality
|
||||
- Download on mobile data
|
||||
- Show explicit content
|
||||
- Crossfade settings
|
||||
- Gapless playback
|
||||
- Normalize volume
|
||||
|
||||
### 2. Cache Management
|
||||
- Automatic cache size calculation
|
||||
- Temporary and documents directory scanning
|
||||
- Human-readable size formatting
|
||||
- Clear cache functionality
|
||||
|
||||
### 3. Profile Management
|
||||
- Integration with AuthApiService
|
||||
- Load profile from `/api/v1/auth/me`
|
||||
- Update profile with PUT request
|
||||
- Display name and avatar URL updates
|
||||
- Error handling with snackbar notifications
|
||||
|
||||
### 4. Neon Cyberpunk Theme
|
||||
- Cyan glow effects
|
||||
- Gradient backgrounds
|
||||
- Border styling with transparency
|
||||
- Custom toggle switches
|
||||
- Elevated and outlined button styles
|
||||
- Consistent with existing app theme
|
||||
|
||||
### 5. Responsive Design
|
||||
- CustomScrollView with SliverAppBar
|
||||
- Card-based layout
|
||||
- Proper spacing and padding
|
||||
- Responsive width constraints
|
||||
- Mobile-optimized
|
||||
|
||||
## API Integration
|
||||
|
||||
### GET /api/v1/auth/me
|
||||
```dart
|
||||
final user = await _authApiService.getCurrentUser();
|
||||
```
|
||||
|
||||
### PUT /api/v1/auth/me
|
||||
```dart
|
||||
final updatedUser = await _authApiService.updateProfile(
|
||||
displayName: displayName,
|
||||
avatarUrl: avatarUrl,
|
||||
);
|
||||
```
|
||||
|
||||
## Usage Example
|
||||
|
||||
### Navigate to Settings Page
|
||||
```dart
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SettingsPage(),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
### Using Settings Provider
|
||||
```dart
|
||||
final settingsState = ref.watch(settingsProvider);
|
||||
|
||||
// Update audio quality
|
||||
ref.read(settingsProvider.notifier).setAudioQuality(AudioQuality.high);
|
||||
|
||||
// Toggle setting
|
||||
ref.read(settingsProvider.notifier).toggleCrossfade(true);
|
||||
|
||||
// Update profile
|
||||
await ref.read(settingsProvider.notifier).updateProfile(
|
||||
displayName: 'New Name',
|
||||
);
|
||||
|
||||
// Clear cache
|
||||
await ref.read(settingsProvider.notifier).clearCache();
|
||||
```
|
||||
|
||||
## Theme Integration
|
||||
|
||||
All widgets follow the neon cyberpunk theme:
|
||||
- **Primary Colors**: Cyan (#00F0FF), Violet (#BF00FF), Rose (#FF006E)
|
||||
- **Backgrounds**: Dark blue tones (#0A0E27, #1A1F3A)
|
||||
- **Text**: OnBackground (#E0E6FF), OnSurface (#B0B8D4)
|
||||
- **Effects**: Glow shadows, gradients, borders
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Network errors caught and displayed
|
||||
- Snackbar notifications for user feedback
|
||||
- Loading states during async operations
|
||||
- Validation for profile updates
|
||||
- Graceful fallbacks for missing data
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Avatar Upload**: Implement server-side image upload
|
||||
2. **Equalizer**: Add customizable EQ presets
|
||||
3. **Language**: Add language selector
|
||||
4. **Theme**: Add light/dark theme toggle
|
||||
5. **Notifications**: Add notification settings
|
||||
6. **Privacy**: Add privacy settings page
|
||||
7. **Account**: Add account deletion
|
||||
8. **Social**: Add social links management
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
1. **Profile Updates**: Test display name changes
|
||||
2. **Audio Quality**: Test all quality levels
|
||||
3. **Cache**: Test cache clearing on different devices
|
||||
4. **Toggles**: Test all toggle switches persist
|
||||
5. **Logout**: Test logout flow
|
||||
6. **Network**: Test with poor network conditions
|
||||
7. **Validation**: Test empty display names
|
||||
8. **Image Picker**: Test on iOS and Android
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
frontend/lib/
|
||||
├── presentation/
|
||||
│ ├── providers/
|
||||
│ │ └── settings_provider.dart
|
||||
│ ├── pages/
|
||||
│ │ └── settings/
|
||||
│ │ └── settings_page.dart
|
||||
│ └── widgets/
|
||||
│ └── settings/
|
||||
│ ├── settings_tile.dart
|
||||
│ ├── profile_section.dart
|
||||
│ ├── audio_quality_selector.dart
|
||||
│ ├── cache_management_tile.dart
|
||||
│ ├── edit_profile_dialog.dart
|
||||
│ └── settings_widgets.dart
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Avatar upload requires server implementation
|
||||
- Image picker requires permissions in AndroidManifest.xml and Info.plist
|
||||
- Cache clearing may take time on large caches
|
||||
- Premium features should be validated on backend
|
||||
- Audio quality changes should take effect on next track load
|
||||
@@ -0,0 +1,48 @@
|
||||
/// API constants
|
||||
class ApiConstants {
|
||||
ApiConstants._();
|
||||
|
||||
// Base URLs
|
||||
static const String baseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'http://localhost:8000/api/v1',
|
||||
);
|
||||
|
||||
static const String wsUrl = String.fromEnvironment(
|
||||
'WS_BASE_URL',
|
||||
defaultValue: 'ws://localhost:8000',
|
||||
);
|
||||
|
||||
// Timeout durations
|
||||
static const int connectionTimeoutMs = 30000; // 30 seconds
|
||||
static const int receiveTimeoutMs = 30000;
|
||||
static const int sendTimeoutMs = 30000;
|
||||
|
||||
// API Endpoints
|
||||
static const String auth = '/auth';
|
||||
static const String music = '/music';
|
||||
static const String playlists = '/playlists';
|
||||
static const String library = '/library';
|
||||
static const String search = '/search';
|
||||
|
||||
// Auth endpoints
|
||||
static const String login = '/auth/login';
|
||||
static const String register = '/auth/register';
|
||||
static const String refresh = '/auth/refresh';
|
||||
static const String logout = '/auth/logout';
|
||||
static const String me = '/auth/me';
|
||||
|
||||
// Music endpoints
|
||||
static const String tracks = '/music/tracks';
|
||||
static const String artists = '/music/artists';
|
||||
static const String albums = '/music/albums';
|
||||
static const String searchMusic = '/music/search';
|
||||
static const String stream = '/stream';
|
||||
static const String recommendations = '/music/tracks';
|
||||
static const String trending = '/music/trending';
|
||||
|
||||
// Playlist endpoints
|
||||
static const String userPlaylists = '/playlists';
|
||||
static const String playlistTracks = '/tracks';
|
||||
static const String reorder = '/tracks/reorder';
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'colors.dart';
|
||||
import 'text_styles.dart';
|
||||
|
||||
/// App Theme - Neon Cyberpunk
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
// Light theme (not used, keeping for completeness)
|
||||
static ThemeData get lightTheme => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
colorScheme: _lightColorScheme,
|
||||
textTheme: _textTheme,
|
||||
fontFamily: AppTextStyles.fontFamily,
|
||||
);
|
||||
|
||||
// Dark theme (main theme)
|
||||
static ThemeData get darkTheme => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: _darkColorScheme,
|
||||
textTheme: _textTheme,
|
||||
fontFamily: AppTextStyles.fontFamily,
|
||||
scaffoldBackgroundColor: AppColors.primary,
|
||||
appBarTheme: _appBarTheme,
|
||||
cardTheme: _cardTheme,
|
||||
elevatedButtonTheme: _elevatedButtonTheme,
|
||||
textButtonTheme: _textButtonTheme,
|
||||
outlinedButtonTheme: _outlinedButtonTheme,
|
||||
inputDecorationTheme: _inputDecorationTheme,
|
||||
floatingActionButtonTheme: _floatingActionButtonTheme,
|
||||
bottomNavigationBarTheme: _bottomNavigationBarTheme,
|
||||
navigationBarTheme: _navigationBarTheme,
|
||||
sliderTheme: _sliderTheme,
|
||||
progressIndicatorTheme: _progressIndicatorTheme,
|
||||
);
|
||||
|
||||
// Color Schemes
|
||||
static const ColorScheme _lightColorScheme = ColorScheme.light(
|
||||
primary: AppColors.cyan,
|
||||
secondary: AppColors.violet,
|
||||
tertiary: AppColors.rose,
|
||||
surface: AppColors.surface,
|
||||
error: AppColors.error,
|
||||
onPrimary: AppColors.primary,
|
||||
onSecondary: AppColors.primary,
|
||||
onSurface: AppColors.onSurface,
|
||||
onError: Colors.white,
|
||||
);
|
||||
|
||||
static const ColorScheme _darkColorScheme = ColorScheme.dark(
|
||||
primary: AppColors.cyan,
|
||||
secondary: AppColors.violet,
|
||||
tertiary: AppColors.rose,
|
||||
surface: AppColors.surface,
|
||||
error: AppColors.error,
|
||||
onPrimary: AppColors.primary,
|
||||
onSecondary: AppColors.primary,
|
||||
onSurface: AppColors.onSurface,
|
||||
onError: Colors.white,
|
||||
);
|
||||
|
||||
// Text Theme
|
||||
static const TextTheme _textTheme = TextTheme(
|
||||
displayLarge: AppTextStyles.h1,
|
||||
displayMedium: AppTextStyles.h2,
|
||||
displaySmall: AppTextStyles.h3,
|
||||
bodyLarge: AppTextStyles.bodyLarge,
|
||||
bodyMedium: AppTextStyles.body,
|
||||
bodySmall: AppTextStyles.bodySmall,
|
||||
labelLarge: AppTextStyles.button,
|
||||
labelMedium: AppTextStyles.label,
|
||||
labelSmall: AppTextStyles.caption,
|
||||
);
|
||||
|
||||
// AppBar Theme
|
||||
static const AppBarTheme _appBarTheme = AppBarTheme(
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppColors.onBackground,
|
||||
titleTextStyle: AppTextStyles.h2,
|
||||
iconTheme: IconThemeData(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
);
|
||||
|
||||
// Card Theme
|
||||
static CardTheme _cardTheme = CardTheme(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.15),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
color: AppColors.surface,
|
||||
margin: const EdgeInsets.all(8),
|
||||
);
|
||||
|
||||
// Elevated Button Theme
|
||||
static ElevatedButtonThemeData _elevatedButtonTheme =
|
||||
ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
backgroundColor: AppColors.cyan,
|
||||
foregroundColor: AppColors.primary,
|
||||
textStyle: AppTextStyles.button,
|
||||
shadowColor: AppColors.cyan.withOpacity(0.4),
|
||||
).copyWith(
|
||||
overlayColor: MaterialStateProperty.resolveWith((states) {
|
||||
if (states.contains(MaterialState.pressed)) {
|
||||
return AppColors.cyan.withOpacity(0.2);
|
||||
}
|
||||
if (states.contains(MaterialState.hovered)) {
|
||||
return AppColors.cyan.withOpacity(0.1);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Text Button Theme
|
||||
static TextButtonThemeData _textButtonTheme = TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
foregroundColor: AppColors.cyan,
|
||||
textStyle: AppTextStyles.button,
|
||||
).copyWith(
|
||||
side: MaterialStateProperty.resolveWith((states) {
|
||||
if (states.contains(MaterialState.pressed)) {
|
||||
return BorderSide(color: AppColors.cyan, width: 2);
|
||||
}
|
||||
return BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.5),
|
||||
width: 1.5,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Outlined Button Theme
|
||||
static OutlinedButtonThemeData _outlinedButtonTheme =
|
||||
OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14),
|
||||
side: const BorderSide(color: AppColors.cyan, width: 2),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
foregroundColor: AppColors.cyan,
|
||||
textStyle: AppTextStyles.button,
|
||||
),
|
||||
);
|
||||
|
||||
// Input Decoration Theme
|
||||
static InputDecorationTheme _inputDecorationTheme =
|
||||
InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.2),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.2),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: const BorderSide(color: AppColors.cyan, width: 2),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: const BorderSide(color: AppColors.error, width: 2),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: const BorderSide(color: AppColors.error, width: 2),
|
||||
),
|
||||
hintStyle: AppTextStyles.body.copyWith(color: AppColors.muted),
|
||||
);
|
||||
|
||||
// Floating Action Button Theme
|
||||
static FloatingActionButtonThemeData _floatingActionButtonTheme =
|
||||
FloatingActionButtonThemeData(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
backgroundColor: AppColors.cyan,
|
||||
foregroundColor: AppColors.primary,
|
||||
iconSize: 24,
|
||||
);
|
||||
|
||||
// Bottom Navigation Bar Theme
|
||||
static BottomNavigationBarThemeData _bottomNavigationBarTheme =
|
||||
BottomNavigationBarThemeData(
|
||||
elevation: 8,
|
||||
backgroundColor: AppColors.surface,
|
||||
selectedItemColor: AppColors.cyan,
|
||||
unselectedItemColor: AppColors.muted,
|
||||
selectedLabelStyle: AppTextStyles.caption,
|
||||
unselectedLabelStyle: AppTextStyles.caption,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
);
|
||||
|
||||
// Navigation Bar Theme (Material 3)
|
||||
static NavigationBarThemeData _navigationBarTheme =
|
||||
NavigationBarThemeData(
|
||||
elevation: 0,
|
||||
backgroundColor: AppColors.surface,
|
||||
indicatorColor: AppColors.cyan.withOpacity(0.15),
|
||||
labelTextStyle: MaterialStateProperty.all(AppTextStyles.caption),
|
||||
height: 56,
|
||||
);
|
||||
|
||||
// Slider Theme
|
||||
static SliderThemeData _sliderTheme = SliderThemeData(
|
||||
trackHeight: 3,
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 6,
|
||||
),
|
||||
overlayShape: const RoundSliderOverlayShape(
|
||||
overlayRadius: 16,
|
||||
),
|
||||
activeTrackColor: AppColors.cyan,
|
||||
inactiveTrackColor: AppColors.surfaceVariant,
|
||||
thumbColor: AppColors.cyan,
|
||||
overlayColor: AppColors.cyan.withOpacity(0.2),
|
||||
);
|
||||
|
||||
// Progress Indicator Theme
|
||||
static ProgressIndicatorThemeData _progressIndicatorTheme =
|
||||
ProgressIndicatorThemeData(
|
||||
color: AppColors.cyan,
|
||||
linearTrackColor: AppColors.surfaceVariant,
|
||||
circularTrackColor: AppColors.surfaceVariant,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// App Colors - Neon Cyberpunk Theme
|
||||
class AppColors {
|
||||
AppColors._();
|
||||
|
||||
// Backgrounds
|
||||
static const Color primary = Color(0xFF0A0E27); // Bleu nuit très foncé
|
||||
static const Color surface = Color(0xFF1A1F3A); // Bleu nuit
|
||||
static const Color surfaceVariant = Color(0xFF252B4A);
|
||||
static const Color surfaceElevated = Color(0xFF2D344F);
|
||||
|
||||
// Neon accent colors
|
||||
static const Color cyan = Color(0xFF00F0FF); // Cyan électrique néon
|
||||
static const Color violet = Color(0xFFBF00FF); // Violet/magenta néon
|
||||
static const Color rose = Color(0xFFFF006E); // Rose néon vif
|
||||
static const Color vert = Color(0xFF39FF14); // Vert néon matrix
|
||||
static const Color jaune = Color(0xFFFFD600); // Jaune néon
|
||||
static const Color rouge = Color(0xFFFF2A6D); // Rouge néon
|
||||
|
||||
// Text colors
|
||||
static const Color onBackground = Color(0xFFE0E6FF); // Blanc bleuté
|
||||
static const Color onSurface = Color(0xFFB0B8D4); // Bleu gris clair
|
||||
static const Color onSurfaceVariant = Color(0xFF8A92B4);
|
||||
static const Color muted = Color(0xFF6A7294); // Bleu gris désaturé
|
||||
|
||||
// Functional colors
|
||||
static const Color success = vert;
|
||||
static const Color warning = jaune;
|
||||
static const Color error = rouge;
|
||||
static const Color info = cyan;
|
||||
|
||||
// Gradients
|
||||
static const LinearGradient primaryGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [cyan, violet],
|
||||
);
|
||||
|
||||
static const LinearGradient accentGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [violet, rose],
|
||||
);
|
||||
|
||||
static const LinearGradient fullGradient = LinearGradient(
|
||||
begin: Alignment(-1.0, -1.0),
|
||||
end: Alignment(1.0, 1.0),
|
||||
colors: [cyan, violet, rose],
|
||||
);
|
||||
|
||||
// Glow shadows
|
||||
static List<BoxShadow> get cyanGlow => [
|
||||
BoxShadow(
|
||||
color: cyan.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
];
|
||||
|
||||
static List<BoxShadow> get violetGlow => [
|
||||
BoxShadow(
|
||||
color: violet.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
];
|
||||
|
||||
static List<BoxShadow> get roseGlow => [
|
||||
BoxShadow(
|
||||
color: rose.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'colors.dart';
|
||||
|
||||
/// App Text Styles - Neon Cyberpunk Theme
|
||||
class AppTextStyles {
|
||||
AppTextStyles._();
|
||||
|
||||
// Font family
|
||||
static const String fontFamily = 'Outfit';
|
||||
|
||||
// Heading 1 - 32px Bold
|
||||
static const TextStyle h1 = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.onBackground,
|
||||
letterSpacing: -0.5,
|
||||
);
|
||||
|
||||
// Heading 2 - 24px SemiBold
|
||||
static const TextStyle h2 = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onBackground,
|
||||
letterSpacing: -0.25,
|
||||
);
|
||||
|
||||
// Heading 3 - 20px SemiBold
|
||||
static const TextStyle h3 = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onBackground,
|
||||
);
|
||||
|
||||
// Body Large - 16px Regular
|
||||
static const TextStyle bodyLarge = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.onBackground,
|
||||
height: 1.5,
|
||||
);
|
||||
|
||||
// Body - 14px Regular
|
||||
static const TextStyle body = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.onSurface,
|
||||
height: 1.5,
|
||||
);
|
||||
|
||||
// Body Small - 12px Regular
|
||||
static const TextStyle bodySmall = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.muted,
|
||||
height: 1.4,
|
||||
);
|
||||
|
||||
// Caption - 12px Regular
|
||||
static const TextStyle caption = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.muted,
|
||||
height: 1.3,
|
||||
);
|
||||
|
||||
// Button - 14px SemiBold
|
||||
static const TextStyle button = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
letterSpacing: 0.5,
|
||||
);
|
||||
|
||||
// Label - 14px Medium
|
||||
static const TextStyle label = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import 'artist.dart';
|
||||
|
||||
/// Album entity
|
||||
class Album extends Equatable {
|
||||
final String id;
|
||||
final String title;
|
||||
final DateTime? releaseDate;
|
||||
final String? imageUrl;
|
||||
final int totalTracks;
|
||||
final String? genre;
|
||||
final String? artistId;
|
||||
final Artist? artist;
|
||||
final String? spotifyId;
|
||||
final String? youtubePlaylistId;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const Album({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.releaseDate,
|
||||
this.imageUrl,
|
||||
this.totalTracks = 0,
|
||||
this.genre,
|
||||
this.artistId,
|
||||
this.artist,
|
||||
this.spotifyId,
|
||||
this.youtubePlaylistId,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
/// Create Album from JSON
|
||||
factory Album.fromJson(Map<String, dynamic> json) {
|
||||
return Album(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
releaseDate: json['release_date'] != null
|
||||
? DateTime.parse(json['release_date'] as String)
|
||||
: null,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
totalTracks: json['total_tracks'] as int? ?? 0,
|
||||
genre: json['genre'] as String?,
|
||||
artistId: json['artist_id'] as String?,
|
||||
artist: json['artist'] != null
|
||||
? Artist.fromJson(json['artist'] as Map<String, dynamic>)
|
||||
: null,
|
||||
spotifyId: json['spotify_id'] as String?,
|
||||
youtubePlaylistId: json['youtube_playlist_id'] as String?,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String)
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at'] as String)
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, title, releaseDate, totalTracks];
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Artist entity
|
||||
class Artist extends Equatable {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? imageUrl;
|
||||
final String? bio;
|
||||
final List<String> genres;
|
||||
final int popularity;
|
||||
final String? spotifyId;
|
||||
final String? youtubeId;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const Artist({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.imageUrl,
|
||||
this.bio,
|
||||
this.genres = const [],
|
||||
this.popularity = 0,
|
||||
this.spotifyId,
|
||||
this.youtubeId,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
/// Create Artist from JSON
|
||||
factory Artist.fromJson(Map<String, dynamic> json) {
|
||||
return Artist(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
bio: json['bio'] as String?,
|
||||
genres: (json['genres'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[],
|
||||
popularity: json['popularity'] as int? ?? 0,
|
||||
spotifyId: json['spotify_id'] as String?,
|
||||
youtubeId: json['youtube_id'] as String?,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String)
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at'] as String)
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, name, genres, popularity];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Domain entities."""
|
||||
export 'user.dart';
|
||||
export 'track.dart';
|
||||
export 'playlist.dart';
|
||||
export 'artist.dart';
|
||||
export 'album.dart';
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import 'track.dart';
|
||||
|
||||
/// Playlist entity
|
||||
class Playlist extends Equatable {
|
||||
final String id;
|
||||
final String userId;
|
||||
final String name;
|
||||
final String? description;
|
||||
final String? imageUrl;
|
||||
final bool isPublic;
|
||||
final bool isCollaborative;
|
||||
final bool isSmart;
|
||||
final int trackCount;
|
||||
final int totalDuration;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final List<PlaylistTrack>? tracks;
|
||||
|
||||
const Playlist({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.name,
|
||||
this.description,
|
||||
this.imageUrl,
|
||||
this.isPublic = false,
|
||||
this.isCollaborative = false,
|
||||
this.isSmart = false,
|
||||
this.trackCount = 0,
|
||||
this.totalDuration = 0,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.tracks,
|
||||
});
|
||||
|
||||
/// Format total duration as Xh Ym or Ym Zs
|
||||
String get formattedDuration {
|
||||
final hours = totalDuration ~/ 3600;
|
||||
final minutes = (totalDuration % 3600) ~/ 60;
|
||||
final seconds = totalDuration % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m';
|
||||
} else if (minutes > 0) {
|
||||
return '${minutes}m ${seconds}s';
|
||||
} else {
|
||||
return '${seconds}s';
|
||||
}
|
||||
}
|
||||
|
||||
/// Create Playlist from JSON
|
||||
factory Playlist.fromJson(Map<String, dynamic> json) {
|
||||
return Playlist(
|
||||
id: json['id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String?,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
isPublic: json['is_public'] as bool? ?? false,
|
||||
isCollaborative: json['is_collaborative'] as bool? ?? false,
|
||||
isSmart: json['is_smart'] as bool? ?? false,
|
||||
trackCount: json['track_count'] as int? ?? 0,
|
||||
totalDuration: json['total_duration'] as int? ?? 0,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String)
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at'] as String)
|
||||
: DateTime.now(),
|
||||
tracks: json['tracks'] != null
|
||||
? (json['tracks'] as List)
|
||||
.map((item) => PlaylistTrack.fromJson(item as Map<String, dynamic>))
|
||||
.toList()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
isPublic,
|
||||
isCollaborative,
|
||||
trackCount,
|
||||
totalDuration,
|
||||
];
|
||||
}
|
||||
|
||||
/// Playlist track association
|
||||
class PlaylistTrack extends Equatable {
|
||||
final String id;
|
||||
final String playlistId;
|
||||
final String trackId;
|
||||
final int position;
|
||||
final DateTime addedAt;
|
||||
final String? addedBy;
|
||||
final Track? track;
|
||||
|
||||
const PlaylistTrack({
|
||||
required this.id,
|
||||
required this.playlistId,
|
||||
required this.trackId,
|
||||
required this.position,
|
||||
required this.addedAt,
|
||||
this.adddedBy,
|
||||
this.track,
|
||||
});
|
||||
|
||||
/// Create PlaylistTrack from JSON
|
||||
factory PlaylistTrack.fromJson(Map<String, dynamic> json) {
|
||||
return PlaylistTrack(
|
||||
id: json['id'] as String,
|
||||
playlistId: json['playlist_id'] as String,
|
||||
trackId: json['track_id'] as String,
|
||||
position: json['position'] as int,
|
||||
addedAt: json['added_at'] != null
|
||||
? DateTime.parse(json['added_at'] as String)
|
||||
: DateTime.now(),
|
||||
addedBy: json['added_by'] as String?,
|
||||
track: json['track'] != null
|
||||
? Track.fromJson(json['track'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, playlistId, trackId, position, addedAt];
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import 'artist.dart';
|
||||
import 'album.dart';
|
||||
|
||||
/// Track entity
|
||||
class Track extends Equatable {
|
||||
final String id;
|
||||
final String title;
|
||||
final int? duration;
|
||||
final int? trackNumber;
|
||||
final String? imageUrl;
|
||||
final String? artistId;
|
||||
final String? albumId;
|
||||
final Artist? artist;
|
||||
final Album? album;
|
||||
final String? audioUrl;
|
||||
final int? playCount;
|
||||
final String? youtubeId;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const Track({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.duration,
|
||||
this.trackNumber,
|
||||
this.imageUrl,
|
||||
this.artistId,
|
||||
this.albumId,
|
||||
this.artist,
|
||||
this.album,
|
||||
this.audioUrl,
|
||||
this.playCount,
|
||||
this.youtubeId,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
Track copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
int? duration,
|
||||
int? trackNumber,
|
||||
String? imageUrl,
|
||||
String? artistId,
|
||||
String? albumId,
|
||||
Artist? artist,
|
||||
Album? album,
|
||||
String? audioUrl,
|
||||
int? playCount,
|
||||
String? youtubeId,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return Track(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
duration: duration ?? this.duration,
|
||||
trackNumber: trackNumber ?? this.trackNumber,
|
||||
imageUrl: imageUrl ?? this.imageUrl,
|
||||
artistId: artistId ?? this.artistId,
|
||||
albumId: albumId ?? this.albumId,
|
||||
artist: artist ?? this.artist,
|
||||
album: album ?? this.album,
|
||||
audioUrl: audioUrl ?? this.audioUrl,
|
||||
playCount: playCount ?? this.playCount,
|
||||
youtubeId: youtubeId ?? this.youtubeId,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Format duration as mm:ss
|
||||
String get formattedDuration {
|
||||
if (duration == null) return '--:--';
|
||||
final minutes = duration! ~/ 60;
|
||||
final seconds = duration! % 60;
|
||||
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// Create Track from JSON
|
||||
factory Track.fromJson(Map<String, dynamic> json) {
|
||||
return Track(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
duration: json['duration'] as int?,
|
||||
trackNumber: json['track_number'] as int?,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
artistId: json['artist_id'] as String?,
|
||||
albumId: json['album_id'] as String?,
|
||||
artist: json['artist'] != null
|
||||
? Artist.fromJson(json['artist'] as Map<String, dynamic>)
|
||||
: null,
|
||||
album: json['album'] != null
|
||||
? Album.fromJson(json['album'] as Map<String, dynamic>)
|
||||
: null,
|
||||
audioUrl: json['audio_url'] as String?,
|
||||
playCount: json['play_count'] as int?,
|
||||
youtubeId: json['youtube_id'] as String?,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String)
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at'] as String)
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
title,
|
||||
duration,
|
||||
artistId,
|
||||
albumId,
|
||||
youtubeId,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// User entity
|
||||
class User extends Equatable {
|
||||
final String id;
|
||||
final String email;
|
||||
final String username;
|
||||
final String? displayName;
|
||||
final String? avatarUrl;
|
||||
final bool isPremium;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const User({
|
||||
required this.id,
|
||||
required this.email,
|
||||
required this.username,
|
||||
this.displayName,
|
||||
this.avatarUrl,
|
||||
this.isPremium = false,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
User copyWith({
|
||||
String? id,
|
||||
String? email,
|
||||
String? username,
|
||||
String? displayName,
|
||||
String? avatarUrl,
|
||||
bool? isPremium,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return User(
|
||||
id: id ?? this.id,
|
||||
email: email ?? this.email,
|
||||
username: username ?? this.username,
|
||||
displayName: displayName ?? this.displayName,
|
||||
avatarUrl: avatarUrl ?? this.avatarUrl,
|
||||
isPremium: isPremium ?? this.isPremium,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
email,
|
||||
username,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
isPremium,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/// API Client exports
|
||||
export 'api_service.dart';
|
||||
export 'auth_api_service.dart';
|
||||
export 'music_api_service.dart';
|
||||
export 'playlist_api_service.dart';
|
||||
@@ -0,0 +1,76 @@
|
||||
/// API Service - Main HTTP client using Dio
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
|
||||
|
||||
import '../../../core/constants/api_constants.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
|
||||
/// API Service provider
|
||||
final apiServiceProvider = Provider<Dio>((ref) {
|
||||
final authState = ref.watch(authProvider);
|
||||
final token = authState?.accessToken;
|
||||
|
||||
final options = BaseOptions(
|
||||
baseUrl: ApiConstants.baseUrl,
|
||||
connectTimeout: const Duration(milliseconds: ApiConstants.connectionTimeoutMs),
|
||||
receiveTimeout: const Duration(milliseconds: ApiConstants.receiveTimeoutMs),
|
||||
sendTimeout: const Duration(milliseconds: ApiConstants.sendTimeoutMs),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
if (token != null) 'Authorization': 'Bearer $token',
|
||||
},
|
||||
);
|
||||
|
||||
final dio = Dio(options);
|
||||
|
||||
// Add logger in debug mode
|
||||
dio.interceptors.add(
|
||||
PrettyDioLogger(
|
||||
requestHeader: true,
|
||||
requestBody: true,
|
||||
responseBody: true,
|
||||
responseHeader: false,
|
||||
error: true,
|
||||
compact: true,
|
||||
),
|
||||
);
|
||||
|
||||
// Add token refresh interceptor
|
||||
dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onError: (error, handler) async {
|
||||
if (error.response?.statusCode == 401) {
|
||||
// Try to refresh token
|
||||
try {
|
||||
final newToken = await ref.read(authProvider.notifier).refreshToken();
|
||||
if (newToken != null) {
|
||||
// Retry original request with new token
|
||||
final opts = options.copyWith(
|
||||
headers: {
|
||||
...options.headers,
|
||||
'Authorization': 'Bearer $newToken',
|
||||
},
|
||||
);
|
||||
final clonedReq = await dio.fetch(opts..path = error.requestOptions.path);
|
||||
return handler.resolve(clonedReq);
|
||||
}
|
||||
} catch (e) {
|
||||
// Refresh failed, logout user
|
||||
ref.read(authProvider.notifier).logout();
|
||||
}
|
||||
}
|
||||
return handler.next(error);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return dio;
|
||||
});
|
||||
|
||||
/// Get API client
|
||||
Dio getDio(Ref ref) {
|
||||
return ref.read(apiServiceProvider);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/// Auth API Service
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/constants/api_constants.dart';
|
||||
import '../../../domain/entities/user.dart';
|
||||
import 'api_service.dart';
|
||||
|
||||
/// Auth API response models
|
||||
class LoginResponse {
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final int expiresIn;
|
||||
final User user;
|
||||
|
||||
LoginResponse({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.expiresIn,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
factory LoginResponse.fromJson(Map<String, dynamic> json) {
|
||||
return LoginResponse(
|
||||
accessToken: json['access_token'] as String,
|
||||
refreshToken: json['refresh_token'] as String,
|
||||
expiresIn: json['expires_in'] as int,
|
||||
user: User.fromJson(json['user'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension on User for JSON serialization
|
||||
extension UserJson on User {
|
||||
static User fromJson(Map<String, dynamic> json) {
|
||||
return User(
|
||||
id: json['id'] as String,
|
||||
email: json['email'] as String,
|
||||
username: json['username'] as String,
|
||||
displayName: json['display_name'] as String?,
|
||||
avatarUrl: json['avatar_url'] as String?,
|
||||
isPremium: json['is_premium'] as bool? ?? false,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'email': email,
|
||||
'username': username,
|
||||
if (displayName != null) 'display_name': displayName,
|
||||
if (avatarUrl != null) 'avatar_url': avatarUrl,
|
||||
'is_premium': isPremium,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Auth API Service
|
||||
class AuthApiService {
|
||||
AuthApiService(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
/// Login with email and password
|
||||
Future<LoginResponse> login(String email, String password) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
ApiConstants.login,
|
||||
data: {
|
||||
'email': email,
|
||||
'password': password,
|
||||
},
|
||||
);
|
||||
|
||||
return LoginResponse.fromJson(response.data);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new user
|
||||
Future<LoginResponse> register({
|
||||
required String email,
|
||||
required String username,
|
||||
required String password,
|
||||
String? displayName,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
ApiConstants.register,
|
||||
data: {
|
||||
'email': email,
|
||||
'username': username,
|
||||
'password': password,
|
||||
if (displayName != null) 'display_name': displayName,
|
||||
},
|
||||
);
|
||||
|
||||
return LoginResponse.fromJson(response.data);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh access token
|
||||
Future<Map<String, dynamic>> refreshToken(String refreshToken) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
ApiConstants.refresh,
|
||||
data: {'refresh_token': refreshToken},
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current user profile
|
||||
Future<User> getCurrentUser() async {
|
||||
try {
|
||||
final response = await _dio.get(ApiConstants.me);
|
||||
return UserJson.fromJson(response.data);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update user profile
|
||||
Future<User> updateProfile({
|
||||
String? displayName,
|
||||
String? avatarUrl,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.put(
|
||||
ApiConstants.me,
|
||||
data: {
|
||||
if (displayName != null) 'display_name': displayName,
|
||||
if (avatarUrl != null) 'avatar_url': avatarUrl,
|
||||
},
|
||||
);
|
||||
|
||||
return UserJson.fromJson(response.data);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Logout
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
await _dio.post(ApiConstants.logout);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Exception _handleDioError(DioException error) {
|
||||
if (error.response != null) {
|
||||
final statusCode = error.response!.statusCode;
|
||||
final message = error.response!.data['detail'] as String? ??
|
||||
'An error occurred';
|
||||
|
||||
return Exception('$statusCode: $message');
|
||||
} else if (error.type == DioExceptionType.connectionTimeout) {
|
||||
return const Exception('Connection timeout');
|
||||
} else if (error.type == DioExceptionType.receiveTimeout) {
|
||||
return const Exception('Receive timeout');
|
||||
} else {
|
||||
return Exception('Network error: ${error.message}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for Auth API Service
|
||||
final authApiServiceProvider = Provider<AuthApiService>((ref) {
|
||||
final dio = ref.watch(apiServiceProvider);
|
||||
return AuthApiService(dio);
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/// Music API Service
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/constants/api_constants.dart';
|
||||
import '../../../domain/entities/track.dart';
|
||||
import 'api_service.dart';
|
||||
|
||||
/// Music API Service
|
||||
class MusicApiService {
|
||||
MusicApiService(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
/// Search for music
|
||||
Future<Map<String, dynamic>> search(
|
||||
String query, {
|
||||
String type = 'all',
|
||||
int limit = 20,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
ApiConstants.searchMusic,
|
||||
queryParameters: {
|
||||
'q': query,
|
||||
'type': type,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
},
|
||||
);
|
||||
|
||||
return response.data as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get track details
|
||||
Future<Map<String, dynamic>> getTrack(String trackId) async {
|
||||
try {
|
||||
final response = await _dio.get('${ApiConstants.tracks}/$trackId');
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get stream URL for a track
|
||||
Future<Map<String, dynamic>> getStreamUrl(String trackId) async {
|
||||
try {
|
||||
final response = await _dio.get('${ApiConstants.tracks}/$trackId${ApiConstants.stream}');
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get recommendations based on a track
|
||||
Future<List<Map<String, dynamic>>> getRecommendations(
|
||||
String trackId, {
|
||||
int limit = 10,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'${ApiConstants.recommendations}/$trackId/recommendations',
|
||||
queryParameters: {'limit': limit},
|
||||
);
|
||||
return (response.data as List).cast<Map<String, dynamic>>();
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get trending tracks
|
||||
Future<List<Map<String, dynamic>>> getTrending({int limit = 20}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
ApiConstants.trending,
|
||||
queryParameters: {'limit': limit},
|
||||
);
|
||||
return (response.data as List).cast<Map<String, dynamic>>();
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get artist details
|
||||
Future<Map<String, dynamic>> getArtist(String artistId) async {
|
||||
try {
|
||||
final response = await _dio.get('${ApiConstants.artists}/$artistId');
|
||||
return response.data as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get artist's top tracks
|
||||
Future<List<Map<String, dynamic>>> getArtistTopTracks(
|
||||
String artistId, {
|
||||
int limit = 10,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'${ApiConstants.artists}/$artistId/tracks',
|
||||
queryParameters: {'limit': limit},
|
||||
);
|
||||
return (response.data as List).cast<Map<String, dynamic>>();
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get artist's albums
|
||||
Future<List<Map<String, dynamic>>> getArtistAlbums(
|
||||
String artistId, {
|
||||
int limit = 20,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'${ApiConstants.artists}/$artistId/albums',
|
||||
queryParameters: {'limit': limit},
|
||||
);
|
||||
return (response.data as List).cast<Map<String, dynamic>>();
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get album details
|
||||
Future<Map<String, dynamic>> getAlbum(String albumId) async {
|
||||
try {
|
||||
final response = await _dio.get('${ApiConstants.albums}/$albumId');
|
||||
return response.data as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get album tracks
|
||||
Future<List<Map<String, dynamic>>> getAlbumTracks(String albumId) async {
|
||||
try {
|
||||
final response = await _dio.get('${ApiConstants.albums}/$albumId/tracks');
|
||||
return (response.data as List).cast<Map<String, dynamic>>();
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Exception _handleError(DioException error) {
|
||||
if (error.response != null) {
|
||||
final message = error.response!.data['detail'] ?? 'An error occurred';
|
||||
return Exception('${error.response!.statusCode}: $message');
|
||||
}
|
||||
return Exception('Network error: ${error.message}');
|
||||
}
|
||||
}
|
||||
|
||||
final musicApiServiceProvider = Provider<MusicApiService>((ref) {
|
||||
final dio = ref.watch(apiServiceProvider);
|
||||
return MusicApiService(dio);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/// Playlist API Service
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/constants/api_constants.dart';
|
||||
import 'api_service.dart';
|
||||
|
||||
/// Playlist API Service
|
||||
class PlaylistApiService {
|
||||
PlaylistApiService(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
/// Get user playlists
|
||||
Future<List<Map<String, dynamic>>> getPlaylists({
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
ApiConstants.userPlaylists,
|
||||
queryParameters: {'limit': limit, 'offset': offset},
|
||||
);
|
||||
return (response.data as List).cast<Map<String, dynamic>>();
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Create playlist
|
||||
Future<Map<String, dynamic>> createPlaylist({
|
||||
required String name,
|
||||
String? description,
|
||||
String? imageUrl,
|
||||
bool isPublic = false,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
ApiConstants.userPlaylists,
|
||||
data: {
|
||||
'name': name,
|
||||
if (description != null) 'description': description,
|
||||
if (imageUrl != null) 'image_url': imageUrl,
|
||||
'is_public': isPublic,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get playlist with tracks
|
||||
Future<Map<String, dynamic>> getPlaylist(String playlistId) async {
|
||||
try {
|
||||
final response = await _dio.get('${ApiConstants.userPlaylists}/$playlistId');
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update playlist
|
||||
Future<Map<String, dynamic>> updatePlaylist(
|
||||
String playlistId, {
|
||||
String? name,
|
||||
String? description,
|
||||
String? imageUrl,
|
||||
bool? isPublic,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.put(
|
||||
'${ApiConstants.userPlaylists}/$playlistId',
|
||||
data: {
|
||||
if (name != null) 'name': name,
|
||||
if (description != null) 'description': description,
|
||||
if (imageUrl != null) 'image_url': imageUrl,
|
||||
if (isPublic != null) 'is_public': isPublic,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete playlist
|
||||
Future<void> deletePlaylist(String playlistId) async {
|
||||
try {
|
||||
await _dio.delete('${ApiConstants.userPlaylists}/$playlistId');
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add tracks to playlist
|
||||
Future<Map<String, dynamic>> addTracks(
|
||||
String playlistId,
|
||||
List<String> trackIds, {
|
||||
int? position,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'${ApiConstants.userPlaylists}/$playlistId${ApiConstants.playlistTracks}',
|
||||
data: {
|
||||
'track_ids': trackIds,
|
||||
if (position != null) 'position': position,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove track from playlist
|
||||
Future<Map<String, dynamic>> removeTrack(
|
||||
String playlistId,
|
||||
String trackId,
|
||||
) async {
|
||||
try {
|
||||
final response = await _dio.delete(
|
||||
'${ApiConstants.userPlaylists}/$playlistId${ApiConstants.playlistTracks}/$trackId',
|
||||
);
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reorder track in playlist
|
||||
Future<Map<String, dynamic>> reorderTrack(
|
||||
String playlistId,
|
||||
String trackId,
|
||||
int newPosition,
|
||||
) async {
|
||||
try {
|
||||
final response = await _dio.put(
|
||||
'${ApiConstants.userPlaylists}/$playlistId${ApiConstants.reorder}',
|
||||
data: {
|
||||
'track_id': trackId,
|
||||
'new_position': newPosition,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Exception _handleError(DioException error) {
|
||||
if (error.response != null) {
|
||||
final message = error.response!.data['detail'] ?? 'An error occurred';
|
||||
return Exception('${error.response!.statusCode}: $message');
|
||||
}
|
||||
return Exception('Network error: ${error.message}');
|
||||
}
|
||||
}
|
||||
|
||||
final playlistApiServiceProvider = Provider<PlaylistApiService>((ref) {
|
||||
final dio = ref.watch(apiServiceProvider);
|
||||
return PlaylistApiService(dio);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'presentation/adaptive/adaptive_layout.dart';
|
||||
|
||||
void main() {
|
||||
// Ensure Flutter bindings are initialized
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Set preferred orientations
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
|
||||
runApp(
|
||||
const ProviderScope(
|
||||
child: SpotifyLe2App(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class SpotifyLe2App extends StatelessWidget {
|
||||
const SpotifyLe2App({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Spotify Le 2',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
themeMode: ThemeMode.dark, // Always dark for neon cyberpunk
|
||||
home: const AdaptiveLayout(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../pages/desktop/home_page.dart';
|
||||
import '../pages/mobile/mobile_home_page.dart';
|
||||
import '../pages/search/search_page.dart';
|
||||
import '../pages/library/library_page.dart';
|
||||
import '../widgets/common/mini_player.dart';
|
||||
import '../widgets/desktop/desktop_sidebar.dart';
|
||||
import '../widgets/desktop/desktop_top_bar.dart';
|
||||
|
||||
/// Adaptive Layout - Desktop or Mobile based on screen width
|
||||
class AdaptiveLayout extends ConsumerWidget {
|
||||
const AdaptiveLayout({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Breakpoint at 800px
|
||||
if (constraints.maxWidth >= 800) {
|
||||
return const DesktopLayout();
|
||||
} else {
|
||||
return const MobileLayout();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Desktop Layout - 3 columns
|
||||
class DesktopLayout extends ConsumerWidget {
|
||||
const DesktopLayout({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentPage = ref.watch(currentPageProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
// Sidebar (240px fixed)
|
||||
const DesktopSidebar(
|
||||
width: 240,
|
||||
),
|
||||
|
||||
// Main content
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
// Top bar
|
||||
const DesktopTopBar(),
|
||||
|
||||
// Content area
|
||||
Expanded(
|
||||
child: _buildCurrentPage(currentPage),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Right panel (320px) - Queue/Now Playing
|
||||
// TODO: Implement RightPanel
|
||||
// const SizedBox(width: 320, child: RightPanel()),
|
||||
],
|
||||
),
|
||||
// Persistent mini player at bottom
|
||||
bottomNavigationBar: const MiniPlayer(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCurrentPage(String page) {
|
||||
switch (page) {
|
||||
case 'home':
|
||||
return const HomePage();
|
||||
case 'search':
|
||||
return const SearchPage();
|
||||
case 'library':
|
||||
return const LibraryPage();
|
||||
case 'settings':
|
||||
// TODO: Implement SettingsPage
|
||||
return const _PlaceholderPage(title: 'Settings');
|
||||
default:
|
||||
return const HomePage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mobile Layout - Bottom nav
|
||||
class MobileLayout extends ConsumerWidget {
|
||||
const MobileLayout({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentPage = ref.watch(currentPageProvider);
|
||||
final navigationNotifier = ref.read(navigationProvider.notifier);
|
||||
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
// Top bar
|
||||
// TODO: Implement MobileTopBar
|
||||
const SizedBox(height: 60),
|
||||
|
||||
// Content area
|
||||
Expanded(
|
||||
child: _buildCurrentPage(currentPage),
|
||||
),
|
||||
|
||||
// Mini player (sticky)
|
||||
const MiniPlayer(),
|
||||
|
||||
// Bottom navigation
|
||||
NavigationBar(
|
||||
height: 56,
|
||||
selectedIndex: _navItems.indexWhere(
|
||||
(item) => item.page == currentPage,
|
||||
),
|
||||
onDestinationSelected: (index) {
|
||||
navigationNotifier.navigateTo(_navItems[index].page);
|
||||
},
|
||||
destinations: _navItems
|
||||
.map(
|
||||
(item) => NavigationDestination(
|
||||
icon: Icon(item.icon),
|
||||
label: Text(item.label),
|
||||
selectedIcon: Icon(item.selectedIcon ?? item.icon),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCurrentPage(String page) {
|
||||
switch (page) {
|
||||
case 'home':
|
||||
return const MobileHomePage();
|
||||
case 'search':
|
||||
return const SearchPage();
|
||||
case 'library':
|
||||
return const LibraryPage();
|
||||
case 'settings':
|
||||
return const _PlaceholderPage(title: 'Settings');
|
||||
default:
|
||||
return const MobileHomePage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation items
|
||||
class _NavItem {
|
||||
final String page;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final IconData? selectedIcon;
|
||||
|
||||
const _NavItem({
|
||||
required this.page,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
this.selectedIcon,
|
||||
});
|
||||
}
|
||||
|
||||
final List<_NavItem> _navItems = const [
|
||||
_NavItem(
|
||||
page: 'home',
|
||||
label: 'Home',
|
||||
icon: Icons.home_outlined,
|
||||
selectedIcon: Icons.home,
|
||||
),
|
||||
_NavItem(
|
||||
page: 'search',
|
||||
label: 'Search',
|
||||
icon: Icons.search_outlined,
|
||||
selectedIcon: Icons.search,
|
||||
),
|
||||
_NavItem(
|
||||
page: 'library',
|
||||
label: 'Library',
|
||||
icon: Icons.library_music_outlined,
|
||||
selectedIcon: Icons.library_music,
|
||||
),
|
||||
_NavItem(
|
||||
page: 'settings',
|
||||
label: 'Settings',
|
||||
icon: Icons.settings_outlined,
|
||||
selectedIcon: Icons.settings,
|
||||
),
|
||||
];
|
||||
|
||||
/// Placeholder page for unimplemented pages
|
||||
class _PlaceholderPage extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
const _PlaceholderPage({required this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.construction,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.displaySmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Coming soon...',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
/// Album Details Page - Desktop Layout
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../providers/album_provider.dart';
|
||||
import '../../providers/music_provider.dart';
|
||||
import '../../../domain/entities/track.dart';
|
||||
import '../../widgets/album/album_widgets.dart';
|
||||
import '../common/cached_network_image_with_fallback.dart';
|
||||
|
||||
class AlbumDesktopPage extends ConsumerStatefulWidget {
|
||||
final String albumId;
|
||||
|
||||
const AlbumDesktopPage({
|
||||
required this.albumId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<AlbumDesktopPage> createState() => _AlbumDesktopPageState();
|
||||
}
|
||||
|
||||
class _AlbumDesktopPageState extends ConsumerState<AlbumDesktopPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Load album data
|
||||
Future.microtask(() {
|
||||
ref.read(albumProvider.notifier).loadAlbum(widget.albumId);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final albumState = ref.watch(albumProvider);
|
||||
|
||||
if (albumState.isLoading && albumState.album == null) {
|
||||
return _buildLoadingState();
|
||||
}
|
||||
|
||||
if (albumState.error != null && albumState.album == null) {
|
||||
return _buildErrorState(albumState.error!);
|
||||
}
|
||||
|
||||
if (albumState.album == null) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.primary,
|
||||
body: Row(
|
||||
children: [
|
||||
// Left panel - Album art and info
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: _buildLeftPanel(albumState),
|
||||
),
|
||||
|
||||
const VerticalDivider(width: 1, color: AppColors.surfaceVariant),
|
||||
|
||||
// Right panel - Tracklist
|
||||
Expanded(
|
||||
flex: 6,
|
||||
child: _buildRightPanel(albumState),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingState() {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.cyan),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(String error) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: AppColors.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Something went wrong',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: AppColors.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
ref.read(albumProvider.notifier).loadAlbum(widget.albumId);
|
||||
},
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.cyan),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLeftPanel(albumState) {
|
||||
final album = albumState.album!;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
AppColors.violet.withOpacity(0.2),
|
||||
AppColors.primary,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Album art
|
||||
Hero(
|
||||
tag: 'album_art_${album.id}',
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: AppColors.cyanGlow,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: CachedNetworkImageWithFallback(
|
||||
imageUrl: album.imageUrl,
|
||||
fallbackIcon: Icons.album,
|
||||
progressColor: AppColors.cyan,
|
||||
width: 320,
|
||||
height: 320,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Album title
|
||||
Text(
|
||||
album.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Artist name and year
|
||||
if (album.artist != null)
|
||||
Text(
|
||||
'${album.artist!.name}${album.releaseDate != null ? ' • ${album.releaseDate!.year}' : ''}',
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Action buttons
|
||||
_buildActionButtons(albumState),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Album info chips
|
||||
_buildAlbumInfo(albumState),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(albumState) {
|
||||
final tracks = albumState.tracks;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Play All button
|
||||
SizedBox(
|
||||
width: 180,
|
||||
height: 52,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: tracks.isNotEmpty
|
||||
? () {
|
||||
final albumNotifier = ref.read(albumProvider.notifier);
|
||||
final playerNotifier = ref.read(playerProvider.notifier);
|
||||
albumNotifier.playAll(playerNotifier);
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.play_arrow, size: 26),
|
||||
label: const Text('Play All',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.cyan,
|
||||
foregroundColor: AppColors.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Shuffle button
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.violet, AppColors.rose],
|
||||
),
|
||||
boxShadow: AppColors.violetGlow,
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.shuffle, size: 24),
|
||||
color: AppColors.onBackground,
|
||||
onPressed: tracks.isNotEmpty
|
||||
? () {
|
||||
final albumNotifier = ref.read(albumProvider.notifier);
|
||||
final playerNotifier = ref.read(playerProvider.notifier);
|
||||
albumNotifier.shuffle(playerNotifier);
|
||||
}
|
||||
: null,
|
||||
iconSize: 52,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumInfo(albumState) {
|
||||
final album = albumState.album!;
|
||||
final tracks = albumState.tracks;
|
||||
|
||||
return Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (album.totalTracks > 0)
|
||||
_buildInfoChip('${album.totalTracks} ${album.totalTracks == 1 ? 'track' : 'tracks'}'),
|
||||
if (album.totalTracks > 0 && albumState.totalDuration > 0)
|
||||
_buildInfoChip(albumState.formattedTotalDuration),
|
||||
if (album.genre != null)
|
||||
_buildInfoChip(album.genre!, isGenre: true),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoChip(String text, {bool isGenre = false}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isGenre
|
||||
? AppColors.violet.withOpacity(0.2)
|
||||
: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isGenre
|
||||
? AppColors.violet.withOpacity(0.5)
|
||||
: AppColors.cyan.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: isGenre ? AppColors.violet : AppColors.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRightPanel(albumState) {
|
||||
final tracks = albumState.tracks;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface.withOpacity(0.5),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppColors.surfaceVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: AppColors.onBackground),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const Text(
|
||||
'Tracklist',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (tracks.isNotEmpty)
|
||||
Text(
|
||||
'${tracks.length} tracks',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Tracklist
|
||||
Expanded(
|
||||
child: tracks.isEmpty
|
||||
? _buildEmptyTracklistState()
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
itemCount: tracks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final track = tracks[index];
|
||||
return AlbumTrackTile(
|
||||
track: track,
|
||||
index: index,
|
||||
onTap: () => _playTrack(track, index),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyTracklistState() {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.music_note,
|
||||
size: 64,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'No tracks available',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _playTrack(Track track, int index) {
|
||||
final albumNotifier = ref.read(albumProvider.notifier);
|
||||
final playerNotifier = ref.read(playerProvider.notifier);
|
||||
albumNotifier.playTrack(playerNotifier, track);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/// Album Details Page - Adaptive layout
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'album_mobile_page.dart';
|
||||
import 'album_desktop_page.dart';
|
||||
|
||||
class AlbumDetailsPage extends StatelessWidget {
|
||||
final String albumId;
|
||||
|
||||
const AlbumDetailsPage({
|
||||
required this.albumId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth >= 800) {
|
||||
return AlbumDesktopPage(albumId: albumId);
|
||||
} else {
|
||||
return AlbumMobilePage(albumId: albumId);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
/// Album Details Page - Mobile Layout
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../providers/album_provider.dart';
|
||||
import '../../providers/music_provider.dart';
|
||||
import '../../../domain/entities/track.dart';
|
||||
import '../../widgets/album/album_widgets.dart';
|
||||
import '../common/cached_network_image_with_fallback.dart';
|
||||
|
||||
class AlbumMobilePage extends ConsumerStatefulWidget {
|
||||
final String albumId;
|
||||
|
||||
const AlbumMobilePage({
|
||||
required this.albumId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<AlbumMobilePage> createState() => _AlbumMobilePageState();
|
||||
}
|
||||
|
||||
class _AlbumMobilePageState extends ConsumerState<AlbumMobilePage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Load album data
|
||||
Future.microtask(() {
|
||||
ref.read(albumProvider.notifier).loadAlbum(widget.albumId);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final albumState = ref.watch(albumProvider);
|
||||
|
||||
if (albumState.isLoading && albumState.album == null) {
|
||||
return _buildLoadingState();
|
||||
}
|
||||
|
||||
if (albumState.error != null && albumState.album == null) {
|
||||
return _buildErrorState(albumState.error!);
|
||||
}
|
||||
|
||||
if (albumState.album == null) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Hero header
|
||||
_buildHeroHeader(albumState.album!),
|
||||
|
||||
// Action buttons
|
||||
_buildActionButtons(albumState.tracks),
|
||||
|
||||
// Album info
|
||||
_buildAlbumInfo(albumState),
|
||||
|
||||
// Tracklist
|
||||
if (albumState.tracks.isNotEmpty)
|
||||
_buildTracklistSection(albumState.tracks),
|
||||
|
||||
// Bottom spacing
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 100),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingState() {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.cyan),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(String error) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: AppColors.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Something went wrong',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: AppColors.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
ref.read(albumProvider.notifier).loadAlbum(widget.albumId);
|
||||
},
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.cyan),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroHeader(album) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Background gradient
|
||||
Container(
|
||||
height: 400,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
AppColors.violet.withOpacity(0.3),
|
||||
AppColors.primary,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Album art background (blurred)
|
||||
if (album.imageUrl != null)
|
||||
Positioned.fill(
|
||||
child: Opacity(
|
||||
opacity: 0.15,
|
||||
child: Image.network(
|
||||
album.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
SizedBox(
|
||||
height: 400,
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Back button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back,
|
||||
color: AppColors.onBackground),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Album art
|
||||
Center(
|
||||
child: Hero(
|
||||
tag: 'album_art_${album.id}',
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: AppColors.cyanGlow,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImageWithFallback(
|
||||
imageUrl: album.imageUrl,
|
||||
fallbackIcon: Icons.album,
|
||||
progressColor: AppColors.cyan,
|
||||
width: 240,
|
||||
height: 240,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Album title
|
||||
Text(
|
||||
album.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Artist name and year
|
||||
if (album.artist != null)
|
||||
Text(
|
||||
'${album.artist!.name}${album.releaseDate != null ? ' • ${album.releaseDate!.year}' : ''}',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(List<Track> tracks) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Play All button
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: tracks.isNotEmpty
|
||||
? () {
|
||||
final albumNotifier = ref.read(albumProvider.notifier);
|
||||
final playerNotifier = ref.read(playerProvider.notifier);
|
||||
albumNotifier.playAll(playerNotifier);
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.play_arrow, size: 24),
|
||||
label: const Text('Play All',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.cyan,
|
||||
foregroundColor: AppColors.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Shuffle button
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.violet, AppColors.rose],
|
||||
),
|
||||
boxShadow: AppColors.violetGlow,
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.shuffle, size: 20),
|
||||
color: AppColors.onBackground,
|
||||
onPressed: tracks.isNotEmpty
|
||||
? () {
|
||||
final albumNotifier = ref.read(albumProvider.notifier);
|
||||
final playerNotifier = ref.read(playerProvider.notifier);
|
||||
albumNotifier.shuffle(playerNotifier);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumInfo(albumState) {
|
||||
final album = albumState.album!;
|
||||
final tracks = albumState.tracks;
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (album.totalTracks > 0)
|
||||
_buildInfoChip('${album.totalTracks} ${album.totalTracks == 1 ? 'track' : 'tracks'}'),
|
||||
if (album.totalTracks > 0 && albumState.totalDuration > 0)
|
||||
const SizedBox(width: 8),
|
||||
if (albumState.totalDuration > 0)
|
||||
_buildInfoChip(albumState.formattedTotalDuration),
|
||||
if (album.genre != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
_buildInfoChip(album.genre!, isGenre: true),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoChip(String text, {bool isGenre = false}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isGenre
|
||||
? AppColors.violet.withOpacity(0.2)
|
||||
: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isGenre
|
||||
? AppColors.violet.withOpacity(0.5)
|
||||
: AppColors.cyan.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: isGenre ? AppColors.violet : AppColors.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTracklistSection(List<Track> tracks) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 24, 16, 12),
|
||||
child: Text(
|
||||
'Tracklist',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
),
|
||||
),
|
||||
...tracks.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final track = entry.value;
|
||||
return AlbumTrackTile(
|
||||
track: track,
|
||||
index: index,
|
||||
onTap: () => _playTrack(track, index),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _playTrack(Track track, int index) {
|
||||
final albumNotifier = ref.read(albumProvider.notifier);
|
||||
final playerNotifier = ref.read(playerProvider.notifier);
|
||||
albumNotifier.playTrack(playerNotifier, track);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
/// Artist Details Page - Desktop Layout
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../providers/artist_provider.dart';
|
||||
import '../../providers/music_provider.dart';
|
||||
import '../../../domain/entities/track.dart';
|
||||
import '../../widgets/artist/artist_track_tile.dart';
|
||||
import '../../widgets/artist/artist_album_card.dart';
|
||||
|
||||
class ArtistDesktopPage extends ConsumerStatefulWidget {
|
||||
final String artistId;
|
||||
|
||||
const ArtistDesktopPage({
|
||||
required this.artistId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ArtistDesktopPage> createState() => _ArtistDesktopPageState();
|
||||
}
|
||||
|
||||
class _ArtistDesktopPageState extends ConsumerState<ArtistDesktopPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Load artist data
|
||||
Future.microtask(() {
|
||||
ref.read(artistProvider.notifier).loadAllArtistData(widget.artistId);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final artistState = ref.watch(artistProvider);
|
||||
|
||||
if (artistState.isLoading && artistState.artist == null) {
|
||||
return _buildLoadingState();
|
||||
}
|
||||
|
||||
if (artistState.error != null && artistState.artist == null) {
|
||||
return _buildErrorState(artistState.error!);
|
||||
}
|
||||
|
||||
if (artistState.artist == null) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Hero header
|
||||
_buildHeroHeader(artistState.artist!),
|
||||
|
||||
// Main content
|
||||
SliverToBoxAdapter(
|
||||
child: _buildMainContent(artistState),
|
||||
),
|
||||
|
||||
// Bottom spacing
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 100),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingState() {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.cyan),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(String error) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: AppColors.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Something went wrong',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: AppColors.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
ref.read(artistProvider.notifier).loadAllArtistData(widget.artistId);
|
||||
},
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.cyan),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroHeader(artist) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Background gradient
|
||||
Container(
|
||||
height: 350,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
AppColors.violet.withOpacity(0.2),
|
||||
AppColors.primary,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Artist image background
|
||||
if (artist.imageUrl != null)
|
||||
Positioned.fill(
|
||||
child: Opacity(
|
||||
opacity: 0.15,
|
||||
child: Image.network(
|
||||
artist.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
SizedBox(
|
||||
height: 350,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 48),
|
||||
|
||||
// Back button
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 24),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: AppColors.onBackground),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Artist image and info
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Spacer(),
|
||||
|
||||
// Artist image
|
||||
if (artist.imageUrl != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.network(
|
||||
artist.imageUrl!,
|
||||
width: 220,
|
||||
height: 220,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Artist name
|
||||
Text(
|
||||
artist.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.onBackground,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Genres
|
||||
if (artist.genres.isNotEmpty)
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
alignment: WrapAlignment.center,
|
||||
children: artist.genres.take(4).map((genre) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: AppColors.cyan.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
genre,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cyan,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(flex: 3),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMainContent(artistState) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Play All button
|
||||
_buildPlayAllButton(artistState.topTracks),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Two column layout
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Left column - Popular tracks
|
||||
Expanded(
|
||||
child: _buildPopularTracksSection(artistState.topTracks),
|
||||
),
|
||||
|
||||
const SizedBox(width: 48),
|
||||
|
||||
// Right column - Albums and Related
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Albums section
|
||||
if (artistState.albums.isNotEmpty)
|
||||
_buildAlbumsSection(artistState.albums),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Related tracks section
|
||||
if (artistState.relatedTracks.isNotEmpty)
|
||||
_buildRelatedTracksSection(artistState.relatedTracks),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlayAllButton(List<Track> tracks) {
|
||||
return SizedBox(
|
||||
width: 200,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: tracks.isNotEmpty
|
||||
? () {
|
||||
final playerNotifier = ref.read(playerProvider.notifier);
|
||||
playerNotifier.setQueue(tracks, startIndex: 0);
|
||||
playerNotifier.loadTrack(tracks.first);
|
||||
playerNotifier.play();
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.play_arrow, size: 28),
|
||||
label: const Text('Play All', style: TextStyle(fontSize: 16)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
backgroundColor: AppColors.cyan,
|
||||
foregroundColor: AppColors.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPopularTracksSection(List<Track> tracks) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Popular Tracks',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppColors.cyan.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: tracks.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final track = entry.value;
|
||||
return ArtistTrackTile(
|
||||
track: track,
|
||||
index: index,
|
||||
onTap: () => _playTrack(track),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumsSection(albums) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Albums',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Grid of albums - 2 per row on desktop
|
||||
Column(
|
||||
children: [
|
||||
for (int i = 0; i < albums.length; i += 2)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ArtistAlbumCard(
|
||||
album: albums[i],
|
||||
onTap: () {
|
||||
// TODO: Navigate to album details
|
||||
},
|
||||
),
|
||||
),
|
||||
if (i + 1 < albums.length) ...[
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ArtistAlbumCard(
|
||||
album: albums[i + 1],
|
||||
onTap: () {
|
||||
// TODO: Navigate to album details
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRelatedTracksSection(List<Track> tracks) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Related Tracks',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppColors.violet.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: tracks.take(5).toList().asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final track = entry.value;
|
||||
return ArtistTrackTile(
|
||||
track: track,
|
||||
index: index,
|
||||
onTap: () => _playTrack(track),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _playTrack(Track track) {
|
||||
final playerNotifier = ref.read(playerProvider.notifier);
|
||||
final artistState = ref.read(artistProvider);
|
||||
playerNotifier.setQueue(artistState.topTracks, startIndex: 0);
|
||||
playerNotifier.loadTrack(track);
|
||||
playerNotifier.play();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/// Artist Details Page - Adaptive layout
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'artist_mobile_page.dart';
|
||||
import 'artist_desktop_page.dart';
|
||||
|
||||
class ArtistDetailsPage extends StatelessWidget {
|
||||
final String artistId;
|
||||
|
||||
const ArtistDetailsPage({
|
||||
required this.artistId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth >= 800) {
|
||||
return ArtistDesktopPage(artistId: artistId);
|
||||
} else {
|
||||
return ArtistMobilePage(artistId: artistId);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
/// Artist Details Page - Mobile Layout
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../providers/artist_provider.dart';
|
||||
import '../../providers/music_provider.dart';
|
||||
import '../../../domain/entities/track.dart';
|
||||
import '../../widgets/artist/artist_track_tile.dart';
|
||||
import '../../widgets/artist/artist_album_card.dart';
|
||||
|
||||
class ArtistMobilePage extends ConsumerStatefulWidget {
|
||||
final String artistId;
|
||||
|
||||
const ArtistMobilePage({
|
||||
required this.artistId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ArtistMobilePage> createState() => _ArtistMobilePageState();
|
||||
}
|
||||
|
||||
class _ArtistMobilePageState extends ConsumerState<ArtistMobilePage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Load artist data
|
||||
Future.microtask(() {
|
||||
ref.read(artistProvider.notifier).loadAllArtistData(widget.artistId);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final artistState = ref.watch(artistProvider);
|
||||
|
||||
if (artistState.isLoading && artistState.artist == null) {
|
||||
return _buildLoadingState();
|
||||
}
|
||||
|
||||
if (artistState.error != null && artistState.artist == null) {
|
||||
return _buildErrorState(artistState.error!);
|
||||
}
|
||||
|
||||
if (artistState.artist == null) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Hero header
|
||||
_buildHeroHeader(artistState.artist!),
|
||||
|
||||
// Play All button
|
||||
_buildPlayAllButton(artistState.topTracks),
|
||||
|
||||
// Popular tracks section
|
||||
if (artistState.topTracks.isNotEmpty)
|
||||
_buildPopularTracksSection(artistState.topTracks),
|
||||
|
||||
// Albums section
|
||||
if (artistState.albums.isNotEmpty)
|
||||
_buildAlbumsSection(artistState.albums),
|
||||
|
||||
// Related tracks section
|
||||
if (artistState.relatedTracks.isNotEmpty)
|
||||
_buildRelatedTracksSection(artistState.relatedTracks),
|
||||
|
||||
// Bottom spacing
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 100),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingState() {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.cyan),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(String error) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: AppColors.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Something went wrong',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: AppColors.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
ref.read(artistProvider.notifier).loadAllArtistData(widget.artistId);
|
||||
},
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.cyan),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroHeader(artist) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Background gradient
|
||||
Container(
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
AppColors.violet.withOpacity(0.3),
|
||||
AppColors.primary,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Artist image
|
||||
if (artist.imageUrl != null)
|
||||
Positioned.fill(
|
||||
child: Opacity(
|
||||
opacity: 0.2,
|
||||
child: Image.network(
|
||||
artist.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
SizedBox(
|
||||
height: 280,
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Back button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: AppColors.onBackground),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Artist image
|
||||
if (artist.imageUrl != null)
|
||||
Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
artist.imageUrl!,
|
||||
width: 160,
|
||||
height: 160,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Artist name
|
||||
Text(
|
||||
artist.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Genres
|
||||
if (artist.genres.isNotEmpty)
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
alignment: WrapAlignment.center,
|
||||
children: artist.genres.take(3).map((genre) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppColors.cyan.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
genre,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cyan,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlayAllButton(List<Track> tracks) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: tracks.isNotEmpty
|
||||
? () {
|
||||
final playerNotifier = ref.read(playerProvider.notifier);
|
||||
playerNotifier.setQueue(tracks, startIndex: 0);
|
||||
playerNotifier.loadTrack(tracks.first);
|
||||
playerNotifier.play();
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.play_arrow, size: 28),
|
||||
label: const Text('Play All', style: TextStyle(fontSize: 16)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
backgroundColor: AppColors.cyan,
|
||||
foregroundColor: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPopularTracksSection(List<Track> tracks) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 24, 16, 12),
|
||||
child: Text(
|
||||
'Popular',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
),
|
||||
),
|
||||
...tracks.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final track = entry.value;
|
||||
return ArtistTrackTile(
|
||||
track: track,
|
||||
index: index,
|
||||
onTap: () => _playTrack(track),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumsSection(albums) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 24, 16, 12),
|
||||
child: Text(
|
||||
'Albums',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemCount: albums.length,
|
||||
itemBuilder: (context, index) {
|
||||
return ArtistAlbumCard(
|
||||
album: albums[index],
|
||||
onTap: () {
|
||||
// TODO: Navigate to album details
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRelatedTracksSection(List<Track> tracks) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 24, 16, 12),
|
||||
child: Text(
|
||||
'Related Tracks',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
),
|
||||
),
|
||||
...tracks.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final track = entry.value;
|
||||
return ArtistTrackTile(
|
||||
track: track,
|
||||
index: index,
|
||||
onTap: () => _playTrack(track),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _playTrack(Track track) {
|
||||
final playerNotifier = ref.read(playerProvider.notifier);
|
||||
final artistState = ref.read(artistProvider);
|
||||
playerNotifier.setQueue(artistState.topTracks, startIndex: 0);
|
||||
playerNotifier.loadTrack(track);
|
||||
playerNotifier.play();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/// Login Page
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
bool _isLoginMode = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final authNotifier = ref.read(authProvider.notifier);
|
||||
|
||||
if (_isLoginMode) {
|
||||
await authNotifier.login(
|
||||
_emailController.text.trim(),
|
||||
_passwordController.text,
|
||||
);
|
||||
} else {
|
||||
await authNotifier.register(
|
||||
email: _emailController.text.trim(),
|
||||
username: _emailController.text.split('@')[0],
|
||||
password: _passwordController.text,
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted && authNotifier.hasError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(authNotifier.error ?? 'An error occurred'),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
// If already logged in, redirect to home
|
||||
if (authState.isAuthenticated) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Navigator.of(context).pushReplacementNamed('/');
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppColors.primary,
|
||||
AppColors.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Card(
|
||||
elevation: 20,
|
||||
shadowColor: AppColors.cyan.withOpacity(0.3),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Logo/Title
|
||||
const Text(
|
||||
'AudiOhm',
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
foreground: AppColors.primaryGradient,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_isLoginMode ? 'Welcome back' : 'Create account',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Email field
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Email is required';
|
||||
}
|
||||
if (!value.contains('@')) {
|
||||
return 'Enter a valid email';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Password field
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
prefixIcon: const Icon(Icons.lock_outlined),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Password is required';
|
||||
}
|
||||
if (value.length < 8) {
|
||||
return 'Password must be at least 8 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Login button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: FilledButton(
|
||||
onPressed: authState.isLoading ? null : _submit,
|
||||
child: authState.isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
)
|
||||
: Text(_isLoginMode ? 'Login' : 'Register'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Toggle mode
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isLoginMode = !_isLoginMode;
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
_isLoginMode
|
||||
? "Don't have an account? Register"
|
||||
: 'Already have an account? Login',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/colors.dart';
|
||||
|
||||
/// Desktop Home Page
|
||||
class HomePage extends StatelessWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Header
|
||||
SliverAppBar(
|
||||
expandedHeight: 200,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
title: const Text(
|
||||
'Good Evening',
|
||||
style: TextStyle(
|
||||
color: AppColors.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
background: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppColors.surface,
|
||||
AppColors.surfaceVariant,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content sections
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Quick picks
|
||||
const _SectionTitle(title: 'Quick Picks'),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: 6,
|
||||
itemBuilder: (context, index) {
|
||||
return const _QuickPickCard();
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Recently played
|
||||
const _SectionTitle(title: 'Recently Played'),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: 10,
|
||||
itemBuilder: (context, index) {
|
||||
return const _AlbumCard();
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Made for you
|
||||
const _SectionTitle(title: 'Made For You'),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: 8,
|
||||
itemBuilder: (context, index) {
|
||||
return const _PlaylistCard();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
const _SectionTitle({required this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
color: AppColors.cyan,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickPickCard extends StatelessWidget {
|
||||
const _QuickPickCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 280,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColors.surface,
|
||||
AppColors.surfaceVariant,
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppColors.cyan.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(7),
|
||||
bottomLeft: Radius.circular(7),
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.music_note,
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
),
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Playlist Name',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'Description',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumCard extends StatelessWidget {
|
||||
const _AlbumCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 160,
|
||||
margin: const EdgeInsets.only(right: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Album art
|
||||
Container(
|
||||
width: 160,
|
||||
height: 160,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.accentGradient,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.album,
|
||||
size: 64,
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Album info
|
||||
const Text(
|
||||
'Album Name',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const Text(
|
||||
'Artist Name',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaylistCard extends StatelessWidget {
|
||||
const _PlaylistCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 160,
|
||||
margin: const EdgeInsets.only(right: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Playlist art
|
||||
Container(
|
||||
width: 160,
|
||||
height: 160,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.fullGradient,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.playlist_play,
|
||||
size: 64,
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Playlist info
|
||||
const Text(
|
||||
'Playlist Name',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const Text(
|
||||
'Description',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
/// Library Page - Desktop Layout
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../providers/library_provider.dart';
|
||||
import '../../widgets/library/playlist_tile.dart';
|
||||
import '../../../domain/entities/track.dart';
|
||||
import '../../../domain/entities/album.dart';
|
||||
import '../../../domain/entities/artist.dart';
|
||||
|
||||
class LibraryDesktopPage extends ConsumerStatefulWidget {
|
||||
const LibraryDesktopPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LibraryDesktopPage> createState() =>
|
||||
_LibraryDesktopPageState();
|
||||
}
|
||||
|
||||
class _LibraryDesktopPageState extends ConsumerState<LibraryDesktopPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 4, vsync: this);
|
||||
// Load library on init
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(libraryProvider.notifier).loadLibrary();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final libraryState = ref.watch(libraryProvider);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Header with tabs
|
||||
_buildHeader(libraryState),
|
||||
|
||||
// Content based on selected tab
|
||||
Expanded(
|
||||
child: _buildContent(libraryState),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(dynamic libraryState) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant.withOpacity(0.5),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.library_music,
|
||||
color: AppColors.cyan,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const Text(
|
||||
'Your Library',
|
||||
style: TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (libraryState.totalItems > 0)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, color: AppColors.cyan),
|
||||
onPressed: () {
|
||||
ref.read(libraryProvider.notifier).refresh();
|
||||
},
|
||||
tooltip: 'Refresh',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(dynamic libraryState) {
|
||||
if (libraryState.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.cyan),
|
||||
);
|
||||
}
|
||||
|
||||
if (libraryState.error != null) {
|
||||
return _buildErrorState(libraryState.error ?? 'Unknown error');
|
||||
}
|
||||
|
||||
return TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildPlaylistsTab(libraryState.playlists),
|
||||
_buildLikedSongsTab(libraryState.likedSongs),
|
||||
_buildAlbumsTab(libraryState.savedAlbums),
|
||||
_buildArtistsTab(libraryState.followedArtists),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaylistsTab(List<dynamic> playlists) {
|
||||
if (playlists.isEmpty) {
|
||||
return _buildEmptyState(
|
||||
icon: Icons.playlist_play,
|
||||
message: 'No playlists yet',
|
||||
submessage: 'Create your first playlist to get started',
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(24),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
mainAxisSpacing: 16,
|
||||
crossAxisSpacing: 16,
|
||||
childAspectRatio: 2.5,
|
||||
),
|
||||
itemCount: playlists.length,
|
||||
itemBuilder: (context, index) {
|
||||
final playlist = playlists[index];
|
||||
return PlaylistTile(
|
||||
playlist: playlist,
|
||||
onTap: () => _openPlaylist(playlist),
|
||||
canDelete: true,
|
||||
onDelete: () => _confirmDeletePlaylist(playlist),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLikedSongsTab(List<dynamic> likedSongs) {
|
||||
if (likedSongs.isEmpty) {
|
||||
return _buildEmptyState(
|
||||
icon: Icons.favorite_border,
|
||||
message: 'No liked songs',
|
||||
submessage: 'Like songs to see them here',
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(24),
|
||||
itemCount: likedSongs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final track = likedSongs[index] as Track;
|
||||
return _buildTrackTile(track, index);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumsTab(List<dynamic> albums) {
|
||||
if (albums.isEmpty) {
|
||||
return _buildEmptyState(
|
||||
icon: Icons.album,
|
||||
message: 'No saved albums',
|
||||
submessage: 'Save albums to see them here',
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(24),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
mainAxisSpacing: 16,
|
||||
crossAxisSpacing: 16,
|
||||
childAspectRatio: 1,
|
||||
),
|
||||
itemCount: albums.length,
|
||||
itemBuilder: (context, index) {
|
||||
final album = albums[index] as Album;
|
||||
return _buildAlbumCard(album);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArtistsTab(List<dynamic> artists) {
|
||||
if (artists.isEmpty) {
|
||||
return _buildEmptyState(
|
||||
icon: Icons.person,
|
||||
message: 'No followed artists',
|
||||
submessage: 'Follow artists to see them here',
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(24),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
mainAxisSpacing: 16,
|
||||
crossAxisSpacing: 16,
|
||||
childAspectRatio: 1,
|
||||
),
|
||||
itemCount: artists.length,
|
||||
itemBuilder: (context, index) {
|
||||
final artist = artists[index] as Artist;
|
||||
return _buildArtistCard(artist);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackTile(Track track, int index) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppColors.cyan.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
track.title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
track.artist?.name ?? 'Unknown Artist',
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
trailing: Text(
|
||||
track.formattedDuration,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
// TODO: Play track
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumCard(Album album) {
|
||||
return GestureDetector(
|
||||
onTap: () => _openAlbum(album),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.rose.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.accentGradient,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
child: album.imageUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
child: Image.network(
|
||||
album.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.album,
|
||||
size: 64,
|
||||
color: AppColors.muted,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.album,
|
||||
size: 64,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
album.title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (album.artist != null)
|
||||
Text(
|
||||
album.artist!.name,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArtistCard(Artist artist) {
|
||||
return GestureDetector(
|
||||
onTap: () => _openArtist(artist),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.violet.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: AppColors.primaryGradient,
|
||||
),
|
||||
child: artist.imageUrl != null
|
||||
? ClipOval(
|
||||
child: Image.network(
|
||||
artist.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.person,
|
||||
size: 48,
|
||||
color: AppColors.muted,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.person,
|
||||
size: 48,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
artist.name,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState({
|
||||
required IconData icon,
|
||||
required String message,
|
||||
required String submessage,
|
||||
}) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 64,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
color: AppColors.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
submessage,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(String error) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: AppColors.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Something went wrong',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
ref.read(libraryProvider.notifier).refresh();
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.cyan,
|
||||
foregroundColor: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openPlaylist(dynamic playlist) {
|
||||
// TODO: Navigate to playlist details
|
||||
print('Opening playlist: ${playlist.name}');
|
||||
}
|
||||
|
||||
void _confirmDeletePlaylist(dynamic playlist) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.surfaceVariant,
|
||||
title: const Text(
|
||||
'Delete Playlist?',
|
||||
style: TextStyle(color: AppColors.onSurface),
|
||||
),
|
||||
content: Text(
|
||||
'Are you sure you want to delete "${playlist.name}"? This action cannot be undone.',
|
||||
style: const TextStyle(color: AppColors.muted),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text(
|
||||
'Cancel',
|
||||
style: TextStyle(color: AppColors.muted),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
ref.read(libraryProvider.notifier).deletePlaylist(playlist.id);
|
||||
},
|
||||
child: const Text(
|
||||
'Delete',
|
||||
style: TextStyle(color: AppColors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openAlbum(Album album) {
|
||||
// TODO: Navigate to album details
|
||||
print('Opening album: ${album.title}');
|
||||
}
|
||||
|
||||
void _openArtist(Artist artist) {
|
||||
// TODO: Navigate to artist details
|
||||
print('Opening artist: ${artist.name}');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
/// Library Page - Mobile Layout
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../providers/library_provider.dart';
|
||||
import '../../widgets/library/playlist_tile.dart';
|
||||
import '../../../domain/entities/track.dart';
|
||||
import '../../../domain/entities/album.dart';
|
||||
import '../../../domain/entities/artist.dart';
|
||||
|
||||
class LibraryMobilePage extends ConsumerStatefulWidget {
|
||||
const LibraryMobilePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LibraryMobilePage> createState() =>
|
||||
_LibraryMobilePageState();
|
||||
}
|
||||
|
||||
class _LibraryMobilePageState extends ConsumerState<LibraryMobilePage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 4, vsync: this);
|
||||
// Load library on init
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(libraryProvider.notifier).loadLibrary();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final libraryState = ref.watch(libraryProvider);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Header with title
|
||||
_buildHeader(libraryState),
|
||||
|
||||
// Tab bar
|
||||
_buildTabBar(),
|
||||
|
||||
// Content based on selected tab
|
||||
Expanded(
|
||||
child: _buildContent(libraryState),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(dynamic libraryState) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant.withOpacity(0.5),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.library_music,
|
||||
color: AppColors.cyan,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Your Library',
|
||||
style: TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (libraryState.totalItems > 0)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, color: AppColors.cyan),
|
||||
onPressed: () {
|
||||
ref.read(libraryProvider.notifier).refresh();
|
||||
},
|
||||
tooltip: 'Refresh',
|
||||
iconSize: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabBar() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
indicatorColor: AppColors.cyan,
|
||||
labelColor: AppColors.cyan,
|
||||
unselectedLabelColor: AppColors.muted,
|
||||
labelStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
tabs: const [
|
||||
Tab(text: 'Playlists'),
|
||||
Tab(text: 'Songs'),
|
||||
Tab(text: 'Albums'),
|
||||
Tab(text: 'Artists'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(dynamic libraryState) {
|
||||
if (libraryState.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.cyan),
|
||||
);
|
||||
}
|
||||
|
||||
if (libraryState.error != null) {
|
||||
return _buildErrorState(libraryState.error ?? 'Unknown error');
|
||||
}
|
||||
|
||||
return TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildPlaylistsTab(libraryState.playlists),
|
||||
_buildLikedSongsTab(libraryState.likedSongs),
|
||||
_buildAlbumsTab(libraryState.savedAlbums),
|
||||
_buildArtistsTab(libraryState.followedArtists),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaylistsTab(List<dynamic> playlists) {
|
||||
if (playlists.isEmpty) {
|
||||
return _buildEmptyState(
|
||||
icon: Icons.playlist_play,
|
||||
message: 'No playlists yet',
|
||||
submessage: 'Create your first playlist to get started',
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: playlists.length,
|
||||
itemBuilder: (context, index) {
|
||||
final playlist = playlists[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: PlaylistTile(
|
||||
playlist: playlist,
|
||||
onTap: () => _openPlaylist(playlist),
|
||||
canDelete: true,
|
||||
onDelete: () => _confirmDeletePlaylist(playlist),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLikedSongsTab(List<dynamic> likedSongs) {
|
||||
if (likedSongs.isEmpty) {
|
||||
return _buildEmptyState(
|
||||
icon: Icons.favorite_border,
|
||||
message: 'No liked songs',
|
||||
submessage: 'Like songs to see them here',
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: likedSongs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final track = likedSongs[index] as Track;
|
||||
return _buildTrackTile(track, index);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumsTab(List<dynamic> albums) {
|
||||
if (albums.isEmpty) {
|
||||
return _buildEmptyState(
|
||||
icon: Icons.album,
|
||||
message: 'No saved albums',
|
||||
submessage: 'Save albums to see them here',
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 1,
|
||||
),
|
||||
itemCount: albums.length,
|
||||
itemBuilder: (context, index) {
|
||||
final album = albums[index] as Album;
|
||||
return _buildAlbumCard(album);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArtistsTab(List<dynamic> artists) {
|
||||
if (artists.isEmpty) {
|
||||
return _buildEmptyState(
|
||||
icon: Icons.person,
|
||||
message: 'No followed artists',
|
||||
submessage: 'Follow artists to see them here',
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 1,
|
||||
),
|
||||
itemCount: artists.length,
|
||||
itemBuilder: (context, index) {
|
||||
final artist = artists[index] as Artist;
|
||||
return _buildArtistCard(artist);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackTile(Track track, int index) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppColors.cyan.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
track.title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
track.artist?.name ?? 'Unknown Artist',
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
trailing: Text(
|
||||
track.formattedDuration,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
// TODO: Play track
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumCard(Album album) {
|
||||
return GestureDetector(
|
||||
onTap: () => _openAlbum(album),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.rose.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.accentGradient,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
child: album.imageUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
child: Image.network(
|
||||
album.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.album,
|
||||
size: 48,
|
||||
color: AppColors.muted,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.album,
|
||||
size: 48,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
album.title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (album.artist != null)
|
||||
Text(
|
||||
album.artist!.name,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 11,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArtistCard(Artist artist) {
|
||||
return GestureDetector(
|
||||
onTap: () => _openArtist(artist),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.violet.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: AppColors.primaryGradient,
|
||||
),
|
||||
child: artist.imageUrl != null
|
||||
? ClipOval(
|
||||
child: Image.network(
|
||||
artist.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.person,
|
||||
size: 36,
|
||||
color: AppColors.muted,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.person,
|
||||
size: 36,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
artist.name,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState({
|
||||
required IconData icon,
|
||||
required String message,
|
||||
required String submessage,
|
||||
}) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 48,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
submessage,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(String error) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: AppColors.error,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Something went wrong',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
error,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
ref.read(libraryProvider.notifier).refresh();
|
||||
},
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: const Text('Retry'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.cyan,
|
||||
foregroundColor: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openPlaylist(dynamic playlist) {
|
||||
// TODO: Navigate to playlist details
|
||||
print('Opening playlist: ${playlist.name}');
|
||||
}
|
||||
|
||||
void _confirmDeletePlaylist(dynamic playlist) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.surfaceVariant,
|
||||
title: const Text(
|
||||
'Delete Playlist?',
|
||||
style: TextStyle(color: AppColors.onSurface),
|
||||
),
|
||||
content: Text(
|
||||
'Are you sure you want to delete "${playlist.name}"? This action cannot be undone.',
|
||||
style: const TextStyle(color: AppColors.muted),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text(
|
||||
'Cancel',
|
||||
style: TextStyle(color: AppColors.muted),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
ref.read(libraryProvider.notifier).deletePlaylist(playlist.id);
|
||||
},
|
||||
child: const Text(
|
||||
'Delete',
|
||||
style: TextStyle(color: AppColors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openAlbum(Album album) {
|
||||
// TODO: Navigate to album details
|
||||
print('Opening album: ${album.title}');
|
||||
}
|
||||
|
||||
void _openArtist(Artist artist) {
|
||||
// TODO: Navigate to artist details
|
||||
print('Opening artist: ${artist.name}');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/// Library Page - Adaptive layout
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'library_desktop_page.dart';
|
||||
import 'library_mobile_page.dart';
|
||||
|
||||
class LibraryPage extends StatelessWidget {
|
||||
const LibraryPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth >= 800) {
|
||||
return const LibraryDesktopPage();
|
||||
} else {
|
||||
return const LibraryMobilePage();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/colors.dart';
|
||||
|
||||
/// Mobile Home Page
|
||||
class MobileHomePage extends StatelessWidget {
|
||||
const MobileHomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Header
|
||||
SliverAppBar(
|
||||
expandedHeight: 180,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
title: const Text(
|
||||
'Good Evening',
|
||||
style: TextStyle(
|
||||
color: AppColors.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
background: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppColors.surface,
|
||||
AppColors.surfaceVariant,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content sections
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Quick picks grid
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 2.5,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: 6,
|
||||
itemBuilder: (context, index) {
|
||||
return const _QuickPickCard();
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Recently played
|
||||
const _SectionTitle(title: 'Recently Played'),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 160,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: 10,
|
||||
itemBuilder: (context, index) {
|
||||
return const _AlbumCard();
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Made for you
|
||||
const _SectionTitle(title: 'Made For You'),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 160,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: 8,
|
||||
itemBuilder: (context, index) {
|
||||
return const _PlaylistCard();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
const _SectionTitle({required this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
color: AppColors.cyan,
|
||||
fontSize: 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickPickCard extends StatelessWidget {
|
||||
const _QuickPickCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColors.surface,
|
||||
AppColors.surfaceVariant,
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppColors.cyan.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(7),
|
||||
bottomLeft: Radius.circular(7),
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.music_note,
|
||||
color: AppColors.onBackground,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Playlist',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Description',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumCard extends StatelessWidget {
|
||||
const _AlbumCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 120,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Album art
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.accentGradient,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.album,
|
||||
size: 48,
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// Album info
|
||||
const Text(
|
||||
'Album Name',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const Text(
|
||||
'Artist',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaylistCard extends StatelessWidget {
|
||||
const _PlaylistCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 120,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Playlist art
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.fullGradient,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.playlist_play,
|
||||
size: 48,
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// Playlist info
|
||||
const Text(
|
||||
'Playlist',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const Text(
|
||||
'Description',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/// Pages Export
|
||||
library;
|
||||
|
||||
export 'desktop/home_page.dart';
|
||||
export 'mobile/mobile_home_page.dart';
|
||||
export 'auth/login_page.dart';
|
||||
export 'search/search_page.dart';
|
||||
export 'library/library_page.dart';
|
||||
@@ -0,0 +1,662 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../../domain/entities/track.dart';
|
||||
import '../../providers/music_provider.dart';
|
||||
import '../../widgets/player/queue_track_tile.dart';
|
||||
|
||||
/// Queue View Page
|
||||
///
|
||||
/// Complete queue management interface with:
|
||||
/// - Now Playing section (top)
|
||||
/// - Queue list section (bottom)
|
||||
/// - Swipe to remove
|
||||
/// - Drag to reorder
|
||||
/// - Clear queue functionality
|
||||
class QueueViewPage extends ConsumerStatefulWidget {
|
||||
const QueueViewPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<QueueViewPage> createState() => _QueueViewPageState();
|
||||
}
|
||||
|
||||
class _QueueViewPageState extends ConsumerState<QueueViewPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final queueData = ref.watch(queueProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.primary,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
_buildHeader(queueData),
|
||||
|
||||
// Content
|
||||
Expanded(
|
||||
child: queueData.hasQueue
|
||||
? Column(
|
||||
children: [
|
||||
// Now Playing Section
|
||||
_buildNowPlayingSection(queueData),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Queue Section
|
||||
Expanded(
|
||||
child: _buildQueueSection(queueData),
|
||||
),
|
||||
],
|
||||
)
|
||||
: _buildEmptyQueue(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(QueueViewData queueData) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.cyan.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Back button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
color: AppColors.onSurface,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Title
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Queue',
|
||||
style: TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (queueData.hasQueue)
|
||||
Text(
|
||||
'${queueData.queueCount} tracks',
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Clear queue button
|
||||
if (queueData.hasNextTracks)
|
||||
TextButton.icon(
|
||||
onPressed: () => _showClearQueueDialog(queueData),
|
||||
icon: const Icon(
|
||||
Icons.clear_all,
|
||||
size: 18,
|
||||
color: AppColors.rouge,
|
||||
),
|
||||
label: const Text(
|
||||
'Clear',
|
||||
style: TextStyle(
|
||||
color: AppColors.rouge,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
backgroundColor: AppColors.rouge.withOpacity(0.1),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNowPlayingSection(QueueViewData queueData) {
|
||||
final currentTrack = queueData.currentTrack;
|
||||
|
||||
if (currentTrack == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppColors.surfaceVariant,
|
||||
AppColors.surfaceElevated,
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppColors.cyan.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.cyan.withOpacity(0.1),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Section label
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cyan.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text(
|
||||
'NOW PLAYING',
|
||||
style: TextStyle(
|
||||
color: AppColors.cyan,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_buildPlayingIndicator(queueData.isPlaying),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Track info
|
||||
Row(
|
||||
children: [
|
||||
// Album art
|
||||
_buildLargeAlbumArt(currentTrack),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Track details
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
currentTrack.title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
currentTrack.artist?.name ?? 'Unknown Artist',
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_buildControlButton(
|
||||
icon: Icons.skip_previous,
|
||||
onTap: () => _playPrevious(),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_buildPlayPauseButton(queueData.isPlaying),
|
||||
const SizedBox(width: 12),
|
||||
_buildControlButton(
|
||||
icon: Icons.skip_next,
|
||||
onTap: () => _playNext(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLargeAlbumArt(Track track) {
|
||||
return Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.accentGradient,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: AppColors.violetGlow,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: track.imageUrl != null
|
||||
? Image.network(
|
||||
track.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.music_note,
|
||||
color: AppColors.onBackground,
|
||||
size: 40,
|
||||
);
|
||||
},
|
||||
)
|
||||
: const Icon(
|
||||
Icons.music_note,
|
||||
color: AppColors.onBackground,
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlayingIndicator(bool isPlaying) {
|
||||
if (!isPlaying) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.vert,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.vert,
|
||||
blurRadius: 8,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'Playing',
|
||||
style: TextStyle(
|
||||
color: AppColors.vert,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControlButton({
|
||||
required IconData icon,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceElevated,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: AppColors.onSurface,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlayPauseButton(bool isPlaying) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () => _togglePlayPause(isPlaying),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: AppColors.cyanGlow,
|
||||
),
|
||||
child: Icon(
|
||||
isPlaying ? Icons.pause : Icons.play_arrow,
|
||||
color: AppColors.primary,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQueueSection(QueueViewData queueData) {
|
||||
if (!queueData.hasNextTracks) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Queue header
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.violet.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text(
|
||||
'NEXT UP',
|
||||
style: TextStyle(
|
||||
color: AppColors.violet,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${queueData.nextTracks.length} tracks',
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Queue list
|
||||
Expanded(
|
||||
child: ReorderableListView.builder(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
itemCount: queueData.nextTracks.length,
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
_reorderQueue(oldIndex, newIndex, queueData);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final track = queueData.nextTracks[index];
|
||||
final actualIndex = queueData.currentIndex + 1 + index;
|
||||
|
||||
return Dismissible(
|
||||
key: Key('queue_${track.id}_$index'),
|
||||
direction: DismissDirection.endToStart,
|
||||
onDismissed: (direction) {
|
||||
_removeFromQueue(actualIndex);
|
||||
},
|
||||
background: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.rouge,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: const Icon(
|
||||
Icons.delete,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
child: QueueTrackTile(
|
||||
key: Key('queue_${track.id}_$index'),
|
||||
track: track,
|
||||
index: index,
|
||||
onTap: () => _playTrack(actualIndex),
|
||||
onRemove: () => _removeFromQueue(actualIndex),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyQueue() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppColors.cyan.withOpacity(0.2),
|
||||
AppColors.violet.withOpacity(0.2),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.queue_music,
|
||||
color: AppColors.muted,
|
||||
size: 60,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Queue is empty',
|
||||
style: TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Add tracks to build your queue',
|
||||
style: TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _togglePlayPause(bool isPlaying) {
|
||||
final notifier = ref.read(playerProvider.notifier);
|
||||
if (isPlaying) {
|
||||
notifier.pause();
|
||||
} else {
|
||||
notifier.play();
|
||||
}
|
||||
}
|
||||
|
||||
void _playNext() {
|
||||
ref.read(playerProvider.notifier).next();
|
||||
}
|
||||
|
||||
void _playPrevious() {
|
||||
ref.read(playerProvider.notifier).previous();
|
||||
}
|
||||
|
||||
void _playTrack(int index) {
|
||||
final queueData = ref.read(queueProvider);
|
||||
final track = queueData.queue[index];
|
||||
|
||||
ref.read(playerProvider.notifier).loadTrack(track).then((_) {
|
||||
ref.read(playerProvider.notifier).play();
|
||||
});
|
||||
}
|
||||
|
||||
void _removeFromQueue(int index) {
|
||||
ref.read(playerProvider.notifier).removeFromQueue(index);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Removed from queue'),
|
||||
backgroundColor: AppColors.surfaceElevated,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _reorderQueue(int oldIndex, int newIndex, QueueViewData queueData) {
|
||||
if (oldIndex == newIndex) return;
|
||||
|
||||
// Adjust for the actual queue position
|
||||
final actualOldIndex = queueData.currentIndex + 1 + oldIndex;
|
||||
int actualNewIndex = queueData.currentIndex + 1 + newIndex;
|
||||
|
||||
if (newIndex > oldIndex) {
|
||||
actualNewIndex--;
|
||||
}
|
||||
|
||||
final notifier = ref.read(playerProvider.notifier);
|
||||
final queue = List<Track>.from(queueData.queue);
|
||||
final item = queue.removeAt(actualOldIndex);
|
||||
queue.insert(actualNewIndex, item);
|
||||
|
||||
notifier.setQueue(queue, startIndex: queueData.currentIndex);
|
||||
}
|
||||
|
||||
void _showClearQueueDialog(QueueViewData queueData) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
title: const Text(
|
||||
'Clear Queue?',
|
||||
style: TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
content: Text(
|
||||
'Remove all ${queueData.nextTracks.length} upcoming tracks from the queue?',
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text(
|
||||
'Cancel',
|
||||
style: TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_clearQueue(queueData);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge.withOpacity(0.2),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Clear',
|
||||
style: TextStyle(
|
||||
color: AppColors.rouge,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _clearQueue(QueueViewData queueData) {
|
||||
if (queueData.currentTrack == null) return;
|
||||
|
||||
// Keep only the current track
|
||||
ref.read(playerProvider.notifier).setQueue(
|
||||
[queueData.currentTrack!],
|
||||
startIndex: 0,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Queue cleared'),
|
||||
backgroundColor: AppColors.surfaceElevated,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
/// Playlist Details Page - Desktop layout
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../domain/entities/playlist.dart';
|
||||
import '../../../../domain/entities/track.dart';
|
||||
import '../../../../core/theme/colors.dart';
|
||||
import '../../../providers/playlist_provider.dart';
|
||||
import '../../../providers/music_provider.dart';
|
||||
import '../../../providers/auth_provider.dart';
|
||||
import '../../widgets/playlist/playlist_track_tile.dart';
|
||||
import '../../widgets/common/cached_network_image_with_fallback.dart';
|
||||
|
||||
class PlaylistDesktopPage extends ConsumerStatefulWidget {
|
||||
final String playlistId;
|
||||
|
||||
const PlaylistDesktopPage({
|
||||
required this.playlistId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<PlaylistDesktopPage> createState() => _PlaylistDesktopPageState();
|
||||
}
|
||||
|
||||
class _PlaylistDesktopPageState extends ConsumerState<PlaylistDesktopPage> {
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
final TextEditingController _descriptionController = TextEditingController();
|
||||
bool _isEditing = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startEditing(Playlist playlist) {
|
||||
_nameController.text = playlist.name;
|
||||
_descriptionController.text = playlist.description ?? '';
|
||||
setState(() => _isEditing = true);
|
||||
}
|
||||
|
||||
Future<void> _saveEdit() async {
|
||||
final notifier = ref.read(playlistProvider(widget.playlistId).notifier);
|
||||
await notifier.updatePlaylist(
|
||||
name: _nameController.text.trim(),
|
||||
description: _descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: _descriptionController.text.trim(),
|
||||
);
|
||||
setState(() => _isEditing = false);
|
||||
}
|
||||
|
||||
void _cancelEdit() {
|
||||
setState(() => _isEditing = false);
|
||||
}
|
||||
|
||||
Future<void> _showDeleteDialog(Playlist playlist) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.surfaceElevated,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: AppColors.rose.withOpacity(0.3)),
|
||||
),
|
||||
title: Text(
|
||||
'Delete Playlist',
|
||||
style: TextStyle(color: AppColors.rose, fontSize: 20),
|
||||
),
|
||||
content: Text(
|
||||
'Are you sure you want to delete "${playlist.name}"? This action cannot be undone.',
|
||||
style: TextStyle(color: AppColors.onBackground),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: TextStyle(color: AppColors.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rose,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
final notifier = ref.read(playlistProvider(widget.playlistId).notifier);
|
||||
await notifier.deletePlaylist();
|
||||
if (mounted) Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final playlistState = ref.watch(playlistProvider(widget.playlistId));
|
||||
final authState = ref.watch(authProvider);
|
||||
final playerNotifier = ref.read(playerProvider.notifier);
|
||||
|
||||
final playlist = playlistState.playlist;
|
||||
final tracks = playlistState.tracks;
|
||||
final isOwner = authState.user?.id == playlist?.userId;
|
||||
|
||||
if (playlistState.isLoading && playlist == null) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.primary,
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(color: AppColors.cyan),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (playlistState.error != null && playlist == null) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.primary,
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: AppColors.rose, size: 64),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
playlistState.error!,
|
||||
style: const TextStyle(color: AppColors.rose),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (playlist == null) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.primary,
|
||||
body: Center(
|
||||
child: Text(
|
||||
'Playlist not found',
|
||||
style: TextStyle(color: AppColors.onBackground, fontSize: 20),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.primary,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Header with gradient background
|
||||
SliverAppBar(
|
||||
expandedHeight: 300,
|
||||
pinned: true,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
AppColors.violet.withOpacity(0.3),
|
||||
AppColors.primary,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Row(
|
||||
children: [
|
||||
// Cover image
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
width: 200,
|
||||
height: 200,
|
||||
child: CachedNetworkImageWithFallback(
|
||||
imageUrl: playlist.imageUrl,
|
||||
fallbackIcon: Icons.playlist_play,
|
||||
progressColor: AppColors.cyan,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
|
||||
// Playlist info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (_isEditing) ...[
|
||||
// Edit mode
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onBackground,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: AppColors.cyan,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _descriptionController,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 3,
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: AppColors.cyan,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
hintText: 'Add a description...',
|
||||
hintStyle: TextStyle(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: _saveEdit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.cyan,
|
||||
foregroundColor: AppColors.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
child: const Text('Save'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
TextButton(
|
||||
onPressed: _cancelEdit,
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: TextStyle(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
// View mode
|
||||
Text(
|
||||
playlist.name,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onBackground,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (playlist.description != null) ...[
|
||||
Text(
|
||||
playlist.description!,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
playlist.isPublic
|
||||
? Icons.public
|
||||
: Icons.lock,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${playlist.trackCount} songs',
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
playlistState.formattedTotalDuration,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Action buttons
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Row(
|
||||
children: [
|
||||
// Play button
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: AppColors.primaryGradient,
|
||||
boxShadow: AppColors.cyanGlow,
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.play_arrow, size: 32),
|
||||
color: AppColors.primary,
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(playlistProvider(widget.playlistId).notifier)
|
||||
.playPlaylist(playerNotifier);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Shuffle button
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.surfaceElevated,
|
||||
border: Border.all(
|
||||
color: AppColors.cyan.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.shuffle),
|
||||
color: AppColors.cyan,
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(playlistProvider(widget.playlistId).notifier)
|
||||
.shufflePlaylist(playerNotifier);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Download button
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.surfaceElevated,
|
||||
border: Border.all(
|
||||
color: AppColors.cyan.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.download),
|
||||
color: AppColors.cyan,
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Download feature coming soon',
|
||||
style: TextStyle(color: AppColors.primary),
|
||||
),
|
||||
backgroundColor: AppColors.cyan,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Edit button (for owner)
|
||||
if (isOwner && !_isEditing) ...[
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _startEditing(playlist),
|
||||
icon: const Icon(Icons.edit),
|
||||
label: const Text('Edit'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.surfaceElevated,
|
||||
foregroundColor: AppColors.cyan,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete, color: AppColors.rose),
|
||||
onPressed: () => _showDeleteDialog(playlist),
|
||||
tooltip: 'Delete playlist',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Tracks list
|
||||
if (tracks.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.music_note,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
size: 64,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No tracks in this playlist',
|
||||
style: TextStyle(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final track = tracks[index];
|
||||
return PlaylistTrackTile(
|
||||
track: track,
|
||||
position: index,
|
||||
isOwner: isOwner,
|
||||
onTap: () {
|
||||
// Play this track
|
||||
playerNotifier.setQueue(tracks, startIndex: index);
|
||||
},
|
||||
onRemove: isOwner
|
||||
? () {
|
||||
ref
|
||||
.read(playlistProvider(widget.playlistId).notifier)
|
||||
.removeTrack(track.id);
|
||||
}
|
||||
: null,
|
||||
onAddToQueue: () {
|
||||
playerNotifier.addToQueue(track);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Added to queue',
|
||||
style: TextStyle(color: AppColors.primary),
|
||||
),
|
||||
backgroundColor: AppColors.vert,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
childCount: tracks.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Loading indicator for reordering
|
||||
if (playlistState.isReordering)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(color: AppColors.cyan),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/// Playlist Details Page - Adaptive layout
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'playlist_desktop_page.dart';
|
||||
import 'playlist_mobile_page.dart';
|
||||
|
||||
class PlaylistDetailsPage extends StatelessWidget {
|
||||
final String playlistId;
|
||||
|
||||
const PlaylistDetailsPage({
|
||||
required this.playlistId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth >= 800) {
|
||||
return PlaylistDesktopPage(playlistId: playlistId);
|
||||
} else {
|
||||
return PlaylistMobilePage(playlistId: playlistId);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
/// Playlist Details Page - Mobile layout
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../domain/entities/playlist.dart';
|
||||
import '../../../../core/theme/colors.dart';
|
||||
import '../../../providers/playlist_provider.dart';
|
||||
import '../../../providers/music_provider.dart';
|
||||
import '../../../providers/auth_provider.dart';
|
||||
import '../../widgets/playlist/playlist_track_tile.dart';
|
||||
import '../../widgets/common/cached_network_image_with_fallback.dart';
|
||||
|
||||
class PlaylistMobilePage extends ConsumerStatefulWidget {
|
||||
final String playlistId;
|
||||
|
||||
const PlaylistMobilePage({
|
||||
required this.playlistId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<PlaylistMobilePage> createState() => _PlaylistMobilePageState();
|
||||
}
|
||||
|
||||
class _PlaylistMobilePageState extends ConsumerState<PlaylistMobilePage> {
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
final TextEditingController _descriptionController = TextEditingController();
|
||||
bool _isEditing = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startEditing(Playlist playlist) {
|
||||
_nameController.text = playlist.name;
|
||||
_descriptionController.text = playlist.description ?? '';
|
||||
setState(() => _isEditing = true);
|
||||
}
|
||||
|
||||
Future<void> _saveEdit() async {
|
||||
final notifier = ref.read(playlistProvider(widget.playlistId).notifier);
|
||||
await notifier.updatePlaylist(
|
||||
name: _nameController.text.trim(),
|
||||
description: _descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: _descriptionController.text.trim(),
|
||||
);
|
||||
setState(() => _isEditing = false);
|
||||
}
|
||||
|
||||
void _cancelEdit() {
|
||||
setState(() => _isEditing = false);
|
||||
}
|
||||
|
||||
Future<void> _showDeleteDialog(Playlist playlist) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.surfaceElevated,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: AppColors.rose.withOpacity(0.3)),
|
||||
),
|
||||
title: Text(
|
||||
'Delete Playlist',
|
||||
style: TextStyle(color: AppColors.rose, fontSize: 20),
|
||||
),
|
||||
content: Text(
|
||||
'Are you sure you want to delete "${playlist.name}"?',
|
||||
style: TextStyle(color: AppColors.onBackground),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: TextStyle(color: AppColors.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rose,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
final notifier = ref.read(playlistProvider(widget.playlistId).notifier);
|
||||
await notifier.deletePlaylist();
|
||||
if (mounted) Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final playlistState = ref.watch(playlistProvider(widget.playlistId));
|
||||
final authState = ref.watch(authProvider);
|
||||
final playerNotifier = ref.read(playerProvider.notifier);
|
||||
|
||||
final playlist = playlistState.playlist;
|
||||
final tracks = playlistState.tracks;
|
||||
final isOwner = authState.user?.id == playlist?.userId;
|
||||
|
||||
if (playlistState.isLoading && playlist == null) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.primary,
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(color: AppColors.cyan),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (playlistState.error != null && playlist == null) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.primary,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.primary,
|
||||
elevation: 0,
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: AppColors.rose, size: 64),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
playlistState.error!,
|
||||
style: const TextStyle(color: AppColors.rose),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (playlist == null) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.primary,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.primary,
|
||||
elevation: 0,
|
||||
),
|
||||
body: Center(
|
||||
child: Text(
|
||||
'Playlist not found',
|
||||
style: TextStyle(color: AppColors.onBackground, fontSize: 18),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.primary,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// App bar
|
||||
SliverAppBar(
|
||||
backgroundColor: AppColors.primary,
|
||||
expandedHeight: 320,
|
||||
pinned: true,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
AppColors.violet.withOpacity(0.3),
|
||||
AppColors.primary,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: kToolbarHeight * 2),
|
||||
// Cover image
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
width: 200,
|
||||
height: 200,
|
||||
child: CachedNetworkImageWithFallback(
|
||||
imageUrl: playlist.imageUrl,
|
||||
fallbackIcon: Icons.playlist_play,
|
||||
progressColor: AppColors.cyan,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Playlist info and actions
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title and edit
|
||||
if (_isEditing) ...[
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onBackground,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: AppColors.cyan,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _descriptionController,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 3,
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: AppColors.cyan,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
hintText: 'Add a description...',
|
||||
hintStyle: TextStyle(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: _saveEdit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.cyan,
|
||||
foregroundColor: AppColors.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
child: const Text('Save'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
TextButton(
|
||||
onPressed: _cancelEdit,
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: TextStyle(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
Text(
|
||||
playlist.name,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onBackground,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (playlist.description != null) ...[
|
||||
Text(
|
||||
playlist.description!,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
playlist.isPublic ? Icons.public : Icons.lock,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${playlist.trackCount} songs • ${playlistState.formattedTotalDuration}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
children: [
|
||||
// Play button
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: AppColors.cyanGlow,
|
||||
),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(
|
||||
playlistProvider(widget.playlistId).notifier)
|
||||
.playPlaylist(playerNotifier);
|
||||
},
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: const Text('Play'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppColors.primary,
|
||||
shadowColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Shuffle button
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.surfaceElevated,
|
||||
border: Border.all(
|
||||
color: AppColors.cyan.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.shuffle),
|
||||
color: AppColors.cyan,
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(
|
||||
playlistProvider(widget.playlistId).notifier)
|
||||
.shufflePlaylist(playerNotifier);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Download button
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.surfaceElevated,
|
||||
border: Border.all(
|
||||
color: AppColors.cyan.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.download),
|
||||
color: AppColors.cyan,
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Download feature coming soon',
|
||||
style: TextStyle(color: AppColors.primary),
|
||||
),
|
||||
backgroundColor: AppColors.cyan,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Edit button (for owner)
|
||||
if (isOwner && !_isEditing) ...[
|
||||
IconButton(
|
||||
icon: Icon(Icons.edit, color: AppColors.cyan),
|
||||
onPressed: () => _startEditing(playlist),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete, color: AppColors.rose),
|
||||
onPressed: () => _showDeleteDialog(playlist),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Divider
|
||||
Container(
|
||||
height: 1,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
AppColors.cyan.withOpacity(0.3),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Tracks list
|
||||
if (tracks.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.music_note,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
size: 64,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No tracks in this playlist',
|
||||
style: TextStyle(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverReorderableList(
|
||||
delegate: ReorderableChildBuilderDelegate(
|
||||
childCount: tracks.length,
|
||||
(context, index) {
|
||||
final track = tracks[index];
|
||||
return Dismissible(
|
||||
key: ValueKey(track.id),
|
||||
direction: isOwner
|
||||
? DismissDirection.endToStart
|
||||
: DismissDirection.none,
|
||||
onDismissed: (_) {
|
||||
if (isOwner) {
|
||||
ref
|
||||
.read(playlistProvider(widget.playlistId).notifier)
|
||||
.removeTrack(track.id);
|
||||
}
|
||||
},
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.rose.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.delete, color: AppColors.rose),
|
||||
),
|
||||
child: PlaylistTrackTile(
|
||||
track: track,
|
||||
position: index,
|
||||
isOwner: isOwner,
|
||||
onTap: () {
|
||||
playerNotifier.setQueue(tracks, startIndex: index);
|
||||
},
|
||||
onRemove: isOwner
|
||||
? () {
|
||||
ref
|
||||
.read(playlistProvider(widget.playlistId).notifier)
|
||||
.removeTrack(track.id);
|
||||
}
|
||||
: null,
|
||||
onAddToQueue: () {
|
||||
playerNotifier.addToQueue(track);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Added to queue',
|
||||
style: TextStyle(color: AppColors.primary),
|
||||
),
|
||||
backgroundColor: AppColors.vert,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
onReorder: isOwner
|
||||
? (oldIndex, newIndex) {
|
||||
ref
|
||||
.read(playlistProvider(widget.playlistId).notifier)
|
||||
.reorderTracks(oldIndex, newIndex);
|
||||
}
|
||||
: (_, __) {},
|
||||
),
|
||||
|
||||
// Loading indicator for reordering
|
||||
if (playlistState.isReordering)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(color: AppColors.cyan),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/// Search Page - Desktop Layout
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../providers/search_provider.dart';
|
||||
import '../../providers/music_provider.dart';
|
||||
import '../../../domain/entities/track.dart';
|
||||
import '../../../domain/entities/artist.dart';
|
||||
import '../../../domain/entities/album.dart';
|
||||
import '../../widgets/search/search_track_card.dart';
|
||||
import '../../widgets/search/search_artist_card.dart';
|
||||
import '../../widgets/search/search_album_card.dart';
|
||||
|
||||
class SearchDesktopPage extends ConsumerStatefulWidget {
|
||||
const SearchDesktopPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SearchDesktopPage> createState() => _SearchDesktopPageState();
|
||||
}
|
||||
|
||||
class _SearchDesktopPageState extends ConsumerState<SearchDesktopPage> {
|
||||
final _controller = TextEditingController();
|
||||
final _focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// Search bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: _buildSearchBar(),
|
||||
),
|
||||
// Results
|
||||
Expanded(
|
||||
child: _buildResults(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchBar() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(
|
||||
color: AppColors.cyan.withOpacity(0.2),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
style: const TextStyle(color: AppColors.onSurface, fontSize: 16),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'What do you want to listen to?',
|
||||
hintStyle: const TextStyle(color: AppColors.muted),
|
||||
prefixIcon: const Icon(Icons.search, color: AppColors.cyan),
|
||||
suffixIcon: _controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear, color: AppColors.muted),
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
ref.read(searchProvider.notifier).clear();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: InputBorder.none,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
),
|
||||
onChanged: (value) {
|
||||
ref.read(searchProvider.notifier).search(value);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResults() {
|
||||
final searchState = ref.watch(searchProvider);
|
||||
|
||||
if (searchState.query.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
if (searchState.isSearching) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.cyan),
|
||||
);
|
||||
}
|
||||
|
||||
if (searchState.error != null) {
|
||||
return _buildErrorState(searchState.error ?? 'Unknown error');
|
||||
}
|
||||
|
||||
if (searchState.totalResults == 0) {
|
||||
return _buildNoResultsState();
|
||||
}
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Tracks section
|
||||
if (searchState.tracks.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: _buildSection('Tracks', searchState.tracks),
|
||||
),
|
||||
|
||||
// Artists section
|
||||
if (searchState.artists.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: _buildSection('Artists', searchState.artists),
|
||||
),
|
||||
|
||||
// Albums section
|
||||
if (searchState.albums.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: _buildSection('Albums', searchState.albums),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection(String title, List<dynamic> items) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 12),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.cyan,
|
||||
),
|
||||
),
|
||||
),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
mainAxisSpacing: 16,
|
||||
crossAxisSpacing: 16,
|
||||
childAspectRatio: 1,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildResultCard(items[index]);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultCard(dynamic item) {
|
||||
final playerNotifier = ref.read(playerProvider.notifier);
|
||||
|
||||
if (item is Track) {
|
||||
// It's a track - play on tap
|
||||
return SearchTrackCard(
|
||||
track: item,
|
||||
onTap: () => _playTrack(item, playerNotifier),
|
||||
);
|
||||
} else if (item is Artist) {
|
||||
// It's an artist - show details (TODO: navigate)
|
||||
return SearchArtistCard(
|
||||
artist: item,
|
||||
onTap: () => _showArtistDetails(item),
|
||||
);
|
||||
} else if (item is Album) {
|
||||
// It's an album - show details (TODO: navigate)
|
||||
return SearchAlbumCard(
|
||||
album: item,
|
||||
onTap: () => _showAlbumDetails(item),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
void _playTrack(Track track, PlayerNotifier playerNotifier) {
|
||||
// Set as queue and play
|
||||
playerNotifier.setQueue([track], startIndex: 0);
|
||||
playerNotifier.loadTrack(track);
|
||||
playerNotifier.play();
|
||||
}
|
||||
|
||||
void _showArtistDetails(Artist artist) {
|
||||
// TODO: Navigate to artist details page
|
||||
print('Show artist: ${artist.name}');
|
||||
}
|
||||
|
||||
void _showAlbumDetails(Album album) {
|
||||
// TODO: Navigate to album details page
|
||||
print('Show album: ${album.title}');
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search,
|
||||
size: 64,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Search for your favorite music',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(String error) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: AppColors.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Something went wrong',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNoResultsState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.music_off,
|
||||
size: 64,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'No results found',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/// Search Page - Mobile Layout
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../providers/search_provider.dart';
|
||||
import '../../providers/music_provider.dart';
|
||||
import '../../../domain/entities/track.dart';
|
||||
import '../../../domain/entities/artist.dart';
|
||||
import '../../../domain/entities/album.dart';
|
||||
import '../../widgets/search/search_track_card.dart';
|
||||
import '../../widgets/search/search_artist_card.dart';
|
||||
import '../../widgets/search/search_album_card.dart';
|
||||
|
||||
class SearchMobilePage extends ConsumerStatefulWidget {
|
||||
const SearchMobilePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SearchMobilePage> createState() => _SearchMobilePageState();
|
||||
}
|
||||
|
||||
class _SearchMobilePageState extends ConsumerState<SearchMobilePage> {
|
||||
final _controller = TextEditingController();
|
||||
final _focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// Search bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _buildSearchBar(),
|
||||
),
|
||||
// Results (same as desktop but different layout)
|
||||
Expanded(
|
||||
child: _buildResults(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchBar() {
|
||||
// Similar to desktop but smaller
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: AppColors.cyan.withOpacity(0.2),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
onChanged: (value) {
|
||||
ref.read(searchProvider.notifier).search(value);
|
||||
setState(() {});
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search...',
|
||||
hintStyle: TextStyle(color: AppColors.muted),
|
||||
prefixIcon: Icon(Icons.search, color: AppColors.cyan),
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResults() {
|
||||
// Reuse desktop logic but with 2-column grid
|
||||
final searchState = ref.watch(searchProvider);
|
||||
|
||||
if (searchState.query.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
if (searchState.isSearching) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.cyan),
|
||||
);
|
||||
}
|
||||
|
||||
if (searchState.error != null) {
|
||||
return _buildErrorState(searchState.error ?? 'Unknown error');
|
||||
}
|
||||
|
||||
if (searchState.totalResults == 0) {
|
||||
return _buildNoResultsState();
|
||||
}
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Tracks section
|
||||
if (searchState.tracks.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: _buildSection('Tracks', searchState.tracks),
|
||||
),
|
||||
|
||||
// Artists section
|
||||
if (searchState.artists.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: _buildSection('Artists', searchState.artists),
|
||||
),
|
||||
|
||||
// Albums section
|
||||
if (searchState.albums.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: _buildSection('Albums', searchState.albums),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection(String title, List<dynamic> items) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.cyan,
|
||||
),
|
||||
),
|
||||
),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 1,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildResultCard(items[index]);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultCard(dynamic item) {
|
||||
final playerNotifier = ref.read(playerProvider.notifier);
|
||||
|
||||
if (item is Track) {
|
||||
// It's a track - play on tap
|
||||
return SearchTrackCard(
|
||||
track: item,
|
||||
onTap: () => _playTrack(item, playerNotifier),
|
||||
);
|
||||
} else if (item is Artist) {
|
||||
// It's an artist - show details (TODO: navigate)
|
||||
return SearchArtistCard(
|
||||
artist: item,
|
||||
onTap: () => _showArtistDetails(item),
|
||||
);
|
||||
} else if (item is Album) {
|
||||
// It's an album - show details (TODO: navigate)
|
||||
return SearchAlbumCard(
|
||||
album: item,
|
||||
onTap: () => _showAlbumDetails(item),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
void _playTrack(Track track, PlayerNotifier playerNotifier) {
|
||||
// Set as queue and play
|
||||
playerNotifier.setQueue([track], startIndex: 0);
|
||||
playerNotifier.loadTrack(track);
|
||||
playerNotifier.play();
|
||||
}
|
||||
|
||||
void _showArtistDetails(Artist artist) {
|
||||
// TODO: Navigate to artist details page
|
||||
print('Show artist: ${artist.name}');
|
||||
}
|
||||
|
||||
void _showAlbumDetails(Album album) {
|
||||
// TODO: Navigate to album details page
|
||||
print('Show album: ${album.title}');
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search,
|
||||
size: 48,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Search for your favorite music',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(String error) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: AppColors.error,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Something went wrong',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNoResultsState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.music_off,
|
||||
size: 48,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'No results found',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/// Search Page - Adaptive layout
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'search_desktop_page.dart';
|
||||
import 'search_mobile_page.dart';
|
||||
|
||||
class SearchPage extends StatelessWidget {
|
||||
const SearchPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth >= 800) {
|
||||
return const SearchDesktopPage();
|
||||
} else {
|
||||
return const SearchMobilePage();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
# Settings Page - Visual Preview & Features
|
||||
|
||||
## Visual Design
|
||||
|
||||
### Overall Theme
|
||||
- **Background**: Deep dark blue (#0A0E27) with neon cyan accents
|
||||
- **Cards**: Semi-transparent surfaces with cyan glow borders
|
||||
- **Typography**: Outfit font family with neon color highlights
|
||||
- **Effects**: Subtle gradients, glow shadows, smooth transitions
|
||||
|
||||
## Section Breakdown
|
||||
|
||||
### 1. Profile Section (Top)
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ ┌────┐ John Doe [PREMIUM] │
|
||||
│ │ 👤 │ john.doe@email.com │
|
||||
│ └────┘ @johndoe │
|
||||
│ │
|
||||
│ [ Edit Profile ] │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Features:
|
||||
- Circular avatar with gradient glow
|
||||
- Premium badge with violet/rose gradient
|
||||
- Display name, email, username
|
||||
- Edit Profile button (cyan outlined)
|
||||
|
||||
### 2. Audio Quality Section
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ AUDIO │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 🎵 Audio Quality │
|
||||
│ Higher quality uses more data │
|
||||
│ ──────────────────────────────────────────── │
|
||||
│ Low [96 kbps] Best for... │
|
||||
│ Medium [160 kbps] Good... │
|
||||
│ High [320 kbps] Best... ✓ │
|
||||
│ Lossless [FLAC] Requires... [🔒]│
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Features:
|
||||
- Radio-style selection
|
||||
- Bitrate badges
|
||||
- Quality descriptions
|
||||
- Premium lock on Lossless
|
||||
- Selection indicator (cyan checkmark)
|
||||
|
||||
### 3. Playback Section
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ PLAYBACK │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 🎚️ Crossfade [○] │
|
||||
│ Smooth transition between tracks │
|
||||
│ │
|
||||
│ Crossfade Duration: 5s │
|
||||
│ ━━━━━━━━●━━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ │
|
||||
│ ──────────────────────────────────────────── │
|
||||
│ ♾️ Gapless Playback [●] │
|
||||
│ No gap between tracks │
|
||||
│ │
|
||||
│ ──────────────────────────────────────────── │
|
||||
│ 🔊 Normalize Volume [○] │
|
||||
│ Set same volume for all tracks │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Features:
|
||||
- Toggle switches with cyan active color
|
||||
- Crossfade duration slider (1-12 seconds)
|
||||
- Descriptive subtitles
|
||||
- Icon indicators
|
||||
|
||||
### 4. Downloads Section
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ DOWNLOADS │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 📥 Download on Mobile Data [○] │
|
||||
│ May use extra data │
|
||||
│ │
|
||||
│ ──────────────────────────────────────────── │
|
||||
│ 🔞 Show Explicit Content [●] │
|
||||
│ Display explicit content in search │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5. Storage Section
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 💾 Storage │
|
||||
│ Cache and offline data │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ Cache Size 📁 │ │
|
||||
│ │ 245.3 MB │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [ Clear Cache ] │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Features:
|
||||
- Large cache size display (cyan)
|
||||
- Folder icon
|
||||
- Clear cache button (rose outlined)
|
||||
- Confirmation dialog
|
||||
|
||||
### 6. About Section
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ ABOUT │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ ℹ️ App Version │
|
||||
│ 1.0.0+1 │
|
||||
│ │
|
||||
│ ──────────────────────────────────────────── │
|
||||
│ 📄 Licenses │
|
||||
│ Open source licenses │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7. Logout Button
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ [ 🚪 Log Out ] │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Features:
|
||||
- Rose outlined button
|
||||
- Confirmation dialog
|
||||
- Logout icon
|
||||
|
||||
## Color Palette
|
||||
|
||||
### Primary Colors
|
||||
- **Cyan**: #00F0FF (primary accent)
|
||||
- **Violet**: #BF00FF (secondary accent)
|
||||
- **Rose**: #FF006E (error/danger)
|
||||
- **Green**: #39FF14 (success)
|
||||
|
||||
### Backgrounds
|
||||
- **Primary**: #0A0E27 (main background)
|
||||
- **Surface**: #1A1F3A (cards)
|
||||
- **Surface Variant**: #252B4A (elevated)
|
||||
|
||||
### Text Colors
|
||||
- **On Background**: #E0E6FF (primary text)
|
||||
- **On Surface**: #B0B8D4 (secondary text)
|
||||
- **Muted**: #6A7294 (disabled/hints)
|
||||
|
||||
## Interactive Elements
|
||||
|
||||
### Toggle Switches
|
||||
- Active: Cyan with glow
|
||||
- Inactive: Grey
|
||||
- Smooth animations
|
||||
|
||||
### Buttons
|
||||
- **Elevated**: Cyan gradient with glow shadow
|
||||
- **Outlined**: Cyan/rose border with transparent bg
|
||||
- **Text**: Cyan with underline effect
|
||||
|
||||
### Cards
|
||||
- 1px cyan border (15% opacity)
|
||||
- Subtle glow shadow
|
||||
- 16px border radius
|
||||
- Smooth hover effects
|
||||
|
||||
## Animations
|
||||
|
||||
### Page Transitions
|
||||
- Smooth slide-in from right
|
||||
- Fade-in for content
|
||||
- Staggered section animations
|
||||
|
||||
### Micro-interactions
|
||||
- Ripple effects on taps
|
||||
- Scale animations on buttons
|
||||
- Color transitions on toggles
|
||||
- Slide-up dialogs
|
||||
|
||||
## Dialogs
|
||||
|
||||
### Edit Profile Dialog
|
||||
- Centered, rounded corners
|
||||
- Avatar with camera overlay
|
||||
- Text input with cyan border
|
||||
- Save/Cancel buttons
|
||||
|
||||
### Clear Cache Dialog
|
||||
- Warning icon (rose)
|
||||
- Confirmation text
|
||||
- Cancel/Clear buttons
|
||||
|
||||
### Logout Dialog
|
||||
- Logout icon (rose)
|
||||
- Confirmation message
|
||||
- Cancel/Logout buttons
|
||||
|
||||
## Snackbar Notifications
|
||||
|
||||
### Success
|
||||
- Green background
|
||||
- White text
|
||||
- Checkmark icon
|
||||
- 3 second duration
|
||||
|
||||
### Error
|
||||
- Red background
|
||||
- White text
|
||||
- Error icon
|
||||
- Auto-dismiss
|
||||
|
||||
### Info
|
||||
- Cyan background
|
||||
- White text
|
||||
- Info icon
|
||||
- Extended duration for tips
|
||||
|
||||
## Responsive Design
|
||||
|
||||
### Mobile (< 600px)
|
||||
- Full-width cards
|
||||
- 16px horizontal padding
|
||||
- Bottom navigation or drawer
|
||||
- Compact spacing
|
||||
|
||||
### Tablet (600-900px)
|
||||
- Centered content (max 600px)
|
||||
- Larger touch targets
|
||||
- Side navigation optional
|
||||
|
||||
### Desktop (> 900px)
|
||||
- Centered column (max 800px)
|
||||
- Larger fonts
|
||||
- Side navigation
|
||||
- More spacing
|
||||
|
||||
## Accessibility
|
||||
|
||||
- High contrast ratios (WCAG AA)
|
||||
- Large touch targets (44px min)
|
||||
- Clear visual hierarchy
|
||||
- Screen reader labels
|
||||
- Keyboard navigation support
|
||||
- Focus indicators
|
||||
|
||||
## Performance
|
||||
|
||||
- Lazy loading for images
|
||||
- Efficient state management
|
||||
- Optimized rebuilds with Riverpod
|
||||
- Smooth 60fps animations
|
||||
- Minimal memory usage
|
||||
@@ -0,0 +1,358 @@
|
||||
/// Settings Page
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../../core/theme/text_styles.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../widgets/settings/profile_section.dart';
|
||||
import '../../widgets/settings/audio_quality_selector.dart';
|
||||
import '../../widgets/settings/cache_management_tile.dart';
|
||||
import '../../widgets/settings/settings_tile.dart';
|
||||
|
||||
/// Settings page
|
||||
class SettingsPage extends ConsumerStatefulWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
|
||||
class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
String _appVersion = '1.0.0';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAppVersion();
|
||||
// Load settings on init
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(settingsProvider.notifier).loadSettings();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadAppVersion() async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
setState(() {
|
||||
_appVersion = '${info.version}+${info.buildNumber}';
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settingsState = ref.watch(settingsProvider);
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// App Bar
|
||||
SliverAppBar(
|
||||
floating: true,
|
||||
pinned: true,
|
||||
elevation: 0,
|
||||
backgroundColor: AppColors.primary.withOpacity(0.8),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
title: Text(
|
||||
'Settings',
|
||||
style: AppTextStyles.h2.copyWith(
|
||||
color: AppColors.onBackground,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Content
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
// Profile Section
|
||||
const ProfileSection(),
|
||||
const SizedBox(height: 24),
|
||||
// Audio Quality Section
|
||||
const SettingsSectionHeader(title: 'Audio'),
|
||||
const AudioQualitySelector(),
|
||||
// Playback Section
|
||||
const SettingsSectionHeader(title: 'Playback'),
|
||||
SettingsCard(
|
||||
children: [
|
||||
SettingsToggleTile(
|
||||
title: 'Crossfade',
|
||||
subtitle: 'Smooth transition between tracks',
|
||||
leading: const Icon(Icons.fade_out),
|
||||
value: settingsState.crossfadeEnabled,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.toggleCrossfade(value);
|
||||
},
|
||||
),
|
||||
if (settingsState.crossfadeEnabled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Crossfade Duration: ${settingsState.crossfadeDuration.toInt()}s',
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
Slider(
|
||||
value: settingsState.crossfadeDuration,
|
||||
min: 1,
|
||||
max: 12,
|
||||
divisions: 11,
|
||||
activeColor: AppColors.cyan,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setCrossfadeDuration(value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, color: AppColors.surfaceVariant),
|
||||
SettingsToggleTile(
|
||||
title: 'Gapless Playback',
|
||||
subtitle: 'No gap between tracks',
|
||||
leading: const Icon(Icons.all_inclusive),
|
||||
value: settingsState.gaplessPlayback,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.toggleGaplessPlayback(value);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1, color: AppColors.surfaceVariant),
|
||||
SettingsToggleTile(
|
||||
title: 'Normalize Volume',
|
||||
subtitle: 'Set same volume level for all tracks',
|
||||
leading: const Icon(Icons.volume_up),
|
||||
value: settingsState.normalizeVolume,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.toggleNormalizeVolume(value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
// Downloads Section
|
||||
const SettingsSectionHeader(title: 'Downloads'),
|
||||
SettingsCard(
|
||||
children: [
|
||||
SettingsToggleTile(
|
||||
title: 'Download on Mobile Data',
|
||||
subtitle: 'May use extra data',
|
||||
leading: const Icon(Icons.download_done),
|
||||
value: settingsState.downloadOnMobileData,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.toggleDownloadOnMobileData(value);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1, color: AppColors.surfaceVariant),
|
||||
SettingsToggleTile(
|
||||
title: 'Show Explicit Content',
|
||||
subtitle: 'Display explicit content in search',
|
||||
leading: const Icon(Icons.explicit),
|
||||
value: settingsState.showExplicitContent,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.toggleShowExplicitContent(value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Cache Management
|
||||
const CacheManagementTile(),
|
||||
const SizedBox(height: 24),
|
||||
// About Section
|
||||
const SettingsSectionHeader(title: 'About'),
|
||||
SettingsCard(
|
||||
children: [
|
||||
SettingsTile(
|
||||
title: 'App Version',
|
||||
subtitle: _appVersion,
|
||||
leading: const Icon(Icons.info_outline),
|
||||
),
|
||||
const Divider(height: 1, color: AppColors.surfaceVariant),
|
||||
SettingsTile(
|
||||
title: 'Licenses',
|
||||
subtitle: 'Open source licenses',
|
||||
leading: const Icon(Icons.description_outlined),
|
||||
onTap: () {
|
||||
showLicensePage(
|
||||
context: context,
|
||||
applicationName: 'Spotify Le 2',
|
||||
applicationVersion: _appVersion,
|
||||
applicationLegalese: '© 2025 Spotify Le 2',
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Logout Button
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _showLogoutDialog(context, ref),
|
||||
icon: const Icon(Icons.logout, size: 18),
|
||||
label: const Text('Log Out'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
side: BorderSide(
|
||||
color: AppColors.rose.withOpacity(0.5),
|
||||
width: 1.5,
|
||||
),
|
||||
foregroundColor: AppColors.rose,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Error message
|
||||
if (settingsState.error != null)
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.error.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
color: AppColors.error,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
settingsState.error!,
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.copyWith(error: null);
|
||||
},
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
color: AppColors.error,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showLogoutDialog(BuildContext context, WidgetRef ref) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.rose.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.logout,
|
||||
color: AppColors.rose,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Log Out',
|
||||
style: AppTextStyles.h3.copyWith(
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Text(
|
||||
'Are you sure you want to log out?',
|
||||
style: AppTextStyles.body.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: AppTextStyles.button.copyWith(
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pushNamedAndRemoveUntil(
|
||||
'/login',
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rose,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: Text(
|
||||
'Log Out',
|
||||
style: AppTextStyles.button,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/// Example: How to integrate Settings Page into your app
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotify_le_2/presentation/pages/settings/settings_page.dart';
|
||||
|
||||
// Example 1: Navigate from home page
|
||||
class HomePage extends StatelessWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Home'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SettingsPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: const Center(child: Text('Home Page')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Example 2: Using Go Router
|
||||
/*
|
||||
In your router configuration:
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
final router = GoRouter(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const HomePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
builder: (context, state) => const SettingsPage(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// Then navigate:
|
||||
context.push('/settings');
|
||||
*/
|
||||
|
||||
// Example 3: Bottom navigation bar
|
||||
class MainNavigation extends StatefulWidget {
|
||||
const MainNavigation({super.key});
|
||||
|
||||
@override
|
||||
State<MainNavigation> createState() => _MainNavigationState();
|
||||
}
|
||||
|
||||
class _MainNavigationState extends State<MainNavigation> {
|
||||
int _currentIndex = 0;
|
||||
|
||||
final List<Widget> _pages = [
|
||||
const HomePage(),
|
||||
const SearchPage(),
|
||||
const LibraryPage(),
|
||||
const SettingsPage(), // Settings as a main tab
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: _pages[_currentIndex],
|
||||
bottomNavigationBar: NavigationBar(
|
||||
currentIndex: _currentIndex,
|
||||
onDestinationSelected: (index) {
|
||||
setState(() {
|
||||
_currentIndex = index;
|
||||
});
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.home),
|
||||
label: 'Home',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.search),
|
||||
label: 'Search',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.library_music),
|
||||
label: 'Library',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Example 4: From a profile button in player widget
|
||||
class PlayerWidget extends StatelessWidget {
|
||||
const PlayerWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => _navigateToSettings(context),
|
||||
child: const CircleAvatar(
|
||||
child: Icon(Icons.person),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToSettings(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SettingsPage(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder classes for example
|
||||
class SearchPage extends StatelessWidget {
|
||||
const SearchPage({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context) => const Scaffold(body: Center(child: Text('Search')));
|
||||
}
|
||||
|
||||
class LibraryPage extends StatelessWidget {
|
||||
const LibraryPage({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context) => const Scaffold(body: Center(child: Text('Library')));
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/// Album Provider - Album details state management
|
||||
library;
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'dart:math';
|
||||
|
||||
import '../../../infrastructure/datasources/remote/music_api_service.dart';
|
||||
import '../../../domain/entities/album.dart';
|
||||
import '../../../domain/entities/track.dart';
|
||||
import 'music_provider.dart';
|
||||
|
||||
/// Album state
|
||||
class AlbumState {
|
||||
final Album? album;
|
||||
final List<Track> tracks;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const AlbumState({
|
||||
this.album,
|
||||
this.tracks = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
AlbumState copyWith({
|
||||
Album? album,
|
||||
List<Track>? tracks,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return AlbumState(
|
||||
album: album ?? this.album,
|
||||
tracks: tracks ?? this.tracks,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get total duration of all tracks in seconds
|
||||
int get totalDuration {
|
||||
return tracks.fold(0, (sum, track) {
|
||||
return sum + (track.duration ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Get formatted total duration (hours:minutes:seconds)
|
||||
String get formattedTotalDuration {
|
||||
final totalSeconds = totalDuration;
|
||||
final hours = totalSeconds ~/ 3600;
|
||||
final minutes = (totalSeconds % 3600) ~/ 60;
|
||||
final seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
} else {
|
||||
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Album notifier
|
||||
class AlbumNotifier extends StateNotifier<AlbumState> {
|
||||
AlbumNotifier(this._musicApiService) : super(const AlbumState());
|
||||
|
||||
final MusicApiService _musicApiService;
|
||||
|
||||
/// Load complete album information with tracks
|
||||
Future<void> loadAlbum(String albumId) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
// Load album details and tracks in parallel
|
||||
final results = await Future.wait([
|
||||
_musicApiService.getAlbum(albumId),
|
||||
_musicApiService.getAlbumTracks(albumId),
|
||||
]);
|
||||
|
||||
final album = Album.fromJson(results[0] as Map<String, dynamic>);
|
||||
final tracks = (results[1] as List)
|
||||
.map((json) => Track.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
// Sort tracks by track number
|
||||
tracks.sort((a, b) {
|
||||
final aNum = a.trackNumber ?? 0;
|
||||
final bNum = b.trackNumber ?? 0;
|
||||
return aNum.compareTo(bNum);
|
||||
});
|
||||
|
||||
state = AlbumState(
|
||||
album: album,
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Play all tracks from album
|
||||
Future<void> playAll(PlayerNotifier playerNotifier) async {
|
||||
if (state.tracks.isEmpty) return;
|
||||
|
||||
playerNotifier.setQueue(state.tracks, startIndex: 0);
|
||||
await playerNotifier.loadTrack(state.tracks.first);
|
||||
await playerNotifier.play();
|
||||
}
|
||||
|
||||
/// Shuffle and play all tracks from album
|
||||
Future<void> shuffle(PlayerNotifier playerNotifier) async {
|
||||
if (state.tracks.isEmpty) return;
|
||||
|
||||
// Create shuffled list
|
||||
final shuffledTracks = List<Track>.from(state.tracks);
|
||||
final random = Random();
|
||||
for (int i = shuffledTracks.length - 1; i > 0; i--) {
|
||||
final j = random.nextInt(i + 1);
|
||||
final temp = shuffledTracks[i];
|
||||
shuffledTracks[i] = shuffledTracks[j];
|
||||
shuffledTracks[j] = temp;
|
||||
}
|
||||
|
||||
playerNotifier.setQueue(shuffledTracks, startIndex: 0);
|
||||
await playerNotifier.loadTrack(shuffledTracks.first);
|
||||
await playerNotifier.play();
|
||||
}
|
||||
|
||||
/// Play specific track from album
|
||||
Future<void> playTrack(
|
||||
PlayerNotifier playerNotifier,
|
||||
Track track,
|
||||
) async {
|
||||
final index = state.tracks.indexWhere((t) => t.id == track.id);
|
||||
if (index == -1) return;
|
||||
|
||||
playerNotifier.setQueue(state.tracks, startIndex: index);
|
||||
await playerNotifier.loadTrack(track);
|
||||
await playerNotifier.play();
|
||||
}
|
||||
|
||||
/// Clear state
|
||||
void clear() {
|
||||
state = const AlbumState();
|
||||
}
|
||||
}
|
||||
|
||||
/// Album provider
|
||||
final albumProvider =
|
||||
StateNotifierProvider<AlbumNotifier, AlbumState>((ref) {
|
||||
final musicApiService = ref.watch(musicApiServiceProvider);
|
||||
return AlbumNotifier(musicApiService);
|
||||
});
|
||||
|
||||
/// Album data provider for a specific album ID
|
||||
final albumDataProvider = Provider.family<AlbumState, String>((ref, albumId) {
|
||||
final notifier = ref.watch(albumProvider.notifier);
|
||||
// Load data when first accessed
|
||||
if (notifier.state.album?.id != albumId) {
|
||||
Future.microtask(() => notifier.loadAlbum(albumId));
|
||||
}
|
||||
return notifier.state;
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/// Artist Provider - Artist details state management
|
||||
library;
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../infrastructure/datasources/remote/music_api_service.dart';
|
||||
import '../../../domain/entities/artist.dart';
|
||||
import '../../../domain/entities/track.dart';
|
||||
import '../../../domain/entities/album.dart';
|
||||
|
||||
/// Artist state
|
||||
class ArtistState {
|
||||
final Artist? artist;
|
||||
final List<Track> topTracks;
|
||||
final List<Album> albums;
|
||||
final List<Track> relatedTracks;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const ArtistState({
|
||||
this.artist,
|
||||
this.topTracks = const [],
|
||||
this.albums = const [],
|
||||
this.relatedTracks = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
ArtistState copyWith({
|
||||
Artist? artist,
|
||||
List<Track>? topTracks,
|
||||
List<Album>? albums,
|
||||
List<Track>? relatedTracks,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return ArtistState(
|
||||
artist: artist ?? this.artist,
|
||||
topTracks: topTracks ?? this.topTracks,
|
||||
albums: albums ?? this.albums,
|
||||
relatedTracks: relatedTracks ?? this.relatedTracks,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Artist notifier
|
||||
class ArtistNotifier extends StateNotifier<ArtistState> {
|
||||
ArtistNotifier(this._musicApiService) : super(const ArtistState());
|
||||
|
||||
final MusicApiService _musicApiService;
|
||||
|
||||
/// Load complete artist information
|
||||
Future<void> loadArtist(String artistId) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
// Load artist details
|
||||
final artistData = await _musicApiService.getArtist(artistId);
|
||||
final artist = Artist.fromJson(artistData);
|
||||
|
||||
state = state.copyWith(
|
||||
artist: artist,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load artist's top tracks
|
||||
Future<void> loadTopTracks(String artistId) async {
|
||||
try {
|
||||
final tracksData = await _musicApiService.getArtistTopTracks(artistId);
|
||||
final tracks = tracksData
|
||||
.map((json) => Track.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
state = state.copyWith(topTracks: tracks);
|
||||
} catch (e) {
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Load artist's albums
|
||||
Future<void> loadAlbums(String artistId) async {
|
||||
try {
|
||||
final albumsData = await _musicApiService.getArtistAlbums(artistId);
|
||||
final albums = albumsData
|
||||
.map((json) => Album.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
state = state.copyWith(albums: albums);
|
||||
} catch (e) {
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Load related tracks (based on artist's top track)
|
||||
Future<void> loadRelatedTracks(String artistId) async {
|
||||
try {
|
||||
// Get first track from top tracks for recommendations
|
||||
if (state.topTracks.isEmpty) {
|
||||
await loadTopTracks(artistId);
|
||||
}
|
||||
|
||||
if (state.topTracks.isNotEmpty) {
|
||||
final firstTrack = state.topTracks.first;
|
||||
final relatedData =
|
||||
await _musicApiService.getRecommendations(firstTrack.id);
|
||||
final related = relatedData
|
||||
.map((json) => Track.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
state = state.copyWith(relatedTracks: related);
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all artist data at once
|
||||
Future<void> loadAllArtistData(String artistId) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
// Load all data in parallel
|
||||
final results = await Future.wait([
|
||||
_musicApiService.getArtist(artistId),
|
||||
_musicApiService.getArtistTopTracks(artistId),
|
||||
_musicApiService.getArtistAlbums(artistId),
|
||||
]);
|
||||
|
||||
final artist = Artist.fromJson(results[0] as Map<String, dynamic>);
|
||||
final topTracks = (results[1] as List)
|
||||
.map((json) => Track.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
final albums = (results[2] as List)
|
||||
.map((json) => Album.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
// Load related tracks based on first top track
|
||||
List<Track> relatedTracks = [];
|
||||
if (topTracks.isNotEmpty) {
|
||||
try {
|
||||
final relatedData =
|
||||
await _musicApiService.getRecommendations(topTracks.first.id);
|
||||
relatedTracks = relatedData
|
||||
.map((json) => Track.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (_) {
|
||||
// Don't fail if recommendations fail
|
||||
}
|
||||
}
|
||||
|
||||
state = ArtistState(
|
||||
artist: artist,
|
||||
topTracks: topTracks,
|
||||
albums: albums,
|
||||
relatedTracks: relatedTracks,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear state
|
||||
void clear() {
|
||||
state = const ArtistState();
|
||||
}
|
||||
}
|
||||
|
||||
/// Artist provider
|
||||
final artistProvider =
|
||||
StateNotifierProvider<ArtistNotifier, ArtistState>((ref) {
|
||||
final musicApiService = ref.watch(musicApiServiceProvider);
|
||||
return ArtistNotifier(musicApiService);
|
||||
});
|
||||
|
||||
/// Artist data provider for a specific artist ID
|
||||
final artistDataProvider = Provider.family<ArtistState, String>((ref, artistId) {
|
||||
final notifier = ref.watch(artistProvider.notifier);
|
||||
// Load data when first accessed
|
||||
if (notifier.state.artist?.id != artistId) {
|
||||
Future.microtask(() => notifier.loadAllArtistData(artistId));
|
||||
}
|
||||
return notifier.state;
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user