🎉 Initial commit: AudiOhm - Alternative à Spotify avec streaming YouTube

Features:
- Frontend Flutter avec thème néon cyberpunk
- Backend FastAPI avec streaming YouTube
- Base de données PostgreSQL + Redis
- Authentification JWT complète
- Recherche multi-source (DB + YouTube)
- Playlists CRUD avec drag & drop
- Queue management
- Settings avec audio quality
- Interface adaptative (Desktop + Mobile)

Tech Stack:
- Frontend: Flutter 3.2+, Riverpod
- Backend: Python 3.11+, FastAPI
- Database: PostgreSQL 15+
- Cache: Redis 7+
- Streaming: yt-dlp + FFmpeg

🚀 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
feldenr
2026-01-18 17:08:59 +01:00
commit 9c504d2c3d
128 changed files with 22638 additions and 0 deletions
@@ -0,0 +1,537 @@
/// Playlist Details Page - Desktop layout
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../domain/entities/playlist.dart';
import '../../../../domain/entities/track.dart';
import '../../../../core/theme/colors.dart';
import '../../../providers/playlist_provider.dart';
import '../../../providers/music_provider.dart';
import '../../../providers/auth_provider.dart';
import '../../widgets/playlist/playlist_track_tile.dart';
import '../../widgets/common/cached_network_image_with_fallback.dart';
class PlaylistDesktopPage extends ConsumerStatefulWidget {
final String playlistId;
const PlaylistDesktopPage({
required this.playlistId,
super.key,
});
@override
ConsumerState<PlaylistDesktopPage> createState() => _PlaylistDesktopPageState();
}
class _PlaylistDesktopPageState extends ConsumerState<PlaylistDesktopPage> {
final TextEditingController _nameController = TextEditingController();
final TextEditingController _descriptionController = TextEditingController();
bool _isEditing = false;
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
void _startEditing(Playlist playlist) {
_nameController.text = playlist.name;
_descriptionController.text = playlist.description ?? '';
setState(() => _isEditing = true);
}
Future<void> _saveEdit() async {
final notifier = ref.read(playlistProvider(widget.playlistId).notifier);
await notifier.updatePlaylist(
name: _nameController.text.trim(),
description: _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim(),
);
setState(() => _isEditing = false);
}
void _cancelEdit() {
setState(() => _isEditing = false);
}
Future<void> _showDeleteDialog(Playlist playlist) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.surfaceElevated,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: AppColors.rose.withOpacity(0.3)),
),
title: Text(
'Delete Playlist',
style: TextStyle(color: AppColors.rose, fontSize: 20),
),
content: Text(
'Are you sure you want to delete "${playlist.name}"? This action cannot be undone.',
style: TextStyle(color: AppColors.onBackground),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(
'Cancel',
style: TextStyle(color: AppColors.onSurfaceVariant),
),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rose,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Delete'),
),
],
),
);
if (confirmed == true && mounted) {
final notifier = ref.read(playlistProvider(widget.playlistId).notifier);
await notifier.deletePlaylist();
if (mounted) Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
final playlistState = ref.watch(playlistProvider(widget.playlistId));
final authState = ref.watch(authProvider);
final playerNotifier = ref.read(playerProvider.notifier);
final playlist = playlistState.playlist;
final tracks = playlistState.tracks;
final isOwner = authState.user?.id == playlist?.userId;
if (playlistState.isLoading && playlist == null) {
return Scaffold(
backgroundColor: AppColors.primary,
body: Center(
child: CircularProgressIndicator(color: AppColors.cyan),
),
);
}
if (playlistState.error != null && playlist == null) {
return Scaffold(
backgroundColor: AppColors.primary,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, color: AppColors.rose, size: 64),
const SizedBox(height: 16),
Text(
playlistState.error!,
style: const TextStyle(color: AppColors.rose),
textAlign: TextAlign.center,
),
],
),
),
);
}
if (playlist == null) {
return Scaffold(
backgroundColor: AppColors.primary,
body: Center(
child: Text(
'Playlist not found',
style: TextStyle(color: AppColors.onBackground, fontSize: 20),
),
),
);
}
return Scaffold(
backgroundColor: AppColors.primary,
body: CustomScrollView(
slivers: [
// Header with gradient background
SliverAppBar(
expandedHeight: 300,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.violet.withOpacity(0.3),
AppColors.primary,
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Row(
children: [
// Cover image
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: 200,
height: 200,
child: CachedNetworkImageWithFallback(
imageUrl: playlist.imageUrl,
fallbackIcon: Icons.playlist_play,
progressColor: AppColors.cyan,
fit: BoxFit.cover,
),
),
),
const SizedBox(width: 32),
// Playlist info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (_isEditing) ...[
// Edit mode
TextField(
controller: _nameController,
style: const TextStyle(
color: AppColors.onBackground,
fontSize: 32,
fontWeight: FontWeight.bold,
),
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: AppColors.cyan.withOpacity(0.5),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: AppColors.cyan,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
),
const SizedBox(height: 12),
TextField(
controller: _descriptionController,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
),
maxLines: 3,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: AppColors.cyan.withOpacity(0.5),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: AppColors.cyan,
width: 2,
),
),
hintText: 'Add a description...',
hintStyle: TextStyle(
color: AppColors.onSurfaceVariant,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
),
const SizedBox(height: 16),
Row(
children: [
ElevatedButton(
onPressed: _saveEdit,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.cyan,
foregroundColor: AppColors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
child: const Text('Save'),
),
const SizedBox(width: 12),
TextButton(
onPressed: _cancelEdit,
child: Text(
'Cancel',
style: TextStyle(
color: AppColors.onSurfaceVariant,
),
),
),
],
),
] else ...[
// View mode
Text(
playlist.name,
style: const TextStyle(
color: AppColors.onBackground,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
if (playlist.description != null) ...[
Text(
playlist.description!,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
],
Row(
children: [
Icon(
playlist.isPublic
? Icons.public
: Icons.lock,
color: AppColors.onSurfaceVariant,
size: 16,
),
const SizedBox(width: 8),
Text(
'${playlist.trackCount} songs',
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
),
),
const SizedBox(width: 16),
Text(
playlistState.formattedTotalDuration,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
),
),
],
),
],
],
),
),
],
),
),
),
),
),
),
// Action buttons
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Row(
children: [
// Play button
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: AppColors.primaryGradient,
boxShadow: AppColors.cyanGlow,
),
child: IconButton(
icon: const Icon(Icons.play_arrow, size: 32),
color: AppColors.primary,
onPressed: () {
ref
.read(playlistProvider(widget.playlistId).notifier)
.playPlaylist(playerNotifier);
},
),
),
const SizedBox(width: 16),
// Shuffle button
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.surfaceElevated,
border: Border.all(
color: AppColors.cyan.withOpacity(0.3),
),
),
child: IconButton(
icon: const Icon(Icons.shuffle),
color: AppColors.cyan,
onPressed: () {
ref
.read(playlistProvider(widget.playlistId).notifier)
.shufflePlaylist(playerNotifier);
},
),
),
const SizedBox(width: 16),
// Download button
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.surfaceElevated,
border: Border.all(
color: AppColors.cyan.withOpacity(0.3),
),
),
child: IconButton(
icon: const Icon(Icons.download),
color: AppColors.cyan,
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Download feature coming soon',
style: TextStyle(color: AppColors.primary),
),
backgroundColor: AppColors.cyan,
),
);
},
),
),
const Spacer(),
// Edit button (for owner)
if (isOwner && !_isEditing) ...[
ElevatedButton.icon(
onPressed: () => _startEditing(playlist),
icon: const Icon(Icons.edit),
label: const Text('Edit'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.surfaceElevated,
foregroundColor: AppColors.cyan,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(
color: AppColors.cyan.withOpacity(0.3),
),
),
),
),
const SizedBox(width: 12),
IconButton(
icon: Icon(Icons.delete, color: AppColors.rose),
onPressed: () => _showDeleteDialog(playlist),
tooltip: 'Delete playlist',
),
],
],
),
),
),
// Tracks list
if (tracks.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_note,
color: AppColors.onSurfaceVariant,
size: 64,
),
const SizedBox(height: 16),
Text(
'No tracks in this playlist',
style: TextStyle(
color: AppColors.onSurfaceVariant,
fontSize: 18,
),
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return PlaylistTrackTile(
track: track,
position: index,
isOwner: isOwner,
onTap: () {
// Play this track
playerNotifier.setQueue(tracks, startIndex: index);
},
onRemove: isOwner
? () {
ref
.read(playlistProvider(widget.playlistId).notifier)
.removeTrack(track.id);
}
: null,
onAddToQueue: () {
playerNotifier.addToQueue(track);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Added to queue',
style: TextStyle(color: AppColors.primary),
),
backgroundColor: AppColors.vert,
),
);
},
);
},
childCount: tracks.length,
),
),
),
// Loading indicator for reordering
if (playlistState.isReordering)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(color: AppColors.cyan),
),
),
),
],
),
);
}
}
@@ -0,0 +1,28 @@
/// Playlist Details Page - Adaptive layout
library;
import 'package:flutter/material.dart';
import 'playlist_desktop_page.dart';
import 'playlist_mobile_page.dart';
class PlaylistDetailsPage extends StatelessWidget {
final String playlistId;
const PlaylistDetailsPage({
required this.playlistId,
super.key,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 800) {
return PlaylistDesktopPage(playlistId: playlistId);
} else {
return PlaylistMobilePage(playlistId: playlistId);
}
},
);
}
}
@@ -0,0 +1,565 @@
/// Playlist Details Page - Mobile layout
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../domain/entities/playlist.dart';
import '../../../../core/theme/colors.dart';
import '../../../providers/playlist_provider.dart';
import '../../../providers/music_provider.dart';
import '../../../providers/auth_provider.dart';
import '../../widgets/playlist/playlist_track_tile.dart';
import '../../widgets/common/cached_network_image_with_fallback.dart';
class PlaylistMobilePage extends ConsumerStatefulWidget {
final String playlistId;
const PlaylistMobilePage({
required this.playlistId,
super.key,
});
@override
ConsumerState<PlaylistMobilePage> createState() => _PlaylistMobilePageState();
}
class _PlaylistMobilePageState extends ConsumerState<PlaylistMobilePage> {
final TextEditingController _nameController = TextEditingController();
final TextEditingController _descriptionController = TextEditingController();
bool _isEditing = false;
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
void _startEditing(Playlist playlist) {
_nameController.text = playlist.name;
_descriptionController.text = playlist.description ?? '';
setState(() => _isEditing = true);
}
Future<void> _saveEdit() async {
final notifier = ref.read(playlistProvider(widget.playlistId).notifier);
await notifier.updatePlaylist(
name: _nameController.text.trim(),
description: _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim(),
);
setState(() => _isEditing = false);
}
void _cancelEdit() {
setState(() => _isEditing = false);
}
Future<void> _showDeleteDialog(Playlist playlist) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.surfaceElevated,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: AppColors.rose.withOpacity(0.3)),
),
title: Text(
'Delete Playlist',
style: TextStyle(color: AppColors.rose, fontSize: 20),
),
content: Text(
'Are you sure you want to delete "${playlist.name}"?',
style: TextStyle(color: AppColors.onBackground),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(
'Cancel',
style: TextStyle(color: AppColors.onSurfaceVariant),
),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rose,
foregroundColor: Colors.white,
),
child: const Text('Delete'),
),
],
),
);
if (confirmed == true && mounted) {
final notifier = ref.read(playlistProvider(widget.playlistId).notifier);
await notifier.deletePlaylist();
if (mounted) Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
final playlistState = ref.watch(playlistProvider(widget.playlistId));
final authState = ref.watch(authProvider);
final playerNotifier = ref.read(playerProvider.notifier);
final playlist = playlistState.playlist;
final tracks = playlistState.tracks;
final isOwner = authState.user?.id == playlist?.userId;
if (playlistState.isLoading && playlist == null) {
return Scaffold(
backgroundColor: AppColors.primary,
body: Center(
child: CircularProgressIndicator(color: AppColors.cyan),
),
);
}
if (playlistState.error != null && playlist == null) {
return Scaffold(
backgroundColor: AppColors.primary,
appBar: AppBar(
backgroundColor: AppColors.primary,
elevation: 0,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, color: AppColors.rose, size: 64),
const SizedBox(height: 16),
Text(
playlistState.error!,
style: const TextStyle(color: AppColors.rose),
textAlign: TextAlign.center,
),
],
),
),
);
}
if (playlist == null) {
return Scaffold(
backgroundColor: AppColors.primary,
appBar: AppBar(
backgroundColor: AppColors.primary,
elevation: 0,
),
body: Center(
child: Text(
'Playlist not found',
style: TextStyle(color: AppColors.onBackground, fontSize: 18),
),
),
);
}
return Scaffold(
backgroundColor: AppColors.primary,
body: CustomScrollView(
slivers: [
// App bar
SliverAppBar(
backgroundColor: AppColors.primary,
expandedHeight: 320,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.violet.withOpacity(0.3),
AppColors.primary,
],
),
),
child: Column(
children: [
const SizedBox(height: kToolbarHeight * 2),
// Cover image
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: 200,
height: 200,
child: CachedNetworkImageWithFallback(
imageUrl: playlist.imageUrl,
fallbackIcon: Icons.playlist_play,
progressColor: AppColors.cyan,
fit: BoxFit.cover,
),
),
),
],
),
),
),
),
// Playlist info and actions
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title and edit
if (_isEditing) ...[
TextField(
controller: _nameController,
style: const TextStyle(
color: AppColors.onBackground,
fontSize: 24,
fontWeight: FontWeight.bold,
),
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: AppColors.cyan.withOpacity(0.5),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: AppColors.cyan,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
),
const SizedBox(height: 12),
TextField(
controller: _descriptionController,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
),
maxLines: 3,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: AppColors.cyan.withOpacity(0.5),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: AppColors.cyan,
width: 2,
),
),
hintText: 'Add a description...',
hintStyle: TextStyle(
color: AppColors.onSurfaceVariant,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
),
const SizedBox(height: 16),
Row(
children: [
ElevatedButton(
onPressed: _saveEdit,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.cyan,
foregroundColor: AppColors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
child: const Text('Save'),
),
const SizedBox(width: 12),
TextButton(
onPressed: _cancelEdit,
child: Text(
'Cancel',
style: TextStyle(
color: AppColors.onSurfaceVariant,
),
),
),
],
),
] else ...[
Text(
playlist.name,
style: const TextStyle(
color: AppColors.onBackground,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
if (playlist.description != null) ...[
Text(
playlist.description!,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
],
Row(
children: [
Icon(
playlist.isPublic ? Icons.public : Icons.lock,
color: AppColors.onSurfaceVariant,
size: 14,
),
const SizedBox(width: 4),
Text(
'${playlist.trackCount} songs • ${playlistState.formattedTotalDuration}',
style: const TextStyle(
color: AppColors.onSurfaceVariant,
fontSize: 13,
),
),
],
),
],
const SizedBox(height: 16),
// Action buttons
Row(
children: [
// Play button
Expanded(
child: Container(
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(24),
boxShadow: AppColors.cyanGlow,
),
child: ElevatedButton.icon(
onPressed: () {
ref
.read(
playlistProvider(widget.playlistId).notifier)
.playPlaylist(playerNotifier);
},
icon: const Icon(Icons.play_arrow),
label: const Text('Play'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: AppColors.primary,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
),
),
const SizedBox(width: 12),
// Shuffle button
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.surfaceElevated,
border: Border.all(
color: AppColors.cyan.withOpacity(0.3),
),
),
child: IconButton(
icon: const Icon(Icons.shuffle),
color: AppColors.cyan,
onPressed: () {
ref
.read(
playlistProvider(widget.playlistId).notifier)
.shufflePlaylist(playerNotifier);
},
),
),
// Download button
const SizedBox(width: 8),
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.surfaceElevated,
border: Border.all(
color: AppColors.cyan.withOpacity(0.3),
),
),
child: IconButton(
icon: const Icon(Icons.download),
color: AppColors.cyan,
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Download feature coming soon',
style: TextStyle(color: AppColors.primary),
),
backgroundColor: AppColors.cyan,
),
);
},
),
),
const Spacer(),
// Edit button (for owner)
if (isOwner && !_isEditing) ...[
IconButton(
icon: Icon(Icons.edit, color: AppColors.cyan),
onPressed: () => _startEditing(playlist),
),
IconButton(
icon: Icon(Icons.delete, color: AppColors.rose),
onPressed: () => _showDeleteDialog(playlist),
),
],
],
),
const SizedBox(height: 16),
// Divider
Container(
height: 1,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.transparent,
AppColors.cyan.withOpacity(0.3),
Colors.transparent,
],
),
),
),
],
),
),
),
// Tracks list
if (tracks.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_note,
color: AppColors.onSurfaceVariant,
size: 64,
),
const SizedBox(height: 16),
Text(
'No tracks in this playlist',
style: TextStyle(
color: AppColors.onSurfaceVariant,
fontSize: 16,
),
),
],
),
),
)
else
SliverReorderableList(
delegate: ReorderableChildBuilderDelegate(
childCount: tracks.length,
(context, index) {
final track = tracks[index];
return Dismissible(
key: ValueKey(track.id),
direction: isOwner
? DismissDirection.endToStart
: DismissDirection.none,
onDismissed: (_) {
if (isOwner) {
ref
.read(playlistProvider(widget.playlistId).notifier)
.removeTrack(track.id);
}
},
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
decoration: BoxDecoration(
color: AppColors.rose.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.delete, color: AppColors.rose),
),
child: PlaylistTrackTile(
track: track,
position: index,
isOwner: isOwner,
onTap: () {
playerNotifier.setQueue(tracks, startIndex: index);
},
onRemove: isOwner
? () {
ref
.read(playlistProvider(widget.playlistId).notifier)
.removeTrack(track.id);
}
: null,
onAddToQueue: () {
playerNotifier.addToQueue(track);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Added to queue',
style: TextStyle(color: AppColors.primary),
),
backgroundColor: AppColors.vert,
),
);
},
),
);
},
),
onReorder: isOwner
? (oldIndex, newIndex) {
ref
.read(playlistProvider(widget.playlistId).notifier)
.reorderTracks(oldIndex, newIndex);
}
: (_, __) {},
),
// Loading indicator for reordering
if (playlistState.isReordering)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(color: AppColors.cyan),
),
),
),
],
),
);
}
}