prod: UI Optimisée mise en production

- Documentation archivée et réorganisée
- Backend: Ajout tests, migrations, library service, rate limiting
- Frontend: Suppression Flutter, focus sur interface web HTML/JS
- Tailwind CSS ajouté pour le style
- Améliorations UX et corrections bugs

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-20 09:56:39 +00:00
parent bc03225e47
commit 801e6a050b
263 changed files with 33100 additions and 23058 deletions
+48
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, HTTPException, status
from app.api.dependencies import AuthServiceDep, CurrentUser, DBSession
from app.schemas.auth import (
ChangePasswordRequest,
LoginRequest,
RefreshTokenRequest,
Token,
@@ -176,3 +177,50 @@ async def logout(
# - Log the logout event
return None
@router.post("/change-password")
async def change_password(
password_data: ChangePasswordRequest,
current_user: CurrentUser,
auth_service: AuthServiceDep,
db: DBSession,
):
"""
Change user password.
Requires authentication and current password verification.
- **password_data**: Object containing old_password and new_password
"""
from app.core.security import verify_password, hash_password
# Verify old password
if not verify_password(password_data.old_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Current password is incorrect"
)
# Validate new password
if len(password_data.new_password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="New password must be at least 8 characters"
)
if password_data.old_password == password_data.new_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="New password must be different from current password"
)
# Hash new password
new_password_hash = hash_password(password_data.new_password)
# Update password
current_user.password_hash = new_password_hash
await db.commit()
await db.refresh(current_user)
return {"message": "Password changed successfully"}
+516
View File
@@ -0,0 +1,516 @@
"""Library API routes."""
from typing import List, Optional
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, status
from app.models.track import Track
from app.api.dependencies import CurrentUser, DBSession
from app.schemas.library import (
ListeningHistoryCreate,
ListeningHistoryResponse,
ListeningHistoryStats,
LibraryStatsResponse,
LikedTrackCreate,
LikedTrackResponse,
LikedTrackUpdate,
LikedTrackCheckResponse,
RecentlyPlayedResponse,
MostPlayedTrackResponse,
MostPlayedTracksResponse,
)
from app.services.library_service import LibraryService
router = APIRouter(prefix="/library", tags=["library"])
def build_track_response(track: Track) -> dict:
"""
Build standardized track response dictionary.
Args:
track: Track model instance
Returns:
Dictionary with track data including artist and album info
"""
return {
"id": str(track.id),
"title": track.title,
"duration": track.duration,
"artist": {
"id": str(track.artist.id),
"name": track.artist.name,
} if track.artist else None,
"album": {
"id": str(track.album.id),
"name": track.album.name,
} if track.album else None,
"image_url": track.image_url,
"play_count": track.play_count,
}
# ============ LISTENING HISTORY ENDPOINTS ============
@router.post("/history", response_model=ListeningHistoryResponse, status_code=status.HTTP_201_CREATED)
async def add_to_history(
history_data: ListeningHistoryCreate,
current_user: CurrentUser,
db: DBSession,
):
"""
Add a track to listening history.
- **track_id**: Track UUID
- **played_for**: Duration played in seconds
- **completed**: Whether track was played to completion (default: false)
- **source**: Playback source (library, playlist, search, etc.)
"""
library_service = LibraryService(db)
history_entry = await library_service.add_to_listening_history(
user_id=current_user.id,
track_id=history_data.track_id,
played_for=history_data.played_for,
completed=history_data.completed,
source=history_data.source,
)
# Load track details
from sqlalchemy import select
track_stmt = select(Track).where(Track.id == history_entry.track_id)
track_result = await db.execute(track_stmt)
track = track_result.scalar_one_or_none()
# Build response manually to avoid SQLAlchemy object validation issues
response_data = {
"id": str(history_entry.id),
"user_id": str(history_entry.user_id),
"track_id": str(history_entry.track_id),
"played_for": history_entry.played_for,
"completed": history_entry.completed,
"source": history_entry.source,
"played_at": history_entry.played_at.isoformat(),
"created_at": history_entry.created_at.isoformat(),
}
if track:
response_data["track"] = build_track_response(track)
return ListeningHistoryResponse(**response_data)
@router.get("/history", response_model=List[ListeningHistoryResponse])
async def get_listening_history(
current_user: CurrentUser,
db: DBSession,
limit: int = Query(50, ge=1, le=100, description="Maximum results"),
offset: int = Query(0, ge=0, description="Pagination offset"),
days: int = Query(None, ge=1, le=365, description="Filter by last N days"),
):
"""
Get user's listening history.
- **limit**: Maximum results (1-100, default: 50)
- **offset**: Pagination offset (default: 0)
- **days**: Filter by last N days (1-365, optional)
"""
library_service = LibraryService(db)
history_entries = await library_service.get_listening_history(
user_id=current_user.id,
limit=limit,
offset=offset,
days=days,
)
responses = []
for entry in history_entries:
# Build response manually to avoid SQLAlchemy object validation issues
response_data = {
"id": str(entry.id),
"user_id": str(entry.user_id),
"track_id": str(entry.track_id),
"played_for": entry.played_for,
"completed": entry.completed,
"source": entry.source,
"played_at": entry.played_at.isoformat(),
"created_at": entry.created_at.isoformat(),
}
# Add track info if available
if entry.track:
response_data["track"] = build_track_response(entry.track)
responses.append(ListeningHistoryResponse(**response_data))
return responses
@router.get("/history/recent", response_model=RecentlyPlayedResponse)
async def get_recently_played(
current_user: CurrentUser,
db: DBSession,
limit: int = Query(20, ge=1, le=50, description="Maximum results"),
):
"""
Get user's recently played tracks (unique tracks).
- **limit**: Maximum results (1-50, default: 20)
"""
library_service = LibraryService(db)
tracks = await library_service.get_recently_played(
user_id=current_user.id,
limit=limit,
)
track_data = []
for track in tracks:
track_data.append(build_track_response(track))
return RecentlyPlayedResponse(tracks=track_data, total=len(tracks))
@router.get("/history/most-played", response_model=MostPlayedTracksResponse)
async def get_most_played(
current_user: CurrentUser,
db: DBSession,
limit: int = Query(20, ge=1, le=50, description="Maximum results"),
days: int = Query(None, ge=1, le=365, description="Filter by last N days"),
):
"""
Get user's most played tracks.
- **limit**: Maximum results (1-50, default: 20)
- **days**: Filter by last N days (1-365, optional)
"""
library_service = LibraryService(db)
tracks_with_count = await library_service.get_most_played_tracks(
user_id=current_user.id,
limit=limit,
days=days,
)
track_data = []
for track, play_count in tracks_with_count:
track_response = MostPlayedTrackResponse(
track=build_track_response(track),
play_count=play_count,
)
track_data.append(track_response)
return MostPlayedTracksResponse(tracks=track_data, total=len(track_data))
@router.delete("/history", status_code=status.HTTP_204_NO_CONTENT)
async def clear_listening_history(
current_user: CurrentUser,
db: DBSession,
before_date: datetime = Query(None, description="Clear history before this date (ISO 8601)"),
):
"""
Clear user's listening history.
- **before_date**: Optional cutoff date (ISO 8601 format). If not provided, clears all history.
"""
library_service = LibraryService(db)
await library_service.clear_listening_history(
user_id=current_user.id,
before_date=before_date,
)
# ============ LIKED TRACKS ENDPOINTS ============
@router.post("/liked", response_model=LikedTrackResponse, status_code=status.HTTP_201_CREATED)
async def like_track(
like_data: LikedTrackCreate,
current_user: CurrentUser,
db: DBSession,
):
"""
Add a track to user's liked tracks.
- **track_id**: Track UUID
- **notes**: Optional user notes (max 1000 characters)
"""
library_service = LibraryService(db)
try:
liked_track = await library_service.like_track(
user_id=current_user.id,
track_id=like_data.track_id,
notes=like_data.notes,
)
except ValueError as e:
if "already" in str(e).lower():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(e),
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
# Load track details
from sqlalchemy import select
track_stmt = select(Track).where(Track.id == liked_track.track_id)
track_result = await db.execute(track_stmt)
track = track_result.scalar_one_or_none()
# Build response manually to avoid SQLAlchemy object validation issues
response_data = {
"id": str(liked_track.id),
"user_id": str(liked_track.user_id),
"track_id": str(liked_track.track_id),
"notes": liked_track.notes,
"created_at": liked_track.created_at.isoformat(),
"updated_at": liked_track.updated_at.isoformat(),
}
if track:
response_data["track"] = build_track_response(track)
return LikedTrackResponse(**response_data)
# Alias endpoint for frontend compatibility (track_id in URL path)
@router.post("/liked-tracks/{track_id}", response_model=LikedTrackResponse, status_code=status.HTTP_201_CREATED)
async def like_track_alias(
track_id: str,
current_user: CurrentUser,
db: DBSession,
):
"""
Add a track to user's liked tracks (alias for frontend compatibility).
- **track_id**: Track UUID in URL path
"""
from uuid import UUID
# Create the request data from the URL parameter
like_data = LikedTrackCreate(track_id=UUID(track_id), notes=None)
return await like_track(like_data, current_user, db)
@router.delete("/liked/{track_id}", status_code=status.HTTP_204_NO_CONTENT)
async def unlike_track(
track_id: str,
current_user: CurrentUser,
db: DBSession,
):
"""
Remove a track from user's liked tracks.
- **track_id**: Track UUID
"""
from uuid import UUID
library_service = LibraryService(db)
try:
await library_service.unlike_track(
user_id=current_user.id,
track_id=UUID(track_id),
)
except ValueError as e:
if "not" 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 track ID",
)
# Alias endpoint for frontend compatibility
@router.delete("/liked-tracks/{track_id}", status_code=status.HTTP_204_NO_CONTENT)
async def unlike_track_alias(
track_id: str,
current_user: CurrentUser,
db: DBSession,
):
"""
Remove a track from user's liked tracks (alias for frontend compatibility).
- **track_id**: Track UUID
"""
return await unlike_track(track_id, current_user, db)
@router.get("/liked", response_model=List[LikedTrackResponse])
async def get_liked_tracks(
current_user: CurrentUser,
db: DBSession,
limit: int = Query(50, ge=1, le=100, description="Maximum results"),
offset: int = Query(0, ge=0, description="Pagination offset"),
):
"""
Get user's liked tracks.
- **limit**: Maximum results (1-100, default: 50)
- **offset**: Pagination offset (default: 0)
"""
library_service = LibraryService(db)
liked_tracks = await library_service.get_liked_tracks(
user_id=current_user.id,
limit=limit,
offset=offset,
)
responses = []
for liked_track in liked_tracks:
# Build response manually to avoid SQLAlchemy object validation issues
response_data = {
"id": str(liked_track.id),
"user_id": str(liked_track.user_id),
"track_id": str(liked_track.track_id),
"notes": liked_track.notes,
"created_at": liked_track.created_at.isoformat(),
"updated_at": liked_track.updated_at.isoformat(),
}
# Add track info if available
if liked_track.track:
response_data["track"] = build_track_response(liked_track.track)
responses.append(LikedTrackResponse(**response_data))
return responses
# Alias endpoint for frontend compatibility
@router.get("/liked-tracks", response_model=List[LikedTrackResponse])
async def get_liked_tracks_alias(
current_user: CurrentUser,
db: DBSession,
limit: int = Query(50, ge=1, le=100, description="Maximum results"),
offset: int = Query(0, ge=0, description="Pagination offset"),
):
"""
Get user's liked tracks (alias for frontend compatibility).
- **limit**: Maximum results (1-100, default: 50)
- **offset**: Pagination offset (default: 0)
"""
return await get_liked_tracks(current_user, db, limit, offset)
@router.get("/liked/check/{track_id}", response_model=LikedTrackCheckResponse)
async def check_track_liked(
track_id: str,
current_user: CurrentUser,
db: DBSession,
):
"""
Check if a track is in user's liked tracks.
- **track_id**: Track UUID
"""
from uuid import UUID
library_service = LibraryService(db)
try:
is_liked = await library_service.check_track_liked(
user_id=current_user.id,
track_id=UUID(track_id),
)
except Exception:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid track ID",
)
return LikedTrackCheckResponse(is_liked=is_liked)
@router.put("/liked/{track_id}/notes", response_model=LikedTrackResponse)
async def update_liked_track_notes(
track_id: str,
notes_data: LikedTrackUpdate,
current_user: CurrentUser,
db: DBSession,
):
"""
Update notes for a liked track.
- **track_id**: Track UUID
- **notes**: New notes (max 1000 characters)
"""
from uuid import UUID
library_service = LibraryService(db)
try:
liked_track = await library_service.update_liked_track_notes(
user_id=current_user.id,
track_id=UUID(track_id),
notes=notes_data.notes,
)
except ValueError as e:
if "not" 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 track ID",
)
# Load track details
from sqlalchemy import select
track_stmt = select(Track).where(Track.id == liked_track.track_id)
track_result = await db.execute(track_stmt)
track = track_result.scalar_one_or_none()
# Build response manually to avoid SQLAlchemy object validation issues
response_data = {
"id": str(liked_track.id),
"user_id": str(liked_track.user_id),
"track_id": str(liked_track.track_id),
"notes": liked_track.notes,
"created_at": liked_track.created_at.isoformat(),
"updated_at": liked_track.updated_at.isoformat(),
}
if track:
response_data["track"] = build_track_response(track)
return LikedTrackResponse(**response_data)
# ============ LIBRARY STATS ENDPOINTS ============
@router.get("/stats", response_model=LibraryStatsResponse)
async def get_library_stats(
current_user: CurrentUser,
db: DBSession,
):
"""
Get user's library statistics.
Returns statistics about listening history and liked tracks.
"""
library_service = LibraryService(db)
stats = await library_service.get_library_stats(user_id=current_user.id)
return LibraryStatsResponse(**stats)
+68 -32
View File
@@ -1,10 +1,13 @@
"""Music API routes."""
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, status, Request
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
from app.api.dependencies import CurrentUser, CurrentUserOptional, DBSession
from app.schemas.music import (
AlbumResponse,
@@ -47,13 +50,15 @@ async def search_music(
# Convert results without strict validation
tracks = []
for t in results.get("tracks", []):
# Use youtube_id as the id for YouTube-only results
track_id = t.get("id") or t.get("youtube_id")
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,
"id": track_id,
}
tracks.append(track_data)
@@ -96,44 +101,87 @@ async def get_track(
@router.get("/youtube/{youtube_id}/stream")
@router.head("/youtube/{youtube_id}/stream")
async def stream_youtube_track(
async def stream_youtube_audio(
youtube_id: str,
db: DBSession,
request: Request = None,
):
"""
Stream a track directly from YouTube by youtube_id.
Stream audio from a YouTube video.
This endpoint bypasses the database and streams directly from YouTube.
Downloads the audio as MP3 and streams it to the client.
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)
# Download audio as MP3
from pathlib import Path
if not stream_url:
audio_path = await music_service.youtube.download_audio(youtube_id)
if not audio_path or not audio_path.exists():
raise HTTPException(
status_code=404,
detail=f"Could not get stream for youtube_id: {youtube_id}"
detail=f"Could not download audio for youtube_id: {youtube_id}"
)
# Get range header from request
# Get file info
file_size = audio_path.stat().st_size
# Handle Range request
range_header = request.headers.get("range") if request else None
# Stream directly from YouTube
from fastapi.responses import StreamingResponse
if range_header:
# Parse Range header (format: "bytes=start-end")
try:
range_match = range_header.replace("bytes=", "").strip()
range_parts = range_match.split("-")
start = int(range_parts[0]) if range_parts[0] else 0
end = int(range_parts[1]) if len(range_parts) > 1 and range_parts[1] else file_size - 1
return await music_service.stream_audio_from_youtube(stream_url, range_header)
# Read the specific range
with open(audio_path, "rb") as f:
f.seek(start)
chunk_size = end - start + 1
data = f.read(chunk_size)
from fastapi.responses import Response
return Response(
content=data,
status_code=206, # Partial Content
media_type="audio/mpeg",
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(chunk_size),
"Content-Disposition": f"inline; filename={youtube_id}.mp3",
}
)
except Exception as e:
logger.error(f"Error handling range request: {e}")
# Fall through to full file response
# Full file response
from fastapi.responses import FileResponse
return FileResponse(
audio_path,
media_type="audio/mpeg",
filename=f"{youtube_id}.mp3",
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(file_size),
}
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to stream from YouTube: {str(e)}"
detail=f"Failed to stream audio: {str(e)}"
)
@@ -267,29 +315,17 @@ async def get_track_recommendations(
async def get_trending(
db: DBSession,
limit: int = Query(20, ge=1, le=50),
days: int = Query(7, ge=1, le=30, description="Number of days to look back"),
):
"""
Get trending tracks.
Get trending tracks based on play count and recent listens.
Currently returns placeholder data.
In production, this would use actual trending data.
Returns the most played tracks from the database, sorted by popularity.
Combines total play count with recent activity to determine trending tracks.
"""
music_service = MusicService(db)
# Search for popular music on YouTube
results = await music_service.search("music 2024", search_type="track", limit=limit)
# 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)
# Get trending tracks from database
tracks = await music_service.get_trending(limit=limit, days=days)
return tracks
+24
View File
@@ -0,0 +1,24 @@
"""Rate limiter configuration."""
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from fastapi import Request
from fastapi.responses import JSONResponse
# Create limiter instance
limiter = Limiter(key_func=get_remote_address)
# Custom rate limit exceeded handler
def rate_limit_exceeded_handler(request: Request, exception):
"""Custom handler for rate limit exceeded."""
return JSONResponse(
status_code=429,
content={"detail": "Too many requests. Please try again later."},
)
# Replace the default handler
limiter._rate_limit_exceeded_handler = rate_limit_exceeded_handler
# Rate limit rules
# Example: 100 requests per minute for general endpoints
# 10 requests per minute for authentication endpoints
# 5 requests per second for expensive operations
+17 -8
View File
@@ -1,4 +1,5 @@
"""Main FastAPI application entry point."""
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator
@@ -7,9 +8,13 @@ from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from slowapi.errors import RateLimitExceeded
from app.core.config import settings
from app.core.database import close_db, init_db
from app.core.rate_limiter import limiter
logger = logging.getLogger(__name__)
# Get the base directory
@@ -24,22 +29,22 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
Handles startup and shutdown events.
"""
# Startup
print("Starting up...")
logger.info("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}")
logger.debug("Debug mode is ON")
logger.debug(f"Database URL: {settings.DATABASE_URL}")
logger.debug(f"Redis URL: {settings.FULL_REDIS_URL}")
# Initialize database
await init_db()
print("Database initialized")
logger.info("Database initialized")
yield
# Shutdown
print("Shutting down...")
logger.info("Shutting down...")
await close_db()
print("Database connections closed")
logger.info("Database connections closed")
# Create FastAPI application
@@ -53,6 +58,9 @@ app = FastAPI(
lifespan=lifespan,
)
# Set up rate limiting
app.state.limiter = limiter
# Configure CORS
app.add_middleware(
@@ -109,11 +117,12 @@ async def global_exception_handler(request, exc) -> JSONResponse:
# API routes
from app.api.v1 import auth, music, playlists
from app.api.v1 import auth, music, playlists, library
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"])
app.include_router(library.router, prefix=settings.API_V1_PREFIX, tags=["library"])
# Mount static files
static_dir = BASE_DIR / "app" / "static"
+7
View File
@@ -1,14 +1,21 @@
"""SQLAlchemy models."""
from app.core.database import Base
from app.models.album import Album
from app.models.artist import Artist
from app.models.liked_track import LikedTrack
from app.models.listening_history import ListeningHistory
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__ = [
"Base",
"Album",
"Artist",
"LikedTrack",
"ListeningHistory",
"Playlist",
"PlaylistTrack",
"Track",
+90
View File
@@ -0,0 +1,90 @@
"""Liked Track model."""
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import String, ForeignKey, Index
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.user import User
from app.models.track import Track
class LikedTrack(Base):
"""Liked Track model representing user's liked/favorited tracks."""
__tablename__ = "liked_tracks"
# Primary key
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
index=True,
)
# Foreign keys
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.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,
)
# Additional metadata
notes: Mapped[str | None] = mapped_column(
String(1000),
nullable=True,
comment="User notes about the track",
)
# 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="liked_tracks",
lazy="selectin",
)
track: Mapped["Track"] = relationship(
"Track",
lazy="selectin",
)
# Table indices for optimal queries and uniqueness constraint
__table_args__ = (
Index("ix_liked_tracks_user_track", "user_id", "track_id", unique=True),
)
def __repr__(self) -> str:
return f"<LikedTrack user={self.user_id} track={self.track_id}>"
def to_dict(self) -> dict:
"""Convert liked track model to dictionary."""
return {
"id": str(self.id),
"user_id": str(self.user_id),
"track_id": str(self.track_id),
"notes": self.notes,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
+105
View File
@@ -0,0 +1,105 @@
"""Listening History model."""
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Integer, String, Boolean, ForeignKey, Index
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.user import User
from app.models.track import Track
class ListeningHistory(Base):
"""Listening History model representing user's track listening history."""
__tablename__ = "listening_history"
# Primary key
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
index=True,
)
# Foreign keys
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.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,
)
# Playback details
played_for: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="Duration played in seconds",
)
completed: Mapped[bool] = mapped_column(
Boolean,
default=False,
comment="Whether the track was played to completion",
)
# Source information
source: Mapped[str | None] = mapped_column(
String(50),
comment="Playback source (library, playlist, search, etc.)",
)
# Timestamps
played_at: Mapped[datetime] = mapped_column(
default=datetime.utcnow,
nullable=False,
index=True,
)
created_at: Mapped[datetime] = mapped_column(
default=datetime.utcnow,
nullable=False,
)
# Relationships
user: Mapped["User"] = relationship(
"User",
back_populates="listening_history",
lazy="selectin",
)
track: Mapped["Track"] = relationship(
"Track",
lazy="selectin",
)
# Table indices for optimal queries
__table_args__ = (
Index("ix_listening_history_user_played", "user_id", "played_at"),
Index("ix_listening_history_user_track", "user_id", "track_id"),
)
def __repr__(self) -> str:
return f"<ListeningHistory user={self.user_id} track={self.track_id} at={self.played_at}>"
def to_dict(self) -> dict:
"""Convert listening history model to dictionary."""
return {
"id": str(self.id),
"user_id": str(self.user_id),
"track_id": str(self.track_id),
"played_for": self.played_for,
"completed": bool(self.completed),
"source": self.source,
"played_at": self.played_at.isoformat(),
"created_at": self.created_at.isoformat(),
}
+16
View File
@@ -12,6 +12,8 @@ from app.core.database import Base
if TYPE_CHECKING:
from app.models.playlist import Playlist
from app.models.playlist_track import PlaylistTrack
from app.models.listening_history import ListeningHistory
from app.models.liked_track import LikedTrack
class User(Base):
@@ -100,6 +102,20 @@ class User(Base):
lazy="selectin",
)
listening_history: Mapped[list["ListeningHistory"]] = relationship(
"ListeningHistory",
back_populates="user",
cascade="all, delete-orphan",
lazy="selectin",
)
liked_tracks: Mapped[list["LikedTrack"]] = relationship(
"LikedTrack",
back_populates="user",
cascade="all, delete-orphan",
lazy="selectin",
)
def __repr__(self) -> str:
return f"<User {self.username} ({self.email})>"
+7
View File
@@ -76,3 +76,10 @@ class RefreshTokenRequest(BaseModel):
"""Schema for token refresh request."""
refresh_token: str
class ChangePasswordRequest(BaseModel):
"""Schema for password change request."""
old_password: str = Field(..., min_length=8, max_length=100)
new_password: str = Field(..., min_length=8, max_length=100)
+123
View File
@@ -0,0 +1,123 @@
"""Library schemas."""
from datetime import datetime
from typing import Optional, List
from uuid import UUID
from pydantic import BaseModel, Field, ConfigDict
# ============ LISTENING HISTORY SCHEMAS ============
class ListeningHistoryBase(BaseModel):
"""Base listening history schema."""
played_for: int = Field(..., ge=0, description="Duration played in seconds")
completed: bool = False
source: Optional[str] = Field(None, max_length=50, description="Playback source")
class ListeningHistoryCreate(ListeningHistoryBase):
"""Schema for creating a listening history entry."""
track_id: UUID
class ListeningHistoryResponse(BaseModel):
"""Schema for listening history response."""
model_config = ConfigDict(from_attributes=True)
id: UUID
user_id: UUID
track_id: UUID
played_for: int
completed: bool
source: Optional[str]
played_at: datetime
created_at: datetime
# Embedded track information
track: Optional[dict] = None
class ListeningHistoryStats(BaseModel):
"""Schema for listening history statistics."""
total_plays: int
plays_last_30_days: int
unique_tracks_played: int
# ============ LIKED TRACKS SCHEMAS ============
class LikedTrackBase(BaseModel):
"""Base liked track schema."""
notes: Optional[str] = Field(None, max_length=1000, description="User notes about the track")
class LikedTrackCreate(BaseModel):
"""Schema for liking a track."""
track_id: UUID
notes: Optional[str] = Field(None, max_length=1000)
class LikedTrackUpdate(BaseModel):
"""Schema for updating liked track notes."""
notes: str = Field(..., max_length=1000)
class LikedTrackResponse(BaseModel):
"""Schema for liked track response."""
model_config = ConfigDict(from_attributes=True)
id: UUID
user_id: UUID
track_id: UUID
notes: Optional[str]
created_at: datetime
updated_at: datetime
# Embedded track information
track: Optional[dict] = None
class LikedTrackCheckResponse(BaseModel):
"""Schema for checking if track is liked."""
is_liked: bool
# ============ LIBRARY STATS SCHEMAS ============
class LibraryStatsResponse(BaseModel):
"""Schema for library statistics response."""
liked_tracks_count: int
total_plays: int
plays_last_30_days: int
unique_tracks_played: int
class RecentlyPlayedResponse(BaseModel):
"""Schema for recently played tracks."""
tracks: List[dict]
total: int
class MostPlayedTrackResponse(BaseModel):
"""Schema for most played track response."""
track: dict
play_count: int
class MostPlayedTracksResponse(BaseModel):
"""Schema for most played tracks response."""
tracks: List[MostPlayedTrackResponse]
total: int
+436
View File
@@ -0,0 +1,436 @@
"""Library service."""
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from uuid import UUID
from sqlalchemy import select, delete, update, func, and_, desc
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.listening_history import ListeningHistory
from app.models.liked_track import LikedTrack
from app.models.track import Track
class LibraryService:
"""Service for library operations (listening history and liked tracks)."""
def __init__(self, db: AsyncSession):
self.db = db
# ============ LISTENING HISTORY METHODS ============
async def add_to_listening_history(
self,
user_id: UUID,
track_id: UUID,
played_for: int,
completed: bool = False,
source: Optional[str] = None,
) -> ListeningHistory:
"""
Add a track to user's listening history.
Args:
user_id: User UUID
track_id: Track UUID
played_for: Duration played in seconds
completed: Whether track was played to completion
source: Playback source (library, playlist, search, etc.)
Returns:
Created listening history entry
"""
history_entry = ListeningHistory(
user_id=user_id,
track_id=track_id,
played_for=played_for,
completed=completed,
source=source,
played_at=datetime.now(timezone.utc).replace(tzinfo=None),
)
self.db.add(history_entry)
# Update track play count atomically
update_stmt = (
update(Track)
.where(Track.id == track_id)
.values(play_count=Track.play_count + 1)
)
await self.db.execute(update_stmt)
await self.db.commit()
await self.db.refresh(history_entry)
return history_entry
async def get_listening_history(
self,
user_id: UUID,
limit: int = 50,
offset: int = 0,
days: Optional[int] = None,
) -> List[ListeningHistory]:
"""
Get user's listening history.
Args:
user_id: User UUID
limit: Maximum results
offset: Pagination offset
days: Filter by last N days (None for all time)
Returns:
List of listening history entries
"""
stmt = (
select(ListeningHistory)
.where(ListeningHistory.user_id == user_id)
.options(selectinload(ListeningHistory.track))
.order_by(desc(ListeningHistory.played_at))
)
if days is not None:
cutoff_date = (datetime.now(timezone.utc) - timedelta(days=days)).replace(tzinfo=None)
stmt = stmt.where(ListeningHistory.played_at >= cutoff_date)
stmt = stmt.limit(limit).offset(offset)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def get_recently_played(
self,
user_id: UUID,
limit: int = 20,
) -> List[Track]:
"""
Get user's recently played tracks (unique tracks).
Args:
user_id: User UUID
limit: Maximum results
Returns:
List of unique recently played tracks
"""
# Subquery to get most recent play for each track
subquery = (
select(
ListeningHistory.track_id,
func.max(ListeningHistory.played_at).label("last_played"),
)
.where(ListeningHistory.user_id == user_id)
.group_by(ListeningHistory.track_id)
.order_by(desc("last_played"))
.limit(limit)
.subquery()
)
# Main query to get track details
stmt = (
select(Track)
.join(subquery, Track.id == subquery.c.track_id)
.order_by(desc(subquery.c.last_played))
)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def get_most_played_tracks(
self,
user_id: UUID,
limit: int = 20,
days: Optional[int] = None,
) -> List[tuple[Track, int]]:
"""
Get user's most played tracks.
Args:
user_id: User UUID
limit: Maximum results
days: Filter by last N days (None for all time)
Returns:
List of tuples (track, play_count)
"""
stmt = (
select(
Track,
func.count(ListeningHistory.id).label("play_count"),
)
.join(ListeningHistory, Track.id == ListeningHistory.track_id)
.where(ListeningHistory.user_id == user_id)
.group_by(Track.id)
.order_by(desc("play_count"))
.limit(limit)
)
if days is not None:
cutoff_date = (datetime.now(timezone.utc) - timedelta(days=days)).replace(tzinfo=None)
stmt = stmt.where(ListeningHistory.played_at >= cutoff_date)
result = await self.db.execute(stmt)
return [(row[0], row[1]) for row in result.all()]
async def clear_listening_history(
self,
user_id: UUID,
before_date: Optional[datetime] = None,
) -> int:
"""
Clear user's listening history.
Args:
user_id: User UUID
before_date: Clear history before this date (None for all)
Returns:
Number of entries deleted
"""
stmt = delete(ListeningHistory).where(ListeningHistory.user_id == user_id)
if before_date is not None:
stmt = stmt.where(ListeningHistory.played_at < before_date)
result = await self.db.execute(stmt)
await self.db.commit()
return result.rowcount
# ============ LIKED TRACKS METHODS ============
async def like_track(
self,
user_id: UUID,
track_id: UUID,
notes: Optional[str] = None,
) -> LikedTrack:
"""
Add a track to user's liked tracks.
Args:
user_id: User UUID
track_id: Track UUID
notes: Optional user notes
Returns:
Created liked track entry
Raises:
ValueError: If track is already liked
"""
# Check if already liked
existing_stmt = select(LikedTrack).where(
and_(
LikedTrack.user_id == user_id,
LikedTrack.track_id == track_id,
)
)
existing_result = await self.db.execute(existing_stmt)
existing = existing_result.scalar_one_or_none()
if existing:
raise ValueError("Track is already in liked tracks")
liked_track = LikedTrack(
user_id=user_id,
track_id=track_id,
notes=notes,
)
self.db.add(liked_track)
await self.db.commit()
await self.db.refresh(liked_track)
return liked_track
async def unlike_track(
self,
user_id: UUID,
track_id: UUID,
) -> None:
"""
Remove a track from user's liked tracks.
Args:
user_id: User UUID
track_id: Track UUID
Raises:
ValueError: If track is not in liked tracks
"""
stmt = select(LikedTrack).where(
and_(
LikedTrack.user_id == user_id,
LikedTrack.track_id == track_id,
)
)
result = await self.db.execute(stmt)
liked_track = result.scalar_one_or_none()
if not liked_track:
raise ValueError("Track is not in liked tracks")
await self.db.delete(liked_track)
await self.db.commit()
async def get_liked_tracks(
self,
user_id: UUID,
limit: int = 50,
offset: int = 0,
) -> List[LikedTrack]:
"""
Get user's liked tracks.
Args:
user_id: User UUID
limit: Maximum results
offset: Pagination offset
Returns:
List of liked track entries
"""
stmt = (
select(LikedTrack)
.where(LikedTrack.user_id == user_id)
.options(selectinload(LikedTrack.track))
.order_by(desc(LikedTrack.created_at))
.limit(limit)
.offset(offset)
)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def check_track_liked(
self,
user_id: UUID,
track_id: UUID,
) -> bool:
"""
Check if a track is in user's liked tracks.
Args:
user_id: User UUID
track_id: Track UUID
Returns:
True if track is liked, False otherwise
"""
stmt = select(LikedTrack).where(
and_(
LikedTrack.user_id == user_id,
LikedTrack.track_id == track_id,
)
)
result = await self.db.execute(stmt)
liked_track = result.scalar_one_or_none()
return liked_track is not None
async def update_liked_track_notes(
self,
user_id: UUID,
track_id: UUID,
notes: str,
) -> LikedTrack:
"""
Update notes for a liked track.
Args:
user_id: User UUID
track_id: Track UUID
notes: New notes
Returns:
Updated liked track entry
Raises:
ValueError: If track is not in liked tracks
"""
stmt = select(LikedTrack).where(
and_(
LikedTrack.user_id == user_id,
LikedTrack.track_id == track_id,
)
)
result = await self.db.execute(stmt)
liked_track = result.scalar_one_or_none()
if not liked_track:
raise ValueError("Track is not in liked tracks")
liked_track.notes = notes
liked_track.updated_at = datetime.now(timezone.utc).replace(tzinfo=None)
await self.db.commit()
await self.db.refresh(liked_track)
return liked_track
# ============ LIBRARY STATISTICS METHODS ============
async def get_library_stats(
self,
user_id: UUID,
) -> dict:
"""
Get user's library statistics.
Args:
user_id: User UUID
Returns:
Dictionary with library statistics
"""
# Total liked tracks
liked_count_stmt = (
select(func.count())
.select_from(LikedTrack)
.where(LikedTrack.user_id == user_id)
)
liked_count_result = await self.db.execute(liked_count_stmt)
liked_count = liked_count_result.scalar()
# Total plays
total_plays_stmt = (
select(func.count())
.select_from(ListeningHistory)
.where(ListeningHistory.user_id == user_id)
)
total_plays_result = await self.db.execute(total_plays_stmt)
total_plays = total_plays_result.scalar()
# Plays in last 30 days
thirty_days_ago = (datetime.now(timezone.utc) - timedelta(days=30)).replace(tzinfo=None)
recent_plays_stmt = (
select(func.count())
.select_from(ListeningHistory)
.where(
and_(
ListeningHistory.user_id == user_id,
ListeningHistory.played_at >= thirty_days_ago,
)
)
)
recent_plays_result = await self.db.execute(recent_plays_stmt)
recent_plays = recent_plays_result.scalar()
# Unique tracks played
unique_tracks_stmt = (
select(func.count(func.distinct(ListeningHistory.track_id)))
.select_from(ListeningHistory)
.where(ListeningHistory.user_id == user_id)
)
unique_tracks_result = await self.db.execute(unique_tracks_stmt)
unique_tracks = unique_tracks_result.scalar()
return {
"liked_tracks_count": liked_count,
"total_plays": total_plays,
"plays_last_30_days": recent_plays,
"unique_tracks_played": unique_tracks,
}
+77 -1
View File
@@ -1,4 +1,5 @@
"""Music service."""
import logging
from typing import List, Optional
from uuid import UUID
@@ -8,6 +9,8 @@ from sqlalchemy.orm import selectinload
from app.models.track import Track
from app.models.artist import Artist
logger = logging.getLogger(__name__)
from app.models.album import Album
from app.services.youtube_service import YouTubeService
@@ -331,7 +334,7 @@ class MusicService:
async for chunk in response.aiter_bytes(chunk_size=8192):
yield chunk
except Exception as e:
print(f"Streaming error: {e}")
logger.error(f"Streaming error: {e}")
response_headers = {
"Accept-Ranges": "bytes",
@@ -356,3 +359,76 @@ class MusicService:
status_code=200,
headers=response_headers
)
async def get_trending(
self,
limit: int = 20,
days: int = 7,
) -> List[dict]:
"""
Get trending tracks based on play count and recent listens.
Args:
limit: Maximum number of tracks
days: Number of days to look back for trending
Returns:
List of trending tracks with metadata
"""
from datetime import datetime, timedelta
from app.models.listening_history import ListeningHistory
# Calculate date threshold
threshold = datetime.now() - timedelta(days=days)
# Get tracks with most plays in the recent period
# Count recent plays from ListeningHistory
from sqlalchemy import func
stmt = (
select(
Track.id,
Track.title,
Track.duration,
Track.youtube_id,
Track.image_url,
Track.play_count,
func.count(ListeningHistory.id).label("recent_plays"),
Artist.id.label("artist_id"),
Artist.name.label("artist_name"),
)
.join(Track.artist)
.outerjoin(
ListeningHistory,
(ListeningHistory.track_id == Track.id) &
(ListeningHistory.created_at >= threshold)
)
.group_by(Track.id, Artist.id)
.order_by(
func.count(ListeningHistory.id).desc(), # Order by recent plays
Track.created_at.desc()
)
.limit(limit)
)
result = await self.db.execute(stmt)
rows = result.all()
# Convert to dict format
tracks = []
for row in rows:
tracks.append({
"id": str(row.id),
"title": row.title,
"duration": row.duration,
"youtube_id": row.youtube_id,
"image_url": row.image_url,
"play_count": row.play_count,
"artist": {
"id": str(row.artist_id),
"name": row.artist_name
} if row.artist_id else None,
"artist_name": row.artist_name,
})
return tracks
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Diagnostic AudiOhm</title>
<style>
body { font-family: monospace; padding: 20px; background: #1a1a2e; color: #eee; }
.test { margin: 10px 0; padding: 10px; border: 1px solid #444; }
.pass { background: #1b4332; }
.fail { background: #4a1a1a; }
button { padding: 10px 20px; margin: 5px; cursor: pointer; }
pre { background: #0d0d1a; padding: 10px; overflow-x: auto; }
</style>
</head>
<body>
<h1>🔧 Diagnostic AudiOhm</h1>
<div class="test" id="test-api">Test API...</div>
<div class="test" id="test-auth">Test Auth...</div>
<div class="test" id="test-trending">Test Trending...</div>
<div class="test" id="test-stream">Test Stream URL...</div>
<h2>Actions</h2>
<button onclick="testAll()">Exécuter tous les tests</button>
<button onclick="testLogin()">Test Login</button>
<h2>Résultats</h2>
<pre id="output">Cliquez sur un bouton pour commencer...</pre>
<script>
let authToken = null;
function log(msg) {
const output = document.getElementById('output');
output.textContent += msg + '\n';
}
function updateStatus(id, passed, msg) {
const el = document.getElementById(id);
el.className = 'test ' + (passed ? 'pass' : 'fail');
el.textContent = msg;
}
async function testAPI() {
try {
const response = await fetch('/api/v1/music/trending?limit=1');
const data = await response.json();
updateStatus('test-api', response.ok, `API: ${response.status} - ${response.statusText}`);
log('✅ API accessible');
log('Données: ' + JSON.stringify(data[0], null, 2).substring(0, 200) + '...');
} catch (error) {
updateStatus('test-api', false, 'API: Error - ' + error.message);
log('❌ API error: ' + error.message);
}
}
async function testLogin() {
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'admin@example.com',
password: 'admin123'
})
});
const data = await response.json();
if (response.ok && data.access_token) {
authToken = data.access_token;
updateStatus('test-auth', true, 'Auth: ✅ Connecté');
log('✅ Login réussi');
log('Token: ' + authToken.substring(0, 20) + '...');
} else {
updateStatus('test-auth', false, 'Auth: ❌ ' + JSON.stringify(data));
log('❌ Login failed: ' + JSON.stringify(data));
}
} catch (error) {
updateStatus('test-auth', false, 'Auth: Error - ' + error.message);
log('❌ Auth error: ' + error.message);
}
}
async function testTrending() {
if (!authToken) {
await testLogin();
}
if (!authToken) {
updateStatus('test-trending', false, 'Trending: Pas de token');
return;
}
try {
const response = await fetch('/api/v1/music/trending?limit=2', {
headers: { 'Authorization': 'Bearer ' + authToken }
});
const data = await response.json();
updateStatus('test-trending', response.ok, `Trending: ${response.status} - ${data.length} pistes`);
log('✅ Trending: ' + data.length + ' pistes trouvées');
log('Piste 1: ' + data[0].title);
} catch (error) {
updateStatus('test-trending', false, 'Trending: Error - ' + error.message);
log('❌ Trending error: ' + error.message);
}
}
async function testStream() {
const youtubeId = 'NqDGkdDh8WE';
try {
const response = await fetch(`/api/v1/music/youtube/${youtubeId}/stream`);
const data = await response.json();
if (response.ok && data.stream_url) {
updateStatus('test-stream', true, 'Stream: ✅ URL obtenue');
log('✅ Stream URL obtenue');
log('URL: ' + data.stream_url.substring(0, 100) + '...');
} else {
updateStatus('test-stream', false, 'Stream: ❌ ' + JSON.stringify(data));
log('❌ Stream failed: ' + JSON.stringify(data));
}
} catch (error) {
updateStatus('test-stream', false, 'Stream: Error - ' + error.message);
log('❌ Stream error: ' + error.message);
}
}
async function testAll() {
document.getElementById('output').textContent = '=== Tests en cours ===\n';
await testAPI();
await testLogin();
await testTrending();
await testStream();
log('\n=== Tests terminés ===');
}
// Auto-run on load
window.onload = function() {
log('Page chargée - Prêt à tester');
log('Date: ' + new Date().toISOString());
};
</script>
</body>
</html>
+3228 -147
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html>
<head>
<title>Test AudiOhm</title>
</head>
<body>
<h1>Test API</h1>
<button onclick="testTrending()">Test Trending</button>
<button onclick="testStream()">Test Stream</button>
<pre id="output"></pre>
<script>
async function testTrending() {
const output = document.getElementById('output');
output.textContent = 'Testing trending...';
try {
const response = await fetch('/api/v1/music/trending?limit=1');
const data = await response.json();
output.textContent = 'Trending Response:\n' + JSON.stringify(data, null, 2);
} catch (error) {
output.textContent = 'Error: ' + error.message;
}
}
async function testStream() {
const output = document.getElementById('output');
output.textContent = 'Testing stream...';
try {
const response = await fetch('/api/v1/music/youtube/NqDGkdDh8WE/stream');
const data = await response.json();
output.textContent = 'Stream Response:\n' + JSON.stringify(data, null, 2);
} catch (error) {
output.textContent = 'Error: ' + error.message;
}
}
</script>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html>
<head>
<title>Test Functions</title>
</head>
<body>
<h1>Test des fonctions JavaScript</h1>
<div id="results"></div>
<script src="js/app.js"></script>
<script>
const results = document.getElementById('results');
function testFunction(name, exists) {
const div = document.createElement('div');
div.style.color = exists ? 'green' : 'red';
div.textContent = (exists ? '✅' : '❌') + ' ' + name;
results.appendChild(div);
}
// Tester les fonctions critiques
testFunction('switchLibraryTab', typeof window.switchLibraryTab === 'function');
testFunction('loadUserData', typeof window.loadUserData === 'function');
testFunction('playPrevious', typeof window.playPrevious === 'function');
testFunction('playNext', typeof window.playNext === 'function');
testFunction('togglePlayPause', typeof window.togglePlayPause === 'function');
testFunction('toggleShuffle', typeof window.toggleShuffle === 'function');
testFunction('toggleRepeat', typeof window.toggleRepeat === 'function');
testFunction('toggleMute', typeof window.toggleMute === 'function');
testFunction('handleSeek', typeof window.handleSeek === 'function');
testFunction('handleVolumeChange', typeof window.handleVolumeChange === 'function');
testFunction('updateProgress', typeof window.updateProgress === 'function');
testFunction('updateDuration', typeof window.updateDuration === 'function');
testFunction('handleTrackEnd', typeof window.handleTrackEnd === 'function');
testFunction('toggleLike', typeof window.toggleLike === 'function');
testFunction('loadPlaylists', typeof window.loadPlaylists === 'function');
testFunction('loadLikedTracks', typeof window.loadLikedTracks === 'function');
testFunction('loadListeningHistory', typeof window.loadListeningHistory === 'function');
testFunction('playTrack', typeof window.playTrack === 'function');
testFunction('createPlaylist', typeof window.createPlaylist === 'function');
</script>
</body>
</html>
+99
View File
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html>
<head>
<title>Test AudiOhm API</title>
<style>
body { font-family: Arial; padding: 20px; background: #1a1a1a; color: #fff; }
.test { margin: 20px 0; padding: 15px; background: #2a2a2a; border-radius: 8px; }
.pass { color: #4ade80; }
.fail { color: #f87171; }
pre { background: #1a1a1a; padding: 10px; overflow-x: auto; }
</style>
</head>
<body>
<h1>🧪 Test API AudiOhm</h1>
<div id="results"></div>
<script>
const results = document.getElementById('results');
async function testAPI() {
let token = localStorage.getItem('token');
if (!token) {
// Login first
addTest('POST /api/v1/auth/login', async () => {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'admin@example.com',
password: 'admin123'
})
});
const data = await response.json();
if (data.access_token) {
localStorage.setItem('token', data.access_token);
token = data.access_token;
return { status: '✅', token: token.substring(0, 20) + '...' };
}
throw new Error('No token');
});
}
// Test Playlists
await addTest('GET /api/v1/playlists', async () => {
const response = await fetch('/api/v1/playlists', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
return { status: response.ok ? '✅' : '❌', count: data.length, data: data };
});
// Test Trending
await addTest('GET /api/v1/music/trending', async () => {
const response = await fetch('/api/v1/music/trending', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
return { status: response.ok ? '✅' : '❌', count: data.length };
});
// Test Liked Tracks
await addTest('GET /api/v1/library/liked-tracks', async () => {
const response = await fetch('/api/v1/library/liked-tracks', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
if (data.detail) throw new Error(data.detail);
return { status: response.ok ? '✅' : '❌', count: data.length };
});
// Test History
await addTest('GET /api/v1/library/history', async () => {
const response = await fetch('/api/v1/library/history', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
if (data.detail) throw new Error(data.detail);
return { status: response.ok ? '✅' : '❌', count: data.length };
});
}
async function addTest(name, testFn) {
const div = document.createElement('div');
div.className = 'test';
results.appendChild(div);
try {
const result = await testFn();
div.innerHTML = `<span class="${result.status === '✅' ? 'pass' : 'fail'}">${result.status}</span> <strong>${name}</strong><br><pre>${JSON.stringify(result, null, 2)}</pre>`;
} catch (error) {
div.innerHTML = `<span class="fail">❌</span> <strong>${name}</strong><br><pre>${error.message}</pre>`;
}
}
testAPI();
</script>
</body>
</html>
+244
View File
@@ -0,0 +1,244 @@
<!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>
<!-- Toast Container -->
<div id="toast-container" class="toast-container"></div>
<!-- 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="email" id="login-email" placeholder="Email" required autocomplete="email">
</div>
<div class="form-group">
<input type="password" id="login-password" placeholder="Mot de passe" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-sign-in-alt"></i> 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">
<i class="fas fa-user-plus"></i> 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">
<!-- Mobile Menu Button -->
<button class="mobile-menu-btn" id="mobile-menu-btn">
<i class="fas fa-bars"></i>
</button>
<!-- Sidebar -->
<aside class="sidebar" id="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><i class="fas fa-bolt"></i> 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><i class="fas fa-fire"></i> Musiques tendance</h2>
<div class="track-list" id="trending-tracks">
<div class="loading">
<div class="spinner" style="width: 40px; height: 40px; margin: 0 auto 1rem;"></div>
Chargement...
</div>
</div>
</section>
<section class="section">
<h2><i class="fas fa-clock"></i> Récemment écoutées</h2>
<div class="track-list" id="recent-tracks">
<p style="color: var(--text-secondary);">Aucune écoute récente</p>
</div>
</section>
</div>
<!-- Search Page -->
<div id="search-page" class="page">
<div class="page-header">
<h1><i class="fas fa-search"></i> 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><i class="fas fa-music"></i> Ma Bibliothèque</h1>
</div>
<section class="section">
<h2><i class="fas fa-list"></i> Mes Playlists</h2>
<div class="playlist-list" id="my-playlists">
<div class="loading">
<div class="spinner" style="width: 40px; height: 40px; margin: 0 auto 1rem;"></div>
Chargement...
</div>
</div>
</section>
<section class="section">
<h2><i class="fas fa-heart"></i> Titres likés</h2>
<div class="track-list" id="liked-tracks">
<p style="color: var(--text-secondary);">Aucun titre liké pour le moment</p>
</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="shuffle-btn" title="Aléatoire">
<i class="fas fa-random"></i>
</button>
<button class="btn-control" id="prev-btn" title="Précédent">
<i class="fas fa-step-backward"></i>
</button>
<button class="btn-control btn-play" id="play-btn" title="Play/Pause">
<i class="fas fa-play"></i>
</button>
<button class="btn-control" id="next-btn" title="Suivant">
<i class="fas fa-step-forward"></i>
</button>
<button class="btn-control" id="repeat-btn" title="Répéter">
<i class="fas fa-redo"></i>
</button>
</div>
<div class="player-progress">
<span id="current-time" class="time">0:00</span>
<input type="range" id="progress-bar" min="0" max="100" value="0" class="progress-bar">
<span id="total-time" class="time">0:00</span>
</div>
<div class="player-volume">
<button class="btn-control" id="mute-btn" title="Muet">
<i class="fas fa-volume-up"></i>
</button>
<input type="range" id="volume-bar" min="0" max="100" value="100" class="volume-bar">
</div>
<div class="player-actions">
<button class="btn-control" id="like-btn" title="J'aime">
<i class="far fa-heart"></i>
</button>
<button class="btn-control" id="playlist-btn" title="Ajouter à la playlist">
<i class="fas fa-plus"></i>
</button>
</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>
+695 -157
View File
@@ -1,244 +1,782 @@
<!DOCTYPE html>
<html lang="fr">
<html lang="fr" class="dark">
<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">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
},
accent: {
50: '#fdf2f8',
100: '#fce7f3',
200: '#fbcfe8',
300: '#f9a8d4',
400: '#f472b6',
500: '#ec4899',
600: '#db2777',
700: '#be185d',
800: '#9d174d',
900: '#831843',
},
success: '#10b981',
warning: '#f59e0b',
error: '#ef4444',
}
}
}
}
</script>
<style>
/* Custom animations */
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideIn {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.animate-spin {
animation: spin 1s linear infinite;
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out;
}
.animate-slideIn {
animation: slideIn 0.3s ease-out;
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1f2937;
}
::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
/* Glassmorphism */
.glass {
background: rgba(17, 24, 39, 0.8);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.glass-card {
background: rgba(31, 41, 55, 0.95);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Range slider styling */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
}
input[type="range"]::-webkit-slider-track {
background: #374151;
height: 4px;
border-radius: 2px;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
margin-top: -4px;
background-color: #0ea5e9;
height: 12px;
width: 12px;
border-radius: 50%;
transition: all 0.2s;
}
input[type="range"]::-webkit-slider-thumb:hover {
background-color: #38bdf8;
transform: scale(1.2);
}
/* Larger slider for desktop */
@media (min-width: 640px) {
input[type="range"]::-webkit-slider-track {
height: 6px;
border-radius: 3px;
}
input[type="range"]::-webkit-slider-thumb {
height: 14px;
width: 14px;
}
}
/* Screen reader only utility */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.sr-only.focusable:focus {
position: static;
width: auto;
height: auto;
padding: inherit;
margin: inherit;
overflow: visible;
clip: auto;
white-space: normal;
}
/* Focus visible styles for keyboard navigation */
:focus-visible {
outline: 2px solid #0ea5e9;
outline-offset: 2px;
}
/* Better focus styles for interactive elements */
button:focus-visible,
a:focus-visible,
input:focus-visible,
[tabindex]:focus-visible {
outline: 2px solid #0ea5e9;
outline-offset: 2px;
}
/* Library Tabs Styles */
.library-tab {
background: rgba(31, 41, 55, 0.6);
color: #9ca3af;
border: 1px solid rgba(255, 255, 255, 0.08);
transition: all 0.2s;
}
.library-tab:hover {
background: rgba(55, 65, 81, 0.6);
color: #e5e7eb;
}
.library-tab.active {
background: rgba(14, 165, 233, 0.2);
color: #38bdf8;
border-color: rgba(56, 189, 248, 0.3);
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
</style>
</head>
<body>
<body class="bg-gradient-to-br from-gray-900 via-slate-900 to-gray-900 min-h-screen text-white font-sans">
<!-- Skip Link for Accessibility -->
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary-600 focus:text-white focus:rounded-lg">
Aller au contenu principal
</a>
<!-- Toast Container -->
<div id="toast-container" class="toast-container"></div>
<div id="toast-container" class="fixed top-4 right-4 z-50 flex flex-col gap-2" role="status" aria-live="polite" aria-atomic="true"></div>
<!-- App Container -->
<div id="app">
<div id="app" class="min-h-screen">
<!-- Loading Screen -->
<div id="loading-screen" class="loading-screen">
<div class="spinner"></div>
<h2>Chargement de AudiOhm...</h2>
<div id="loading-screen" class="fixed inset-0 bg-gray-900 flex flex-col items-center justify-center z-50" role="status" aria-live="polite" aria-busy="true">
<div class="relative w-16 h-16 mb-6">
<div class="absolute inset-0 border-4 border-primary-500/30 rounded-full"></div>
<div class="absolute inset-0 border-4 border-transparent border-t-primary-500 rounded-full animate-spin"></div>
</div>
<h2 class="text-2xl font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
Chargement de AudiOhm...
</h2>
<p class="text-gray-400 mt-2">Préparation de votre expérience musicale</p>
</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="email" id="login-email" placeholder="Email" required autocomplete="email">
<div id="login-screen" class="hidden fixed inset-0 bg-gradient-to-br from-gray-900 via-slate-900 to-gray-900 flex items-center justify-center p-4" role="dialog" aria-modal="true" aria-labelledby="login-title">
<div class="glass-card rounded-2xl p-8 w-full max-w-md animate-fadeIn">
<!-- Logo -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-gradient-to-br from-primary-500 to-accent-500 mb-4 shadow-lg shadow-primary-500/25" aria-hidden="true">
<i class="fas fa-headphones text-4xl text-white"></i>
</div>
<div class="form-group">
<input type="password" id="login-password" placeholder="Mot de passe" required autocomplete="current-password">
<h1 id="login-title" class="text-3xl font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
AudiOhm
</h1>
<p class="text-gray-400 mt-2">Votre musique, illimitée</p>
</div>
<!-- Login Form -->
<form id="login-form" class="space-y-4" aria-label="Formulaire de connexion">
<div>
<label for="login-email" class="block text-sm font-medium text-gray-300 mb-2">Email</label>
<div class="relative">
<i class="fas fa-envelope absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" aria-hidden="true"></i>
<input type="email" id="login-email" required
class="w-full pl-10 pr-4 py-3 bg-gray-800/50 border border-gray-700 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent focus:outline-none transition-all"
placeholder="vous@example.com" autocomplete="email" aria-describedby="login-email-hint">
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-sign-in-alt"></i> Se connecter
<div>
<label for="login-password" class="block text-sm font-medium text-gray-300 mb-2">Mot de passe</label>
<div class="relative">
<i class="fas fa-lock absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" aria-hidden="true"></i>
<input type="password" id="login-password" required
class="w-full pl-10 pr-4 py-3 bg-gray-800/50 border border-gray-700 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent focus:outline-none transition-all"
placeholder="••••••••" autocomplete="current-password">
</div>
</div>
<button type="submit"
class="w-full py-3 px-4 bg-gradient-to-r from-primary-600 to-primary-500 hover:from-primary-500 hover:to-primary-400 rounded-xl font-semibold transition-all transform hover:scale-[1.02] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-2 focus:ring-offset-gray-900 shadow-lg shadow-primary-500/25">
<i class="fas fa-sign-in-alt mr-2" aria-hidden="true"></i>
Se connecter
</button>
<p class="register-link">
Pas encore de compte ? <a href="#" id="show-register">Créer un compte</a>
</p>
<div class="text-center">
<p class="text-gray-400 text-sm">
Pas encore de compte ?
<button type="button" id="show-register" class="text-primary-400 hover:text-primary-300 font-medium transition-colors focus:outline-none focus:underline focus:ring-2 focus:ring-primary-400 rounded">
Créer un compte
</button>
</p>
</div>
</form>
<form id="register-form" class="login-form hidden">
<div class="form-group">
<input type="text" id="register-username" placeholder="Nom d'utilisateur" required>
<!-- Register Form -->
<form id="register-form" class="hidden space-y-4" aria-label="Formulaire d'inscription">
<div>
<label for="register-username" class="block text-sm font-medium text-gray-300 mb-2">Nom d'utilisateur</label>
<div class="relative">
<i class="fas fa-user absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" aria-hidden="true"></i>
<input type="text" id="register-username" required
class="w-full pl-10 pr-4 py-3 bg-gray-800/50 border border-gray-700 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent focus:outline-none transition-all"
placeholder="votre_pseudo" autocomplete="username">
</div>
</div>
<div class="form-group">
<input type="email" id="register-email" placeholder="Email" required>
<div>
<label for="register-email" class="block text-sm font-medium text-gray-300 mb-2">Email</label>
<div class="relative">
<i class="fas fa-envelope absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" aria-hidden="true"></i>
<input type="email" id="register-email" required
class="w-full pl-10 pr-4 py-3 bg-gray-800/50 border border-gray-700 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent focus:outline-none transition-all"
placeholder="vous@example.com" autocomplete="email">
</div>
</div>
<div class="form-group">
<input type="password" id="register-password" placeholder="Mot de passe" required>
<div>
<label for="register-password" class="block text-sm font-medium text-gray-300 mb-2">Mot de passe</label>
<div class="relative">
<i class="fas fa-lock absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" aria-hidden="true"></i>
<input type="password" id="register-password" required minlength="8"
class="w-full pl-10 pr-4 py-3 bg-gray-800/50 border border-gray-700 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent focus:outline-none transition-all"
placeholder="Min. 8 caractères" autocomplete="new-password" aria-describedby="password-requirements">
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-user-plus"></i> Créer un compte
<button type="submit"
class="w-full py-3 px-4 bg-gradient-to-r from-accent-600 to-accent-500 hover:from-accent-500 hover:to-accent-400 rounded-xl font-semibold transition-all transform hover:scale-[1.02] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-accent-400 focus:ring-offset-2 focus:ring-offset-gray-900 shadow-lg shadow-accent-500/25">
<i class="fas fa-user-plus mr-2" aria-hidden="true"></i>
Créer un compte
</button>
<p class="register-link">
Déjà un compte ? <a href="#" id="show-login">Se connecter</a>
</p>
<div class="text-center">
<p class="text-gray-400 text-sm">
Déjà un compte ?
<button type="button" id="show-login" class="text-accent-400 hover:text-accent-300 font-medium transition-colors focus:outline-none focus:underline focus:ring-2 focus:ring-accent-400 rounded">
Se connecter
</button>
</p>
</div>
</form>
<div id="auth-error" class="error-message hidden"></div>
<div id="auth-error" class="hidden mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
</div>
</div>
<!-- Main App -->
<div id="main-app" class="screen hidden">
<div id="main-app" class="hidden">
<!-- Mobile Menu Button -->
<button class="mobile-menu-btn" id="mobile-menu-btn">
<i class="fas fa-bars"></i>
<button id="mobile-menu-btn" class="lg:hidden fixed top-4 left-4 z-40 p-3 glass rounded-xl hover:bg-gray-800/50 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all" aria-label="Ouvrir le menu" aria-expanded="false" aria-controls="sidebar">
<i class="fas fa-bars text-xl" aria-hidden="true"></i>
</button>
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<h1 class="logo">
<i class="fas fa-headphones"></i> AudiOhm
</h1>
<aside id="sidebar" class="fixed left-0 top-0 h-full w-64 glass border-r border-gray-800 z-30 transform -translate-x-full lg:translate-x-0 transition-transform duration-300" aria-label="Navigation principale">
<div class="p-6">
<!-- Logo -->
<div class="flex items-center gap-3 mb-8">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-accent-500 flex items-center justify-center shadow-lg" aria-hidden="true">
<i class="fas fa-headphones text-white"></i>
</div>
<h1 class="text-xl font-bold">AudiOhm</h1>
</div>
<!-- Navigation -->
<nav class="space-y-2" aria-label="Navigation principale">
<a href="#" data-page="home" class="nav-item active flex items-center gap-3 px-4 py-3 rounded-xl bg-primary-500/10 text-primary-400 transition-all focus:outline-none focus:ring-2 focus:ring-primary-500" role="button" aria-current="page">
<i class="fas fa-home w-5" aria-hidden="true"></i>
<span>Accueil</span>
</a>
<a href="#" data-page="search" class="nav-item flex items-center gap-3 px-4 py-3 rounded-xl text-gray-400 hover:bg-gray-800/50 transition-all focus:outline-none focus:ring-2 focus:ring-primary-500" role="button">
<i class="fas fa-search w-5" aria-hidden="true"></i>
<span>Rechercher</span>
</a>
<a href="#" data-page="library" class="nav-item flex items-center gap-3 px-4 py-3 rounded-xl text-gray-400 hover:bg-gray-800/50 transition-all focus:outline-none focus:ring-2 focus:ring-primary-500" role="button">
<i class="fas fa-music w-5" aria-hidden="true"></i>
<span>Bibliothèque</span>
</a>
</nav>
</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
<!-- Logout -->
<div class="absolute bottom-0 left-0 right-0 p-6 border-t border-gray-800">
<button id="logout-btn" class="w-full flex items-center justify-center gap-2 px-4 py-3 text-gray-400 hover:text-white hover:bg-gray-800/50 rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-accent-500" aria-label="Se déconnecter">
<i class="fas fa-sign-out-alt" aria-hidden="true"></i>
<span>Déconnexion</span>
</button>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<main id="main-content" class="lg:ml-64 min-h-screen pb-20 sm:pb-32" tabindex="-1">
<!-- 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 id="home-page" class="page active p-4 sm:p-6 lg:p-10 pt-16 sm:pt-6 lg:pt-10">
<!-- Header -->
<div class="mb-6 sm:mb-8">
<h1 class="text-2xl sm:text-3xl lg:text-4xl font-bold mb-2">
<span class="bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
Bienvenue sur AudiOhm
</span>
<span class="text-xl sm:text-2xl"> 🎵</span>
</h1>
<p class="text-sm sm:text-base text-gray-400">Votre alternative à Spotify avec streaming YouTube</p>
</div>
<section class="section">
<h2><i class="fas fa-bolt"></i> 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>
<!-- Quick Search -->
<section class="mb-8 sm:mb-10" aria-labelledby="quick-search-heading">
<h2 id="quick-search-heading" class="text-lg sm:text-xl font-semibold mb-3 sm:mb-4 flex items-center gap-2">
<i class="fas fa-bolt text-primary-400" aria-hidden="true"></i>
Recherche rapide
</h2>
<div class="flex flex-col sm:flex-row gap-2 sm:gap-3">
<label for="quick-search" class="sr-only">Rechercher une musique</label>
<input type="search" id="quick-search"
class="flex-1 px-3 sm:px-4 py-2 sm:py-3 bg-gray-800/50 border border-gray-700 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent focus:outline-none transition-all text-sm"
placeholder="Rechercher une musique, un artiste..." aria-describedby="quick-search-hint">
<button id="quick-search-btn" class="px-4 sm:px-6 py-2 sm:py-3 bg-primary-600 hover:bg-primary-500 rounded-xl font-medium transition-all focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-2 focus:ring-offset-gray-900 min-h-[44px] sm:min-h-[48px]" aria-label="Lancer la recherche">
<i class="fas fa-search text-sm sm:text-base" aria-hidden="true"></i>
<span class="sr-only">Rechercher</span>
</button>
</div>
</section>
<section class="section">
<h2><i class="fas fa-fire"></i> Musiques tendance</h2>
<div class="track-list" id="trending-tracks">
<div class="loading">
<div class="spinner" style="width: 40px; height: 40px; margin: 0 auto 1rem;"></div>
Chargement...
<!-- Trending -->
<section class="mb-8 sm:mb-10">
<h2 class="text-lg sm:text-xl font-semibold mb-3 sm:mb-4 flex items-center gap-2">
<i class="fas fa-fire text-accent-400"></i>
Musiques tendance
</h2>
<div id="trending-tracks" class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 sm:gap-4">
<div class="flex flex-col items-center justify-center py-16 sm:py-20">
<div class="w-10 h-10 sm:w-12 sm:h-12 border-4 border-primary-500/30 border-t-primary-500 rounded-full animate-spin mb-3 sm:mb-4"></div>
<p class="text-sm sm:text-base text-gray-400">Chargement...</p>
</div>
</div>
</section>
<section class="section">
<h2><i class="fas fa-clock"></i> Récemment écoutées</h2>
<div class="track-list" id="recent-tracks">
<p style="color: var(--text-secondary);">Aucune écoute récente</p>
<!-- Recent -->
<section>
<h2 class="text-lg sm:text-xl font-semibold mb-3 sm:mb-4 flex items-center gap-2">
<i class="fas fa-clock text-primary-400"></i>
Récemment écoutées
</h2>
<div id="recent-tracks" class="text-sm sm:text-base text-gray-400">
<p>Aucune écoute récente</p>
</div>
</section>
</div>
<!-- Search Page -->
<div id="search-page" class="page">
<div class="page-header">
<h1><i class="fas fa-search"></i> Recherche</h1>
</div>
<div id="search-page" class="page hidden p-4 sm:p-6 lg:p-10 pt-16 sm:pt-6 lg:pt-10">
<h1 class="text-2xl sm:text-3xl font-bold mb-4 sm:mb-6 flex items-center gap-2 sm:gap-3">
<i class="fas fa-search text-primary-400" aria-hidden="true"></i>
Recherche
</h1>
<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
<div class="flex flex-col sm:flex-row gap-2 sm:gap-3 mb-6 sm:mb-8">
<label for="search-input" class="sr-only">Rechercher de la musique</label>
<input type="search" id="search-input"
class="flex-1 px-3 sm:px-4 py-2 sm:py-3 bg-gray-800/50 border border-gray-700 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent focus:outline-none transition-all text-sm"
placeholder="Que voulez-vous écouter ?" aria-describedby="search-hint">
<button id="search-btn" class="px-4 sm:px-8 py-2 sm:py-3 bg-gradient-to-r from-primary-600 to-primary-500 hover:from-primary-500 hover:to-primary-400 rounded-xl font-semibold transition-all focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-2 focus:ring-offset-gray-900 min-h-[44px] sm:min-h-[48px] text-sm sm:text-base">
<i class="fas fa-search mr-0 sm:mr-2" aria-hidden="true"></i>
<span class="hidden sm:inline">Rechercher</span>
</button>
</div>
<div id="search-results" class="search-results"></div>
<div id="search-results" role="list" aria-label="Résultats de recherche" class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 sm:gap-4"></div>
</div>
<!-- Library Page -->
<div id="library-page" class="page">
<div class="page-header">
<h1><i class="fas fa-music"></i> Ma Bibliothèque</h1>
<div id="library-page" class="page hidden p-4 sm:p-6 lg:p-10 pt-16 sm:pt-6 lg:pt-10">
<h1 class="text-2xl sm:text-3xl font-bold mb-4 sm:mb-6 flex items-center gap-2 sm:gap-3">
<i class="fas fa-music text-accent-400"></i>
Ma Bibliothèque
</h1>
<!-- Tabs Navigation -->
<div class="flex gap-2 mb-6 overflow-x-auto pb-2" role="tablist" aria-label="Onglets de la bibliothèque">
<button id="tab-playlists" class="library-tab active px-4 py-2 rounded-lg font-medium transition-all whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-primary-500"
role="tab"
aria-selected="true"
aria-controls="library-playlists"
onclick="switchLibraryTab('playlists')">
<i class="fas fa-list mr-2"></i>
<span class="hidden sm:inline">Playlists</span>
<span class="sm:hidden">Playlists</span>
</button>
<button id="tab-liked" class="library-tab px-4 py-2 rounded-lg font-medium transition-all whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-primary-500"
role="tab"
aria-selected="false"
aria-controls="library-liked"
onclick="switchLibraryTab('liked')">
<i class="fas fa-heart mr-2"></i>
<span class="hidden sm:inline">Titres likés</span>
<span class="sm:hidden">Likés</span>
</button>
<button id="tab-history" class="library-tab px-4 py-2 rounded-lg font-medium transition-all whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-primary-500"
role="tab"
aria-selected="false"
aria-controls="library-history"
onclick="switchLibraryTab('history')">
<i class="fas fa-history mr-2"></i>
<span class="hidden sm:inline">Historique</span>
<span class="sm:hidden">Historique</span>
</button>
</div>
<section class="section">
<h2><i class="fas fa-list"></i> Mes Playlists</h2>
<div class="playlist-list" id="my-playlists">
<div class="loading">
<div class="spinner" style="width: 40px; height: 40px; margin: 0 auto 1rem;"></div>
Chargement...
</div>
<!-- Tab Panels -->
<div class="tab-panels">
<!-- Playlists Tab -->
<div id="library-playlists" class="tab-panel active" role="tabpanel" aria-labelledby="tab-playlists">
<section class="mb-8 sm:mb-10">
<div class="flex items-center justify-between mb-3 sm:mb-4">
<h2 class="text-lg sm:text-xl font-semibold flex items-center gap-2">
<i class="fas fa-list text-primary-400"></i>
Mes Playlists
</h2>
<button id="create-playlist-btn" class="px-3 sm:px-4 py-2 bg-primary-600 hover:bg-primary-500 rounded-lg font-medium transition-all text-sm flex items-center gap-2 focus:outline-none focus:ring-2 focus:ring-primary-400" aria-label="Créer une nouvelle playlist">
<i class="fas fa-plus"></i>
<span class="hidden sm:inline">Créer</span>
</button>
</div>
<div id="my-playlists" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
<div class="flex flex-col items-center justify-center py-16 sm:py-20">
<div class="w-10 h-10 sm:w-12 sm:h-12 border-4 border-primary-500/30 border-t-primary-500 rounded-full animate-spin mb-3 sm:mb-4"></div>
<p class="text-sm sm:text-base text-gray-400">Chargement...</p>
</div>
</div>
</section>
</div>
</section>
<section class="section">
<h2><i class="fas fa-heart"></i> Titres likés</h2>
<div class="track-list" id="liked-tracks">
<p style="color: var(--text-secondary);">Aucun titre liké pour le moment</p>
<!-- Liked Tracks Tab -->
<div id="library-liked" class="tab-panel hidden" role="tabpanel" aria-labelledby="tab-liked">
<section>
<h2 class="text-lg sm:text-xl font-semibold mb-3 sm:mb-4 flex items-center gap-2">
<i class="fas fa-heart text-accent-400"></i>
Titres likés
</h2>
<div id="liked-tracks" class="space-y-2 max-w-4xl">
<div class="flex flex-col items-center justify-center py-16 sm:py-20">
<div class="w-10 h-10 sm:w-12 sm:h-12 border-4 border-accent-500/30 border-t-accent-500 rounded-full animate-spin mb-3 sm:mb-4"></div>
<p class="text-sm sm:text-base text-gray-400">Chargement...</p>
</div>
</div>
</section>
</div>
</section>
<!-- Listening History Tab -->
<div id="library-history" class="tab-panel hidden" role="tabpanel" aria-labelledby="tab-history">
<section>
<h2 class="text-lg sm:text-xl font-semibold mb-3 sm:mb-4 flex items-center gap-2">
<i class="fas fa-history text-primary-400"></i>
Historique d'écoute
</h2>
<div id="listening-history" class="space-y-2 max-w-4xl">
<div class="flex flex-col items-center justify-center py-16 sm:py-20">
<div class="w-10 h-10 sm:w-12 sm:h-12 border-4 border-primary-500/30 border-t-primary-500 rounded-full animate-spin mb-3 sm:mb-4"></div>
<p class="text-sm sm:text-base text-gray-400">Chargement...</p>
</div>
</div>
</section>
</div>
</div>
</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 id="player" class="hidden fixed bottom-0 left-0 right-0 glass border-t border-gray-800 px-2 sm:px-4 py-2 sm:py-3 z-40" role="region" aria-label="Lecteur audio">
<!-- Mobile Compact View -->
<div class="sm:hidden flex items-center gap-2">
<!-- Track Info (Mobile) -->
<div class="flex items-center gap-2 flex-1 min-w-0">
<img id="player-cover" src="/static/img/default-cover.png" alt=""
class="w-10 h-10 rounded-lg object-cover bg-gray-800 flex-shrink-0" aria-hidden="true">
<button id="mobile-play-btn" class="p-2 bg-primary-600 rounded-full flex-shrink-0" aria-label="Lecture/Pause">
<i class="fas fa-play text-xs"></i>
</button>
<div class="min-w-0 flex-1">
<div id="player-title" class="font-medium text-xs truncate" aria-live="polite">Aucun titre</div>
<div id="player-artist" class="text-xs text-gray-400 truncate" aria-live="polite">-</div>
</div>
</div>
<!-- Actions (Mobile) -->
<div class="flex items-center gap-1 flex-shrink-0">
<button id="mobile-like-btn" class="p-2 text-gray-400 hover:text-accent-400 transition-all" aria-label="J'aime">
<i class="far fa-heart text-sm"></i>
</button>
<button id="mobile-expand-btn" class="p-2 text-gray-400 hover:text-white transition-all" aria-label="Agrandir le player">
<i class="fas fa-chevron-up text-sm"></i>
</button>
</div>
</div>
<div class="player-controls">
<button class="btn-control" id="shuffle-btn" title="Aléatoire">
<i class="fas fa-random"></i>
</button>
<button class="btn-control" id="prev-btn" title="Précédent">
<i class="fas fa-step-backward"></i>
</button>
<button class="btn-control btn-play" id="play-btn" title="Play/Pause">
<i class="fas fa-play"></i>
</button>
<button class="btn-control" id="next-btn" title="Suivant">
<i class="fas fa-step-forward"></i>
</button>
<button class="btn-control" id="repeat-btn" title="Répéter">
<i class="fas fa-redo"></i>
</button>
<!-- Desktop Full View -->
<div class="hidden sm:flex items-center gap-2 lg:gap-4 max-w-screen-2xl mx-auto">
<!-- Track Info -->
<div class="flex items-center gap-2 lg:gap-3 flex-shrink-0 w-32 lg:w-64">
<img id="player-cover-desktop" src="/static/img/default-cover.png" alt=""
class="w-10 h-10 lg:w-14 lg:h-14 rounded-lg object-cover bg-gray-800 flex-shrink-0" aria-hidden="true">
<div class="min-w-0 flex-1 hidden sm:block">
<div id="player-title-desktop" class="font-medium text-xs lg:text-sm truncate" aria-live="polite">Aucun titre</div>
<div id="player-artist-desktop" class="text-xs text-gray-400 truncate" aria-live="polite">-</div>
</div>
</div>
<!-- Controls -->
<div class="flex-1 flex flex-col items-center gap-1 lg:gap-2">
<!-- Main Controls -->
<div class="flex items-center gap-1 lg:gap-2">
<button id="shuffle-btn" class="p-1.5 lg:p-3 text-gray-400 hover:text-white hover:bg-gray-800/50 rounded-lg transition-all focus:outline-none focus:ring-2 focus:ring-primary-500 min-w-[36px] lg:min-w-[44px] min-h-[36px] lg:min-h-[44px] flex items-center justify-center" aria-label="Mode aléatoire" aria-pressed="false">
<i class="fas fa-random text-sm lg:text-base" aria-hidden="true"></i>
</button>
<button id="prev-btn" class="p-1.5 lg:p-3 text-gray-400 hover:text-white hover:bg-gray-800/50 rounded-lg transition-all focus:outline-none focus:ring-2 focus:ring-primary-500 min-w-[36px] lg:min-w-[44px] min-h-[36px] lg:min-h-[44px] flex items-center justify-center" aria-label="Piste précédente">
<i class="fas fa-step-backward text-sm lg:text-base" aria-hidden="true"></i>
</button>
<button id="play-btn" class="p-2 lg:p-4 bg-primary-600 hover:bg-primary-500 rounded-full transition-all transform hover:scale-110 focus:outline-none focus:ring-4 focus:ring-primary-500/50 min-w-[40px] lg:min-w-[52px] min-h-[40px] lg:min-h-[52px] flex items-center justify-center" aria-label="Lecture" aria-pressed="false">
<i class="fas fa-play text-sm lg:text-base" aria-hidden="true"></i>
</button>
<button id="next-btn" class="p-1.5 lg:p-3 text-gray-400 hover:text-white hover:bg-gray-800/50 rounded-lg transition-all focus:outline-none focus:ring-2 focus:ring-primary-500 min-w-[36px] lg:min-w-[44px] min-h-[36px] lg:min-h-[44px] flex items-center justify-center" aria-label="Piste suivante">
<i class="fas fa-step-forward text-sm lg:text-base" aria-hidden="true"></i>
</button>
<button id="repeat-btn" class="p-1.5 lg:p-3 text-gray-400 hover:text-white hover:bg-gray-800/50 rounded-lg transition-all focus:outline-none focus:ring-2 focus:ring-primary-500 min-w-[36px] lg:min-w-[44px] min-h-[36px] lg:min-h-[44px] flex items-center justify-center" aria-label="Répéter" aria-pressed="false">
<i class="fas fa-redo text-sm lg:text-base" aria-hidden="true"></i>
</button>
</div>
<!-- Progress -->
<div class="flex items-center gap-2 lg:gap-3 w-full max-w-xl px-2">
<span id="current-time" class="text-xs text-gray-400 w-8 lg:w-10 text-right flex-shrink-0" aria-live="off" aria-label="Temps écoulé">0:00</span>
<label for="progress-bar" class="sr-only">Barre de progression</label>
<input type="range" id="progress-bar" min="0" max="100" value="0"
class="flex-1 h-1" aria-label="Progression de la lecture" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-valuetext="0%">
<span id="total-time" class="text-xs text-gray-400 w-8 lg:w-10 flex-shrink-0" aria-live="off" aria-label="Durée totale">0:00</span>
</div>
</div>
<!-- Volume & Actions -->
<div class="flex items-center gap-1 lg:gap-2 flex-shrink-0">
<button id="mute-btn" class="p-1.5 lg:p-3 text-gray-400 hover:text-white transition-all focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg min-w-[36px] lg:min-w-[44px] min-h-[36px] lg:min-h-[44px] flex items-center justify-center" aria-label="Couper le son" aria-pressed="false">
<i class="fas fa-volume-up text-sm lg:text-base" aria-hidden="true"></i>
</button>
<label for="volume-bar" class="sr-only">Volume</label>
<input type="range" id="volume-bar" min="0" max="100" value="100"
class="w-12 lg:w-20 hidden md:block" aria-label="Volume" aria-valuemin="0" aria-valuemax="100" aria-valuenow="100" aria-valuetext="100%">
<div class="w-px h-6 lg:h-8 bg-gray-700 mx-1 lg:mx-2 hidden md:block" aria-hidden="true"></div>
<button id="like-btn" class="p-1.5 lg:p-3 text-gray-400 hover:text-accent-400 transition-all focus:outline-none focus:ring-2 focus:ring-accent-500 rounded-lg min-w-[36px] lg:min-w-[44px] min-h-[36px] lg:min-h-[44px] flex items-center justify-center" aria-label="J'aime" aria-pressed="false">
<i class="far fa-heart text-sm lg:text-base" aria-hidden="true"></i>
</button>
<button id="queue-open-btn" class="p-1.5 lg:p-3 text-gray-400 hover:text-white transition-all focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg min-w-[36px] lg:min-w-[44px] min-h-[36px] lg:min-h-[44px] flex items-center justify-center relative" aria-label="File d'attente" aria-expanded="false">
<i class="fas fa-list-ul text-sm lg:text-base" aria-hidden="true"></i>
<span id="queue-count" class="absolute -top-1 -right-1 bg-primary-600 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center font-bold">0</span>
</button>
</div>
</div>
<div class="player-progress">
<span id="current-time" class="time">0:00</span>
<input type="range" id="progress-bar" min="0" max="100" value="0" class="progress-bar">
<span id="total-time" class="time">0:00</span>
<audio id="audio-player" preload="none" class="hidden"></audio>
</div>
<!-- Create Playlist Modal -->
<div id="create-playlist-modal" class="hidden fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" role="dialog" aria-labelledby="create-playlist-title" aria-modal="true" aria-hidden="true">
<div class="glass-card rounded-2xl p-6 w-full max-w-md animate-fadeIn">
<div class="flex items-center justify-between mb-6">
<h2 id="create-playlist-title" class="text-xl font-bold">Créer une playlist</h2>
<button id="close-create-playlist-modal" class="p-2 text-gray-400 hover:text-white transition-all rounded-lg hover:bg-gray-700/50 focus:outline-none focus:ring-2 focus:ring-primary-500" aria-label="Fermer">
<i class="fas fa-times"></i>
</button>
</div>
<form id="create-playlist-form" aria-label="Formulaire de création de playlist">
<div class="mb-4">
<label for="playlist-name" class="block text-sm font-medium text-gray-300 mb-2">Nom de la playlist *</label>
<input type="text" id="playlist-name" required
class="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent focus:outline-none transition-all"
placeholder="Ma nouvelle playlist" aria-describedby="playlist-name-hint">
</div>
<div class="mb-6">
<label for="playlist-description" class="block text-sm font-medium text-gray-300 mb-2">Description (optionnel)</label>
<textarea id="playlist-description" rows="3"
class="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent focus:outline-none transition-all resize-none"
placeholder="Décrivez votre playlist..."></textarea>
</div>
<div class="flex gap-3">
<button type="button" id="cancel-create-playlist"
class="flex-1 px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-xl font-medium transition-all focus:outline-none focus:ring-2 focus:ring-gray-500">
Annuler
</button>
<button type="submit"
class="flex-1 px-4 py-3 bg-gradient-to-r from-primary-600 to-primary-500 hover:from-primary-500 hover:to-primary-400 rounded-xl font-semibold transition-all transform hover:scale-[1.02] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-primary-400 shadow-lg">
Créer
</button>
</div>
</form>
</div>
</div>
<!-- Playlist Details Modal -->
<div id="playlist-details-modal" class="hidden fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" role="dialog" aria-labelledby="playlist-details-title" aria-modal="true" aria-hidden="true">
<div class="glass-card rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden animate-fadeIn flex flex-col">
<!-- Header -->
<div class="p-6 border-b border-gray-800">
<div class="flex items-center justify-between mb-4">
<h2 id="playlist-details-title" class="text-xl font-bold truncate flex-1">Titre de la playlist</h2>
<button id="close-playlist-details" class="p-2 text-gray-400 hover:text-white transition-all rounded-lg hover:bg-gray-700/50 focus:outline-none focus:ring-2 focus:ring-primary-500 ml-2" aria-label="Fermer">
<i class="fas fa-times"></i>
</button>
</div>
<p id="playlist-details-description" class="text-gray-400 text-sm mb-4"></p>
<div class="flex items-center gap-3">
<button id="play-playlist-btn" class="px-4 py-2 bg-primary-600 hover:bg-primary-500 rounded-lg font-medium transition-all flex items-center gap-2 focus:outline-none focus:ring-2 focus:ring-primary-400">
<i class="fas fa-play"></i>
Lecture
</button>
<button id="shuffle-playlist-btn" class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg font-medium transition-all flex items-center gap-2 focus:outline-none focus:ring-2 focus:ring-gray-500">
<i class="fas fa-random"></i>
Aléatoire
</button>
</div>
</div>
<!-- Tracks -->
<div id="playlist-tracks" class="flex-1 overflow-y-auto p-4">
<div class="flex flex-col items-center justify-center py-12 text-gray-400">
<i class="fas fa-music text-4xl mb-4"></i>
<p class="text-lg">Aucune piste</p>
</div>
</div>
</div>
</div>
<!-- Queue Panel -->
<div id="queue-panel" class="fixed inset-y-0 right-0 w-full sm:w-96 glass border-l border-gray-800 z-50 transform translate-x-full transition-transform duration-300 ease-out" role="dialog" aria-labelledby="queue-title" aria-hidden="true">
<!-- Header -->
<div class="p-4 sm:p-6 border-b border-gray-800">
<div class="flex items-center justify-between mb-4">
<h2 id="queue-title" class="text-lg sm:text-xl font-bold flex items-center gap-2">
<i class="fas fa-list-ul text-primary-400"></i>
File d'attente
<span id="queue-count-badge" class="text-sm font-normal text-gray-400">(0)</span>
</h2>
<button id="queue-close-btn" class="p-2 text-gray-400 hover:text-white transition-all focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg" aria-label="Fermer la file d'attente">
<i class="fas fa-times text-lg"></i>
</button>
</div>
<!-- Queue Actions -->
<div class="flex items-center gap-2">
<button id="queue-shuffle-btn" class="flex-1 px-4 py-2 bg-gray-800/50 hover:bg-gray-700/50 text-gray-300 hover:text-white rounded-lg transition-all text-sm font-medium focus:outline-none focus:ring-2 focus:ring-primary-500 flex items-center justify-center gap-2" aria-label="Mélanger la file d'attente">
<i class="fas fa-random"></i>
Mélanger
</button>
<button id="queue-clear-btn" class="flex-1 px-4 py-2 bg-gray-800/50 hover:bg-red-600/30 text-gray-300 hover:text-red-400 rounded-lg transition-all text-sm font-medium focus:outline-none focus:ring-2 focus:ring-red-500 flex items-center justify-center gap-2" aria-label="Vider la file d'attente">
<i class="fas fa-trash-alt"></i>
Vider
</button>
</div>
</div>
<div class="player-volume">
<button class="btn-control" id="mute-btn" title="Muet">
<i class="fas fa-volume-up"></i>
</button>
<input type="range" id="volume-bar" min="0" max="100" value="100" class="volume-bar">
<!-- Queue List -->
<div id="queue-list" class="p-4 overflow-y-auto" style="max-height: calc(100vh - 200px);" role="list" aria-label="Pistes dans la file d'attente">
<!-- Queue items will be dynamically inserted here -->
<div class="flex flex-col items-center justify-center py-12 text-gray-400">
<i class="fas fa-list-ul text-4xl mb-4"></i>
<p class="text-lg">File d'attente vide</p>
<p class="text-sm mt-2">Cliquez sur une piste pour l'ajouter</p>
</div>
</div>
<div class="player-actions">
<button class="btn-control" id="like-btn" title="J'aime">
<i class="far fa-heart"></i>
</button>
<button class="btn-control" id="playlist-btn" title="Ajouter à la playlist">
<i class="fas fa-plus"></i>
</button>
</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>