Files
AudiOhm/frontend/lib/presentation/pages/library/library_mobile_page.dart
T
root a89c7894cf Initial commit: AudiOhm - Alternative Spotify avec streaming YouTube
Backend:
- FastAPI avec PostgreSQL et Redis
- Authentification JWT complète
- API REST pour musique, playlists, recherche
- Streaming audio via yt-dlp
- SQLAlchemy 2.0 async

Frontend:
- Flutter avec thème néon cyberpunk
- State management Riverpod
- Layout adaptatif desktop/mobile
- Lecteur audio avec mini-player

Infrastructure:
- Docker Compose (PostgreSQL + Redis)
- Scripts d'installation automatisés
- Scripts de build pour exécutables

Fichiers ajoutés:
- BUILD_CLIENT_*.bat/sh: Scripts de compilation
- BUILD_CLIENT_README.md: Documentation compilation
- CHECK_FLUTTER.sh: Vérificateur d'environnement
- requirements.txt mis à jour pour Python 3.13
- Modèles SQLAlchemy corrigés (metadata -> extra_metadata)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 20:08:36 +00:00

581 lines
16 KiB
Dart

/// Library Page - Mobile Layout
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/colors.dart';
import '../../providers/library_provider.dart';
import '../../widgets/library/playlist_tile.dart';
import '../../../domain/entities/track.dart';
import '../../../domain/entities/album.dart';
import '../../../domain/entities/artist.dart';
class LibraryMobilePage extends ConsumerStatefulWidget {
const LibraryMobilePage({super.key});
@override
ConsumerState<LibraryMobilePage> createState() =>
_LibraryMobilePageState();
}
class _LibraryMobilePageState extends ConsumerState<LibraryMobilePage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
// Load library on init
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(libraryProvider.notifier).loadLibrary();
});
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final libraryState = ref.watch(libraryProvider);
return Column(
children: [
// Header with title
_buildHeader(libraryState),
// Tab bar
_buildTabBar(),
// Content based on selected tab
Expanded(
child: _buildContent(libraryState),
),
],
);
}
Widget _buildHeader(dynamic libraryState) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.surfaceVariant.withOpacity(0.5),
border: Border(
bottom: BorderSide(
color: AppColors.cyan.withOpacity(0.2),
width: 1,
),
),
),
child: Row(
children: [
const Icon(
Icons.library_music,
color: AppColors.cyan,
size: 24,
),
const SizedBox(width: 12),
const Text(
'Your Library',
style: TextStyle(
color: AppColors.onSurface,
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
if (libraryState.totalItems > 0)
IconButton(
icon: const Icon(Icons.refresh, color: AppColors.cyan),
onPressed: () {
ref.read(libraryProvider.notifier).refresh();
},
tooltip: 'Refresh',
iconSize: 20,
),
],
),
);
}
Widget _buildTabBar() {
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
border: Border(
bottom: BorderSide(
color: AppColors.cyan.withOpacity(0.2),
width: 1,
),
),
),
child: TabBar(
controller: _tabController,
indicatorColor: AppColors.cyan,
labelColor: AppColors.cyan,
unselectedLabelColor: AppColors.muted,
labelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
tabs: const [
Tab(text: 'Playlists'),
Tab(text: 'Songs'),
Tab(text: 'Albums'),
Tab(text: 'Artists'),
],
),
);
}
Widget _buildContent(dynamic libraryState) {
if (libraryState.isLoading) {
return const Center(
child: CircularProgressIndicator(color: AppColors.cyan),
);
}
if (libraryState.error != null) {
return _buildErrorState(libraryState.error ?? 'Unknown error');
}
return TabBarView(
controller: _tabController,
children: [
_buildPlaylistsTab(libraryState.playlists),
_buildLikedSongsTab(libraryState.likedSongs),
_buildAlbumsTab(libraryState.savedAlbums),
_buildArtistsTab(libraryState.followedArtists),
],
);
}
Widget _buildPlaylistsTab(List<dynamic> playlists) {
if (playlists.isEmpty) {
return _buildEmptyState(
icon: Icons.playlist_play,
message: 'No playlists yet',
submessage: 'Create your first playlist to get started',
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: playlists.length,
itemBuilder: (context, index) {
final playlist = playlists[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: PlaylistTile(
playlist: playlist,
onTap: () => _openPlaylist(playlist),
canDelete: true,
onDelete: () => _confirmDeletePlaylist(playlist),
),
);
},
);
}
Widget _buildLikedSongsTab(List<dynamic> likedSongs) {
if (likedSongs.isEmpty) {
return _buildEmptyState(
icon: Icons.favorite_border,
message: 'No liked songs',
submessage: 'Like songs to see them here',
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: likedSongs.length,
itemBuilder: (context, index) {
final track = likedSongs[index] as Track;
return _buildTrackTile(track, index);
},
);
}
Widget _buildAlbumsTab(List<dynamic> albums) {
if (albums.isEmpty) {
return _buildEmptyState(
icon: Icons.album,
message: 'No saved albums',
submessage: 'Save albums to see them here',
);
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1,
),
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index] as Album;
return _buildAlbumCard(album);
},
);
}
Widget _buildArtistsTab(List<dynamic> artists) {
if (artists.isEmpty) {
return _buildEmptyState(
icon: Icons.person,
message: 'No followed artists',
submessage: 'Follow artists to see them here',
);
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1,
),
itemCount: artists.length,
itemBuilder: (context, index) {
final artist = artists[index] as Artist;
return _buildArtistCard(artist);
},
);
}
Widget _buildTrackTile(Track track, int index) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.cyan.withOpacity(0.1),
),
),
child: ListTile(
leading: Text(
'${index + 1}',
style: const TextStyle(
color: AppColors.muted,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
title: Text(
track.title,
style: const TextStyle(
color: AppColors.onSurface,
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
subtitle: Text(
track.artist?.name ?? 'Unknown Artist',
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
),
),
trailing: Text(
track.formattedDuration,
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
),
),
onTap: () {
// TODO: Play track
},
),
);
}
Widget _buildAlbumCard(Album album) {
return GestureDetector(
onTap: () => _openAlbum(album),
child: Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.rose.withOpacity(0.3),
),
),
child: Column(
children: [
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: AppColors.accentGradient,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
),
child: album.imageUrl != null
? ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
child: Image.network(
album.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.album,
size: 48,
color: AppColors.muted,
);
},
),
)
: const Icon(
Icons.album,
size: 48,
color: AppColors.muted,
),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Column(
children: [
Text(
album.title,
style: const TextStyle(
color: AppColors.onSurface,
fontWeight: FontWeight.w500,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
if (album.artist != null)
Text(
album.artist!.name,
style: const TextStyle(
color: AppColors.muted,
fontSize: 11,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
),
),
],
),
),
);
}
Widget _buildArtistCard(Artist artist) {
return GestureDetector(
onTap: () => _openArtist(artist),
child: Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.violet.withOpacity(0.3),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Center(
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: AppColors.primaryGradient,
),
child: artist.imageUrl != null
? ClipOval(
child: Image.network(
artist.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.person,
size: 36,
color: AppColors.muted,
);
},
),
)
: const Icon(
Icons.person,
size: 36,
color: AppColors.muted,
),
),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Text(
artist.name,
style: const TextStyle(
color: AppColors.onSurface,
fontWeight: FontWeight.w500,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
),
],
),
),
);
}
Widget _buildEmptyState({
required IconData icon,
required String message,
required String submessage,
}) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 48,
color: AppColors.muted,
),
const SizedBox(height: 12),
Text(
message,
style: const TextStyle(
fontSize: 16,
color: AppColors.onSurface,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
submessage,
style: const TextStyle(
fontSize: 12,
color: AppColors.muted,
),
),
],
),
);
}
Widget _buildErrorState(String error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 48,
color: AppColors.error,
),
const SizedBox(height: 12),
const Text(
'Something went wrong',
style: TextStyle(
fontSize: 16,
color: AppColors.error,
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
error,
style: const TextStyle(
fontSize: 12,
color: AppColors.muted,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () {
ref.read(libraryProvider.notifier).refresh();
},
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Retry'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.cyan,
foregroundColor: AppColors.primary,
),
),
],
),
);
}
void _openPlaylist(dynamic playlist) {
// TODO: Navigate to playlist details
print('Opening playlist: ${playlist.name}');
}
void _confirmDeletePlaylist(dynamic playlist) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.surfaceVariant,
title: const Text(
'Delete Playlist?',
style: TextStyle(color: AppColors.onSurface),
),
content: Text(
'Are you sure you want to delete "${playlist.name}"? This action cannot be undone.',
style: const TextStyle(color: AppColors.muted),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'Cancel',
style: TextStyle(color: AppColors.muted),
),
),
TextButton(
onPressed: () {
Navigator.pop(context);
ref.read(libraryProvider.notifier).deletePlaylist(playlist.id);
},
child: const Text(
'Delete',
style: TextStyle(color: AppColors.error),
),
),
],
),
);
}
void _openAlbum(Album album) {
// TODO: Navigate to album details
print('Opening album: ${album.title}');
}
void _openArtist(Artist artist) {
// TODO: Navigate to artist details
print('Opening artist: ${artist.name}');
}
}