Files
AudiOhm/frontend/lib/presentation/pages/player/queue_view_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

663 lines
19 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/colors.dart';
import '../../../domain/entities/track.dart';
import '../../providers/music_provider.dart';
import '../../widgets/player/queue_track_tile.dart';
/// Queue View Page
///
/// Complete queue management interface with:
/// - Now Playing section (top)
/// - Queue list section (bottom)
/// - Swipe to remove
/// - Drag to reorder
/// - Clear queue functionality
class QueueViewPage extends ConsumerStatefulWidget {
const QueueViewPage({super.key});
@override
ConsumerState<QueueViewPage> createState() => _QueueViewPageState();
}
class _QueueViewPageState extends ConsumerState<QueueViewPage> {
@override
Widget build(BuildContext context) {
final queueData = ref.watch(queueProvider);
return Scaffold(
backgroundColor: AppColors.primary,
body: SafeArea(
child: Column(
children: [
// Header
_buildHeader(queueData),
// Content
Expanded(
child: queueData.hasQueue
? Column(
children: [
// Now Playing Section
_buildNowPlayingSection(queueData),
const SizedBox(height: 8),
// Queue Section
Expanded(
child: _buildQueueSection(queueData),
),
],
)
: _buildEmptyQueue(),
),
],
),
),
);
}
Widget _buildHeader(QueueViewData queueData) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border(
bottom: BorderSide(
color: AppColors.cyan.withOpacity(0.2),
width: 1,
),
),
boxShadow: [
BoxShadow(
color: AppColors.cyan.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
// Back button
IconButton(
icon: const Icon(Icons.arrow_back),
color: AppColors.onSurface,
onPressed: () => Navigator.of(context).pop(),
),
const SizedBox(width: 8),
// Title
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Queue',
style: TextStyle(
color: AppColors.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
if (queueData.hasQueue)
Text(
'${queueData.queueCount} tracks',
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
),
),
],
),
),
// Clear queue button
if (queueData.hasNextTracks)
TextButton.icon(
onPressed: () => _showClearQueueDialog(queueData),
icon: const Icon(
Icons.clear_all,
size: 18,
color: AppColors.rouge,
),
label: const Text(
'Clear',
style: TextStyle(
color: AppColors.rouge,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
backgroundColor: AppColors.rouge.withOpacity(0.1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
);
}
Widget _buildNowPlayingSection(QueueViewData queueData) {
final currentTrack = queueData.currentTrack;
if (currentTrack == null) {
return const SizedBox.shrink();
}
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.surfaceVariant,
AppColors.surfaceElevated,
],
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.cyan.withOpacity(0.3),
width: 1,
),
boxShadow: [
BoxShadow(
color: AppColors.cyan.withOpacity(0.1),
blurRadius: 20,
spreadRadius: 2,
),
],
),
child: Column(
children: [
// Section label
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.cyan.withOpacity(0.2),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'NOW PLAYING',
style: TextStyle(
color: AppColors.cyan,
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 1,
),
),
),
const Spacer(),
_buildPlayingIndicator(queueData.isPlaying),
],
),
const SizedBox(height: 16),
// Track info
Row(
children: [
// Album art
_buildLargeAlbumArt(currentTrack),
const SizedBox(width: 16),
// Track details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
currentTrack.title,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
currentTrack.artist?.name ?? 'Unknown Artist',
style: const TextStyle(
color: AppColors.muted,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Row(
children: [
_buildControlButton(
icon: Icons.skip_previous,
onTap: () => _playPrevious(),
),
const SizedBox(width: 12),
_buildPlayPauseButton(queueData.isPlaying),
const SizedBox(width: 12),
_buildControlButton(
icon: Icons.skip_next,
onTap: () => _playNext(),
),
],
),
],
),
),
],
),
],
),
);
}
Widget _buildLargeAlbumArt(Track track) {
return Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: AppColors.accentGradient,
borderRadius: BorderRadius.circular(12),
boxShadow: AppColors.violetGlow,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: track.imageUrl != null
? Image.network(
track.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.music_note,
color: AppColors.onBackground,
size: 40,
);
},
)
: const Icon(
Icons.music_note,
color: AppColors.onBackground,
size: 40,
),
),
);
}
Widget _buildPlayingIndicator(bool isPlaying) {
if (!isPlaying) {
return const SizedBox.shrink();
}
return Row(
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: AppColors.vert,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppColors.vert,
blurRadius: 8,
spreadRadius: 2,
),
],
),
),
const SizedBox(width: 4),
const Text(
'Playing',
style: TextStyle(
color: AppColors.vert,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
],
);
}
Widget _buildControlButton({
required IconData icon,
required VoidCallback onTap,
}) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.surfaceElevated,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: AppColors.onSurface,
size: 24,
),
),
),
);
}
Widget _buildPlayPauseButton(bool isPlaying) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => _togglePlayPause(isPlaying),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(12),
boxShadow: AppColors.cyanGlow,
),
child: Icon(
isPlaying ? Icons.pause : Icons.play_arrow,
color: AppColors.primary,
size: 28,
),
),
),
);
}
Widget _buildQueueSection(QueueViewData queueData) {
if (!queueData.hasNextTracks) {
return const SizedBox.shrink();
}
return Column(
children: [
// Queue header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.violet.withOpacity(0.2),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'NEXT UP',
style: TextStyle(
color: AppColors.violet,
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 1,
),
),
),
const SizedBox(width: 8),
Text(
'${queueData.nextTracks.length} tracks',
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
),
),
],
),
),
const SizedBox(height: 8),
// Queue list
Expanded(
child: ReorderableListView.builder(
padding: const EdgeInsets.only(bottom: 16),
itemCount: queueData.nextTracks.length,
onReorder: (oldIndex, newIndex) {
_reorderQueue(oldIndex, newIndex, queueData);
},
itemBuilder: (context, index) {
final track = queueData.nextTracks[index];
final actualIndex = queueData.currentIndex + 1 + index;
return Dismissible(
key: Key('queue_${track.id}_$index'),
direction: DismissDirection.endToStart,
onDismissed: (direction) {
_removeFromQueue(actualIndex);
},
background: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: AppColors.rouge,
borderRadius: BorderRadius.circular(12),
),
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(
Icons.delete,
color: AppColors.primary,
),
),
child: QueueTrackTile(
key: Key('queue_${track.id}_$index'),
track: track,
index: index,
onTap: () => _playTrack(actualIndex),
onRemove: () => _removeFromQueue(actualIndex),
),
);
},
),
),
],
);
}
Widget _buildEmptyQueue() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.cyan.withOpacity(0.2),
AppColors.violet.withOpacity(0.2),
],
),
borderRadius: BorderRadius.circular(30),
),
child: const Icon(
Icons.queue_music,
color: AppColors.muted,
size: 60,
),
),
const SizedBox(height: 24),
const Text(
'Queue is empty',
style: TextStyle(
color: AppColors.onSurface,
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
const Text(
'Add tracks to build your queue',
style: TextStyle(
color: AppColors.muted,
fontSize: 14,
),
),
],
),
);
}
void _togglePlayPause(bool isPlaying) {
final notifier = ref.read(playerProvider.notifier);
if (isPlaying) {
notifier.pause();
} else {
notifier.play();
}
}
void _playNext() {
ref.read(playerProvider.notifier).next();
}
void _playPrevious() {
ref.read(playerProvider.notifier).previous();
}
void _playTrack(int index) {
final queueData = ref.read(queueProvider);
final track = queueData.queue[index];
ref.read(playerProvider.notifier).loadTrack(track).then((_) {
ref.read(playerProvider.notifier).play();
});
}
void _removeFromQueue(int index) {
ref.read(playerProvider.notifier).removeFromQueue(index);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Removed from queue'),
backgroundColor: AppColors.surfaceElevated,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
duration: const Duration(seconds: 2),
),
);
}
void _reorderQueue(int oldIndex, int newIndex, QueueViewData queueData) {
if (oldIndex == newIndex) return;
// Adjust for the actual queue position
final actualOldIndex = queueData.currentIndex + 1 + oldIndex;
int actualNewIndex = queueData.currentIndex + 1 + newIndex;
if (newIndex > oldIndex) {
actualNewIndex--;
}
final notifier = ref.read(playerProvider.notifier);
final queue = List<Track>.from(queueData.queue);
final item = queue.removeAt(actualOldIndex);
queue.insert(actualNewIndex, item);
notifier.setQueue(queue, startIndex: queueData.currentIndex);
}
void _showClearQueueDialog(QueueViewData queueData) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: const Text(
'Clear Queue?',
style: TextStyle(
color: AppColors.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
content: Text(
'Remove all ${queueData.nextTracks.length} upcoming tracks from the queue?',
style: const TextStyle(
color: AppColors.muted,
fontSize: 14,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text(
'Cancel',
style: TextStyle(
color: AppColors.muted,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
TextButton(
onPressed: () {
_clearQueue(queueData);
Navigator.of(context).pop();
},
style: TextButton.styleFrom(
backgroundColor: AppColors.rouge.withOpacity(0.2),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'Clear',
style: TextStyle(
color: AppColors.rouge,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
void _clearQueue(QueueViewData queueData) {
if (queueData.currentTrack == null) return;
// Keep only the current track
ref.read(playerProvider.notifier).setQueue(
[queueData.currentTrack!],
startIndex: 0,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Queue cleared'),
backgroundColor: AppColors.surfaceElevated,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
duration: Duration(seconds: 2),
),
);
}
}