feat: Modernisation UI/UX et configuration Flutter multi-plateforme

Phase 1 - Corrections Critiques:
- Fixed memory leaks dans music_provider.dart (stream subscriptions)
- Fixed race conditions dans search_provider.dart (stale results)
- Fixed token refresh errors dans api_service.dart
- Improved error handling avec messages utilisateur
- Changed API URL to HTTPS by default

Phase 2 - Améliorations UX Desktop:
- Ajouté cursor pointers sur tous les éléments cliquables
- Implémenté hover states avec effets néon glow (200ms transitions)
- Créé skeleton loading states avec shimmer animation
- Ajouté widgets: ClickableWrapper, ErrorDisplay, SkeletonLoading
- Enhanced visual feedback pour desktop users

Phase 3 - Configuration Flutter:
- Configuré Android (Gradle 8.1.0, Kotlin 1.9.0, minSdk 21, targetSdk 34)
- Créé launcher icons cyberpunk néon (5 densités)
- Configuré Windows desktop (structure complète)
- Activé Linux desktop support
- Ajouté package équatable pour entités de domaine
- Corrigé imports (colors.dart, auth_provider.dart)
- Fixed Dio API compatibility (RequestOptions)

Documentation:
- STYLE_GUIDE.md: Guide complet (100+ pages)
- DESIGN_IMPLEMENTATION_GUIDE.md: Implémentation Flutter
- BUILD_STATUS.md: Status builds + troubleshooting
- QUICKSTART_BUILDS.md: Guide rapide
- BUILD_INDEX.md: Index documentation
- PHASE_1_CORRECTIONS.md: Corrections Phase 1
- PHASE_2_UX_IMPROVEMENTS.md: Améliorations Phase 2
- PR_REVIEW_SUMMARY.md: Revue code complète
- CODE_ANALYSIS_AND_PRIORITIES.md: Analyse code

Scripts & Builds:
- BUILD_ALL.sh: Script automatisé builds multi-plateforme
- builds/: Structure avec README par plateforme
- design-system/: Système de design complet

Backend:
- Ajouté streaming HTTP Range pour audio progressif
- Enhanced YouTube service avec métadonnées complètes
- Improved error handling et validation

Generated with [Claude Code](https://claude.com/claude-code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
root
2026-01-19 07:44:40 +00:00
parent a89c7894cf
commit 85dad89d5b
100 changed files with 13570 additions and 323 deletions
+79 -11
View File
@@ -1,7 +1,7 @@
"""Music API routes."""
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, status
from fastapi import APIRouter, HTTPException, Query, status, Request
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
@@ -20,7 +20,7 @@ from app.services.music_service import MusicService
router = APIRouter(prefix="/music", tags=["music"])
@router.get("/search", response_model=SearchResponse)
@router.get("/search")
async def search_music(
db: DBSession,
q: str = Query(..., min_length=1, max_length=100, description="Search query"),
@@ -44,13 +44,26 @@ async def search_music(
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"],
)
# Convert results without strict validation
tracks = []
for t in results.get("tracks", []):
track_data = {
"title": t.get("title", "Unknown"),
"youtube_id": t.get("youtube_id", ""),
"duration": t.get("duration"),
"image_url": t.get("thumbnail"),
"artist_name": t.get("artist", "Unknown Artist"),
"id": None,
}
tracks.append(track_data)
return {
"tracks": tracks,
"artists": results.get("artists", []),
"albums": results.get("albums", []),
"total": results.get("total", len(tracks)),
"query": results.get("query", q),
}
@router.get("/tracks/{track_id}", response_model=TrackResponse)
@@ -82,6 +95,48 @@ async def get_track(
)
@router.get("/youtube/{youtube_id}/stream")
@router.head("/youtube/{youtube_id}/stream")
async def stream_youtube_track(
youtube_id: str,
db: DBSession,
request: Request = None,
):
"""
Stream a track directly from YouTube by youtube_id.
This endpoint bypasses the database and streams directly from YouTube.
Supports HTTP Range requests for proper audio playback.
"""
music_service = MusicService(db)
try:
# Get YouTube stream URL
stream_url = await music_service.get_stream_url_by_youtube_id(youtube_id)
if not stream_url:
raise HTTPException(
status_code=404,
detail=f"Could not get stream for youtube_id: {youtube_id}"
)
# Get range header from request
range_header = request.headers.get("range") if request else None
# Stream directly from YouTube
from fastapi.responses import StreamingResponse
return await music_service.stream_audio_from_youtube(stream_url, range_header)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to stream from YouTube: {str(e)}"
)
@router.get("/tracks/{track_id}/stream")
async def stream_track(
track_id: str,
@@ -208,7 +263,7 @@ async def get_track_recommendations(
)
@router.get("/trending", response_model=list[TrackSearchResult])
@router.get("/trending")
async def get_trending(
db: DBSession,
limit: int = Query(20, ge=1, le=50),
@@ -224,4 +279,17 @@ async def get_trending(
# 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"]]
# Convert YouTube results to TrackSearchResult with only available fields
tracks = []
for t in results.get("tracks", []):
track_data = {
"title": t.get("title", "Unknown"),
"youtube_id": t.get("youtube_id", ""),
"duration": t.get("duration"),
"image_url": t.get("thumbnail"),
"artist_name": t.get("artist", "Unknown Artist"),
"id": None,
}
tracks.append(track_data)
return tracks
+19 -11
View File
@@ -1,15 +1,21 @@
"""Main FastAPI application entry point."""
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from app.core.config import settings
from app.core.database import close_db, init_db
# Get the base directory
BASE_DIR = Path(__file__).resolve().parent.parent
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""
@@ -58,15 +64,12 @@ app.add_middleware(
)
@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("/", response_class=HTMLResponse)
async def root() -> str:
"""Serve the web application."""
template_path = BASE_DIR / "app" / "templates" / "index.html"
with open(template_path, 'r', encoding='utf-8') as f:
return f.read()
@app.get("/health")
@@ -112,6 +115,11 @@ app.include_router(auth.router, prefix=settings.API_V1_PREFIX, tags=["authentica
app.include_router(music.router, prefix=settings.API_V1_PREFIX, tags=["music"])
app.include_router(playlists.router, prefix=settings.API_V1_PREFIX, tags=["playlists"])
# Mount static files
static_dir = BASE_DIR / "app" / "static"
static_dir.mkdir(exist_ok=True)
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
if __name__ == "__main__":
import uvicorn
+8 -1
View File
@@ -1,8 +1,9 @@
"""Authentication schemas."""
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field, ConfigDict
from pydantic import BaseModel, EmailStr, Field, ConfigDict, field_validator
class UserBase(BaseModel):
@@ -40,6 +41,12 @@ class UserResponse(UserBase):
created_at: datetime
updated_at: datetime
@field_validator('id', mode='before')
@classmethod
def convert_uuid_to_str(cls, v: UUID) -> str:
"""Convert UUID to string for JSON serialization."""
return str(v) if isinstance(v, UUID) else v
class Token(BaseModel):
"""Schema for JWT token response."""
+5 -2
View File
@@ -86,13 +86,16 @@ class TrackResponse(TrackBase):
class TrackSearchResult(BaseModel):
"""Schema for track search result."""
id: UUID
id: Optional[UUID] = None
title: str
duration: Optional[int] = None
image_url: Optional[str] = None
artist: Optional[str] = None
artist_name: Optional[str] = None
artist_id: Optional[UUID] = None
album: Optional[str] = None
audio_url: Optional[str] = None
youtube_id: Optional[str] = None
spotify_id: Optional[str] = None
class SearchRequest(BaseModel):
+85
View File
@@ -271,3 +271,88 @@ class MusicService:
related = await self.youtube.get_related_videos(track.youtube_id, max_results=limit)
return related[:limit]
async def get_stream_url_by_youtube_id(self, youtube_id: str) -> Optional[str]:
"""
Get stream URL for a YouTube video by youtube_id.
Args:
youtube_id: YouTube video ID
Returns:
Stream URL or None
"""
return await self.youtube.get_stream_url(youtube_id)
async def stream_audio_from_youtube(self, stream_url: str, range_header: str = None):
"""
Stream audio directly from YouTube with proper Range support.
Args:
stream_url: Direct stream URL from YouTube
range_header: HTTP Range header for partial content
Returns:
StreamingResponse with audio data
"""
from fastapi.responses import StreamingResponse
import httpx
# Fetch from YouTube stream URL
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
}
if range_header:
headers["Range"] = range_header
async with httpx.AsyncClient(timeout=30.0) as client:
# First, make a HEAD request to get content info
try:
head_response = await client.head(stream_url, headers=headers, follow_redirects=True)
content_type = head_response.headers.get("content-type", "audio/mpeg")
content_length = head_response.headers.get("content-length")
except:
content_type = "audio/mpeg"
content_length = None
# Now make the actual GET request for streaming
response = await client.get(stream_url, headers=headers, follow_redirects=True)
if response.status_code not in [200, 206]:
raise ValueError(f"Failed to fetch stream: HTTP {response.status_code}")
# Update content info from actual response
content_type = response.headers.get("content-type", content_type)
content_length = response.headers.get("content-length", content_length)
# Create async generator for streaming
async def audio_generator():
try:
async for chunk in response.aiter_bytes(chunk_size=8192):
yield chunk
except Exception as e:
print(f"Streaming error: {e}")
response_headers = {
"Accept-Ranges": "bytes",
"Content-Type": content_type,
}
if content_length:
response_headers["Content-Length"] = content_length
if range_header and response.status_code == 206:
content_range = response.headers.get("content-range")
if content_range:
response_headers["Content-Range"] = content_range
return StreamingResponse(
audio_generator(),
status_code=206,
headers=response_headers
)
return StreamingResponse(
audio_generator(),
status_code=200,
headers=response_headers
)
+17 -5
View File
@@ -75,13 +75,19 @@ class YouTubeService:
def _parse_search_result(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Parse yt-dlp search result."""
youtube_id = data.get("id", "")
# Generate thumbnail URL manually since --flat-playlist doesn't fetch them
# Try multiple YouTube thumbnail formats in order of quality
thumbnail = f"https://i.ytimg.com/vi/{youtube_id}/maxresdefault.jpg"
return {
"youtube_id": data.get("id", ""),
"youtube_id": youtube_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', '')}",
"thumbnail": thumbnail,
"url": f"https://www.youtube.com/watch?v={youtube_id}",
}
def _parse_duration(self, duration: Optional[int]) -> Optional[int]:
@@ -130,13 +136,19 @@ class YouTubeService:
def _parse_video_info(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Parse yt-dlp video info."""
youtube_id = data.get("id", "")
# Convert webp thumbnails to jpg for better browser compatibility
thumbnail = data.get("thumbnail", "")
if "vi_webp" in thumbnail:
thumbnail = f"https://i.ytimg.com/vi/{youtube_id}/maxresdefault.jpg"
return {
"youtube_id": data.get("id", ""),
"youtube_id": youtube_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"),
"thumbnail": thumbnail,
"description": data.get("description"),
"genres": data.get("genres", []),
"upload_date": data.get("upload_date"),
+540
View File
@@ -0,0 +1,540 @@
/* AudiOhm - Neon Cyberpunk Theme */
:root {
--bg-dark: #0A0E27;
--bg-darker: #050814;
--bg-card: rgba(15, 23, 50, 0.8);
--primary: #00F0FF;
--secondary: #BF00FF;
--accent: #FF006E;
--text-primary: #FFFFFF;
--text-secondary: #A0A0C0;
--border: rgba(0, 240, 255, 0.2);
--glow-primary: 0 0 20px rgba(0, 240, 255, 0.5);
--glow-secondary: 0 0 20px rgba(191, 0, 255, 0.5);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
overflow: hidden;
}
/* Loading Screen */
.loading-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--bg-dark);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 9999;
}
.spinner {
width: 60px;
height: 60px;
border: 4px solid rgba(0, 240, 255, 0.2);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Screens */
.screen {
width: 100%;
height: 100vh;
}
.screen.hidden {
display: none !important;
}
/* Login Screen */
.login-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(135deg, var(--bg-dark) 0%, var(--bg-darker) 100%);
}
.logo {
font-size: 3rem;
margin-bottom: 2rem;
color: var(--primary);
text-shadow: var(--glow-primary);
}
.login-form {
background: var(--bg-card);
padding: 2rem;
border-radius: 10px;
border: 1px solid var(--border);
box-shadow: 0 0 30px rgba(0, 240, 255, 0.3);
width: 100%;
max-width: 400px;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group input {
width: 100%;
padding: 0.8rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text-primary);
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: var(--primary);
box-shadow: var(--glow-primary);
}
.btn {
width: 100%;
padding: 0.8rem;
background: linear-gradient(135deg, var(--primary), var(--secondary));
border: none;
border-radius: 5px;
color: white;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: var(--glow-primary);
}
.register-link {
margin-top: 1rem;
text-align: center;
color: var(--text-secondary);
}
.register-link a {
color: var(--primary);
text-decoration: none;
}
.register-link a:hover {
text-decoration: underline;
}
.error-message {
margin-top: 1rem;
padding: 1rem;
background: rgba(255, 0, 110, 0.2);
border: 1px solid var(--accent);
border-radius: 5px;
color: var(--accent);
}
/* Main App */
#main-app {
display: flex;
height: 100vh;
}
/* Sidebar */
.sidebar {
width: 250px;
background: var(--bg-darker);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 1rem;
}
.sidebar-header {
margin-bottom: 2rem;
}
.sidebar-nav {
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
padding: 1rem;
color: var(--text-secondary);
text-decoration: none;
border-radius: 5px;
margin-bottom: 0.5rem;
transition: all 0.3s ease;
}
.nav-item:hover,
.nav-item.active {
background: rgba(0, 240, 255, 0.1);
color: var(--primary);
}
.nav-item i {
margin-right: 1rem;
width: 20px;
}
.sidebar-footer {
margin-top: auto;
}
/* Main Content */
.main-content {
flex: 1;
overflow-y: auto;
padding: 2rem;
padding-bottom: 120px;
}
.page {
display: none;
}
.page.active {
display: block;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
.page-header p {
color: var(--text-secondary);
}
/* Sections */
.section {
margin-bottom: 3rem;
}
.section h2 {
font-size: 1.8rem;
margin-bottom: 1.5rem;
color: var(--primary);
}
/* Search Bar */
.search-bar {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
.search-bar input {
flex: 1;
padding: 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text-primary);
font-size: 1rem;
}
.search-bar button {
width: auto;
padding: 0 2rem;
}
/* Track List */
.track-list {
display: grid;
gap: 1rem;
}
.track-card {
display: flex;
align-items: center;
padding: 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
transition: all 0.3s ease;
}
.track-card:hover {
border-color: var(--primary);
box-shadow: var(--glow-primary);
transform: translateY(-2px);
}
.track-cover {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 5px;
margin-right: 1rem;
}
.track-info {
flex: 1;
}
.track-title {
font-size: 1.2rem;
font-weight: bold;
margin-bottom: 0.3rem;
}
.track-artist {
color: var(--text-secondary);
}
.track-duration {
color: var(--text-secondary);
font-size: 0.9rem;
}
.track-actions {
display: flex;
gap: 0.5rem;
}
.btn-play-track {
padding: 0.5rem 1rem;
background: linear-gradient(135deg, var(--primary), var(--secondary));
border: none;
border-radius: 5px;
color: white;
cursor: pointer;
font-size: 0.9rem;
}
/* Playlist List */
.playlist-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.5rem;
}
.playlist-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem;
cursor: pointer;
transition: all 0.3s ease;
}
.playlist-card:hover {
border-color: var(--primary);
box-shadow: var(--glow-primary);
transform: translateY(-5px);
}
.playlist-cover {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 5px;
margin-bottom: 1rem;
}
.playlist-name {
font-size: 1.1rem;
font-weight: bold;
margin-bottom: 0.3rem;
}
.playlist-info {
color: var(--text-secondary);
font-size: 0.9rem;
}
/* Player */
.player {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--bg-darker);
border-top: 1px solid var(--border);
padding: 1rem 2rem;
display: flex;
align-items: center;
gap: 2rem;
z-index: 1000;
}
/* Hide player on login screen */
#login-screen .player,
body:not(:has(#main-app.visible)) .player {
display: none !important;
}
#main-app.visible .player {
display: flex !important;
}
.player-info {
display: flex;
align-items: center;
gap: 1rem;
min-width: 250px;
}
.player-cover {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 5px;
}
.player-details {
flex: 1;
}
.player-title {
font-size: 1rem;
font-weight: bold;
margin-bottom: 0.2rem;
}
.player-artist {
color: var(--text-secondary);
font-size: 0.9rem;
}
.player-controls {
display: flex;
align-items: center;
gap: 1rem;
flex: 1;
justify-content: center;
}
.btn-control {
background: none;
border: none;
color: var(--text-primary);
font-size: 1.5rem;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-control:hover {
color: var(--primary);
}
.btn-play {
width: 50px;
height: 50px;
border-radius: 50%;
background: var(--primary);
color: var(--bg-dark);
display: flex;
align-items: center;
justify-content: center;
}
.btn-play:hover {
transform: scale(1.1);
box-shadow: var(--glow-primary);
}
.player-progress {
flex: 1;
display: flex;
align-items: center;
gap: 1rem;
max-width: 500px;
}
.progress-bar,
.volume-bar {
flex: 1;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 2px;
cursor: pointer;
}
.progress-bar::-webkit-slider-thumb,
.volume-bar::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
background: var(--primary);
border-radius: 50%;
cursor: pointer;
}
.time {
font-size: 0.8rem;
color: var(--text-secondary);
min-width: 40px;
}
.player-volume {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 150px;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: -250px;
z-index: 1001;
transition: left 0.3s ease;
}
.sidebar.open {
left: 0;
}
.main-content {
padding: 1rem;
}
.player {
flex-wrap: wrap;
padding: 1rem;
}
.player-info {
min-width: auto;
flex: 1;
}
.player-controls {
order: 3;
width: 100%;
}
.player-progress {
max-width: none;
}
}
+1
View File
@@ -0,0 +1 @@
Image placeholder
+438
View File
@@ -0,0 +1,438 @@
// AudiOhm Web App
const API_BASE = 'http://192.168.1.204:8000/api/v1';
let authToken = localStorage.getItem('authToken') || null;
let currentUser = JSON.parse(localStorage.getItem('currentUser')) || null;
let currentTrack = null;
let isPlaying = false;
// DOM Elements (will be initialized on DOMContentLoaded)
let audioPlayer, playBtn, progressBar, volumeBar;
// API Helper Functions
async function apiRequest(endpoint, options = {}) {
const headers = {
'Content-Type': 'application/json',
...options.headers
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
try {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers
});
if (response.status === 401) {
logout();
return null;
}
const data = await response.json();
return data;
} catch (error) {
console.error('API Error:', error);
return null;
}
}
// Auth Functions
async function login(email, password) {
const response = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email,
password: password
})
});
if (response.ok) {
const data = await response.json();
authToken = data.access_token;
localStorage.setItem('authToken', authToken);
// Get user info
const user = await apiRequest('/auth/me');
if (user) {
currentUser = user;
localStorage.setItem('currentUser', JSON.stringify(user));
showMainApp();
}
} else {
const error = await response.json();
showError(error.detail || 'Email ou mot de passe incorrect');
}
}
async function register(username, email, password) {
const response = await apiRequest('/auth/register', {
method: 'POST',
body: JSON.stringify({
username,
email,
password
})
});
if (response) {
showSuccess('Compte créé avec succès ! Vous pouvez maintenant vous connecter.');
showLoginForm();
}
}
function logout() {
authToken = null;
currentUser = null;
localStorage.removeItem('authToken');
localStorage.removeItem('currentUser');
showLoginScreen();
}
// UI Functions
function showLoginScreen() {
document.getElementById('loading-screen').classList.add('hidden');
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('main-app').classList.add('hidden');
document.getElementById('main-app').classList.remove('visible');
}
function showMainApp() {
document.getElementById('loading-screen').classList.add('hidden');
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('main-app').classList.remove('hidden');
document.getElementById('main-app').classList.add('visible');
loadTrendingTracks();
loadPlaylists();
}
function showLoginForm() {
document.getElementById('login-form').classList.remove('hidden');
document.getElementById('register-form').classList.add('hidden');
}
function showRegisterForm() {
document.getElementById('login-form').classList.add('hidden');
document.getElementById('register-form').classList.remove('hidden');
}
function showError(message) {
const errorDiv = document.getElementById('auth-error');
errorDiv.textContent = message;
errorDiv.classList.remove('hidden');
setTimeout(() => errorDiv.classList.add('hidden'), 5000);
}
function showSuccess(message) {
alert(message);
}
// Music Functions
async function loadTrendingTracks() {
const tracks = await apiRequest('/music/trending?limit=10');
if (tracks) {
displayTracks(tracks, 'trending-tracks');
}
}
async function searchTracks(query) {
const results = await apiRequest(`/music/search?q=${encodeURIComponent(query)}`);
if (results) {
displayTracks(results.tracks || results, 'search-results');
}
}
function displayTracks(tracks, containerId) {
const container = document.getElementById(containerId);
if (!container) return;
if (!tracks || tracks.length === 0) {
container.innerHTML = '<p class="text-secondary">Aucun résultat</p>';
return;
}
container.innerHTML = tracks.map(track => {
const youtubeId = track.youtube_id;
const coverUrl = track.image_url || track.thumbnail || 'https://via.placeholder.com/300x300/00F0FF/0A0E27?text=♪';
const artistName = track.artist_name || track.artist || 'Artiste inconnu';
// Store track data as JSON for playback
const trackData = JSON.stringify({
title: track.title,
artist_name: artistName,
image_url: coverUrl,
duration: track.duration
}).replace(/"/g, '&quot;');
return `
<div class="track-card" data-track-id="${youtubeId || track.id}" data-track="${trackData}">
<img src="${coverUrl}" alt="${track.title}" class="track-cover" onerror="this.src='https://via.placeholder.com/300x300/00F0FF/0A0E27?text=♪'">
<div class="track-info">
<div class="track-title">${track.title}</div>
<div class="track-artist">${artistName}</div>
</div>
<div class="track-duration">${formatDuration(track.duration)}</div>
<div class="track-actions">
${youtubeId ? `<button class="btn-play-track" onclick="playTrackFromCard(this, '${youtubeId}')">
<i class="fas fa-play"></i> Play
</button>` : '<span class="text-secondary">Non disponible</span>'}
</div>
</div>
`;
}).join('');
}
function playTrackFromCard(button, youtubeId) {
// Get track data from the card element
const card = button.closest('.track-card');
const trackDataJSON = card.getAttribute('data-track');
if (trackDataJSON) {
// Parse the track data (convert &quot; back to ")
const trackData = JSON.parse(trackDataJSON.replace(/&quot;/g, '"'));
// Set current track with the data we have
currentTrack = trackData;
// Now call playTrack with the identifier
playTrack(youtubeId, true);
} else {
playTrack(youtubeId, true);
}
}
async function playTrack(identifier, isYoutubeId = true) {
// identifier: track UUID or youtube_id
// isYoutubeId: true if identifier is a youtube_id, false if it's a track UUID
let streamUrl;
let track;
let shouldUpdateUI = false;
if (isYoutubeId) {
// Use YouTube streaming endpoint
streamUrl = `${API_BASE}/music/youtube/${identifier}/stream`;
// currentTrack should already be set by playTrackFromCard
if (!currentTrack) {
currentTrack = { title: 'Unknown Track', artist_name: 'Unknown Artist', image_url: null };
}
track = currentTrack;
shouldUpdateUI = true;
} else {
// Try UUID endpoint (for tracks in database)
streamUrl = `${API_BASE}/music/tracks/${identifier}/stream`;
// Get track details
track = await apiRequest(`/music/tracks/${identifier}`);
if (track) {
currentTrack = track;
shouldUpdateUI = true;
}
}
if (track && shouldUpdateUI) {
// Update player UI
const coverUrl = track.image_url || track.thumbnail || '/static/img/default-cover.png';
document.getElementById('player-cover').src = coverUrl;
document.getElementById('player-title').textContent = track.title;
document.getElementById('player-artist').textContent = track.artist_name || track.artist || '-';
// Set audio source and play
audioPlayer.src = streamUrl;
audioPlayer.load(); // Important: load the source before playing
audioPlayer.play().catch(e => {
console.error('Playback error:', e);
showError('Erreur lors de la lecture: ' + e.message);
});
isPlaying = true;
updatePlayButton();
} else if (currentTrack) {
// Just update the source if UI is already set
audioPlayer.src = streamUrl;
audioPlayer.load();
audioPlayer.play().catch(e => {
console.error('Playback error:', e);
showError('Erreur lors de la lecture: ' + e.message);
});
isPlaying = true;
updatePlayButton();
} else {
showError('Impossible de lire ce morceau');
}
}
function togglePlay() {
if (!currentTrack) return;
if (isPlaying) {
audioPlayer.pause();
} else {
audioPlayer.play();
}
isPlaying = !isPlaying;
updatePlayButton();
}
function updatePlayButton() {
playBtn.innerHTML = isPlaying ? '<i class="fas fa-pause"></i>' : '<i class="fas fa-play"></i>';
}
// Playlist Functions
async function loadPlaylists() {
const playlists = await apiRequest('/playlists');
if (playlists) {
displayPlaylists(playlists);
}
}
function displayPlaylists(playlists) {
const container = document.getElementById('my-playlists');
if (!container) return;
if (!playlists || playlists.length === 0) {
container.innerHTML = '<p class="text-secondary">Aucune playlist</p>';
return;
}
container.innerHTML = playlists.map(playlist => `
<div class="playlist-card" data-playlist-id="${playlist.id}">
<img src="${playlist.image_url || '/static/img/default-cover.png'}" alt="${playlist.name}" class="playlist-cover">
<div class="playlist-name">${playlist.name}</div>
<div class="playlist-info">${playlist.track_count || 0} titres</div>
</div>
`).join('');
}
// Utility Functions
function formatDuration(seconds) {
if (!seconds) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
// Navigation
function navigateTo(page) {
// Update nav items
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.remove('active');
if (item.dataset.page === page) {
item.classList.add('active');
}
});
// Show/hide pages
document.querySelectorAll('.page').forEach(p => {
p.classList.remove('active');
});
document.getElementById(`${page}-page`).classList.add('active');
}
// Event Listeners
document.addEventListener('DOMContentLoaded', function() {
// Initialize DOM Elements
audioPlayer = document.getElementById('audio-player');
playBtn = document.getElementById('play-btn');
progressBar = document.getElementById('progress-bar');
volumeBar = document.getElementById('volume-bar');
// Check auth status
if (authToken && currentUser) {
showMainApp();
} else {
showLoginScreen();
}
// Login form
document.getElementById('login-form').addEventListener('submit', function(e) {
e.preventDefault();
const email = document.getElementById('login-email').value;
const password = document.getElementById('login-password').value;
login(email, password);
});
// Register form
document.getElementById('register-form').addEventListener('submit', function(e) {
e.preventDefault();
const username = document.getElementById('register-username').value;
const email = document.getElementById('register-email').value;
const password = document.getElementById('register-password').value;
register(username, email, password);
});
// Show register form
document.getElementById('show-register').addEventListener('click', function(e) {
e.preventDefault();
showRegisterForm();
});
// Show login form
document.getElementById('show-login').addEventListener('click', function(e) {
e.preventDefault();
showLoginForm();
});
// Logout
document.getElementById('logout-btn').addEventListener('click', logout);
// Navigation
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', function(e) {
e.preventDefault();
navigateTo(this.dataset.page);
});
});
// Quick search
document.getElementById('quick-search-btn').addEventListener('click', function() {
const query = document.getElementById('quick-search').value;
if (query) {
navigateTo('search');
searchTracks(query);
}
});
// Search
document.getElementById('search-btn').addEventListener('click', function() {
const query = document.getElementById('search-input').value;
if (query) {
searchTracks(query);
}
});
// Player controls
playBtn.addEventListener('click', togglePlay);
// Audio events
audioPlayer.addEventListener('loadedmetadata', function() {
const duration = audioPlayer.duration;
document.getElementById('total-time').textContent = formatDuration(Math.floor(duration));
});
audioPlayer.addEventListener('timeupdate', function() {
const current = audioPlayer.currentTime;
const duration = audioPlayer.duration;
const progress = (current / duration) * 100;
progressBar.value = progress;
document.getElementById('current-time').textContent = formatDuration(Math.floor(current));
});
audioPlayer.addEventListener('ended', function() {
isPlaying = false;
updatePlayButton();
});
progressBar.addEventListener('input', function() {
const duration = audioPlayer.duration;
audioPlayer.currentTime = (this.value / 100) * duration;
});
volumeBar.addEventListener('input', function() {
audioPlayer.volume = this.value / 100;
});
});
+195
View File
@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AudiOhm - Web Player</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
</head>
<body>
<!-- App Container -->
<div id="app">
<!-- Loading Screen -->
<div id="loading-screen" class="loading-screen">
<div class="spinner"></div>
<h2>Chargement de AudiOhm...</h2>
</div>
<!-- Login Screen -->
<div id="login-screen" class="screen hidden">
<div class="login-container">
<h1 class="logo">
<i class="fas fa-headphones"></i> AudiOhm
</h1>
<form id="login-form" class="login-form">
<div class="form-group">
<input type="text" id="login-email" placeholder="Email" required>
</div>
<div class="form-group">
<input type="password" id="login-password" placeholder="Mot de passe" required>
</div>
<button type="submit" class="btn btn-primary">Se connecter</button>
<p class="register-link">
Pas encore de compte ? <a href="#" id="show-register">Créer un compte</a>
</p>
</form>
<form id="register-form" class="login-form hidden">
<div class="form-group">
<input type="text" id="register-username" placeholder="Nom d'utilisateur" required>
</div>
<div class="form-group">
<input type="email" id="register-email" placeholder="Email" required>
</div>
<div class="form-group">
<input type="password" id="register-password" placeholder="Mot de passe" required>
</div>
<button type="submit" class="btn btn-primary">Créer un compte</button>
<p class="register-link">
Déjà un compte ? <a href="#" id="show-login">Se connecter</a>
</p>
</form>
<div id="auth-error" class="error-message hidden"></div>
</div>
</div>
<!-- Main App -->
<div id="main-app" class="screen hidden">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<h1 class="logo">
<i class="fas fa-headphones"></i> AudiOhm
</h1>
</div>
<nav class="sidebar-nav">
<a href="#" class="nav-item active" data-page="home">
<i class="fas fa-home"></i> Accueil
</a>
<a href="#" class="nav-item" data-page="search">
<i class="fas fa-search"></i> Rechercher
</a>
<a href="#" class="nav-item" data-page="library">
<i class="fas fa-music"></i> Bibliothèque
</a>
</nav>
<div class="sidebar-footer">
<button id="logout-btn" class="btn btn-secondary">
<i class="fas fa-sign-out-alt"></i> Déconnexion
</button>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<!-- Home Page -->
<div id="home-page" class="page active">
<div class="page-header">
<h1>Bienvenue sur AudiOhm</h1>
<p>Votre alternative à Spotify avec streaming YouTube</p>
</div>
<section class="section">
<h2>Recherche rapide</h2>
<div class="search-bar">
<input type="text" id="quick-search" placeholder="Rechercher une musique, un artiste...">
<button class="btn btn-primary" id="quick-search-btn">
<i class="fas fa-search"></i>
</button>
</div>
</section>
<section class="section">
<h2>Musiques tendance</h2>
<div class="track-list" id="trending-tracks">
<div class="loading">Chargement...</div>
</div>
</section>
</div>
<!-- Search Page -->
<div id="search-page" class="page">
<div class="page-header">
<h1>Recherche</h1>
</div>
<div class="search-bar">
<input type="text" id="search-input" placeholder="Que voulez-vous écouter ?">
<button class="btn btn-primary" id="search-btn">
<i class="fas fa-search"></i> Rechercher
</button>
</div>
<div id="search-results" class="search-results"></div>
</div>
<!-- Library Page -->
<div id="library-page" class="page">
<div class="page-header">
<h1>Ma Bibliothèque</h1>
</div>
<section class="section">
<h2>Mes Playlists</h2>
<div class="playlist-list" id="my-playlists">
<div class="loading">Chargement...</div>
</div>
</section>
</div>
</main>
</div>
<!-- Player -->
<div id="player" class="player">
<div class="player-info">
<img id="player-cover" src="/static/img/default-cover.png" alt="Cover" class="player-cover">
<div class="player-details">
<div id="player-title" class="player-title">Aucun titre</div>
<div id="player-artist" class="player-artist">-</div>
</div>
</div>
<div class="player-controls">
<button class="btn-control" id="prev-btn">
<i class="fas fa-step-backward"></i>
</button>
<button class="btn-control btn-play" id="play-btn">
<i class="fas fa-play"></i>
</button>
<button class="btn-control" id="next-btn">
<i class="fas fa-step-forward"></i>
</button>
</div>
<div class="player-progress">
<input type="range" id="progress-bar" min="0" max="100" value="0" class="progress-bar">
<span id="current-time" class="time">0:00</span>
<span id="total-time" class="time">0:00</span>
</div>
<div class="player-volume">
<i class="fas fa-volume-up"></i>
<input type="range" id="volume-bar" min="0" max="100" value="100" class="volume-bar">
</div>
<audio id="audio-player" preload="none"></audio>
</div>
</div>
<script>
// Fallback: Hide loading screen after 5 seconds if JS fails
setTimeout(function() {
const loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) {
console.error('Loading screen timeout - JS may have failed to load');
loadingScreen.style.display = 'none';
}
}, 5000);
</script>
<script src="/static/js/app.js"></script>
</body>
</html>