first commit
This commit is contained in:
84
lib/features/music/data/services/music_service.dart
Normal file
84
lib/features/music/data/services/music_service.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../../domain/models/track_model.dart';
|
||||
|
||||
class MusicService {
|
||||
const MusicService(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
User get _currentUser {
|
||||
final user = _client.auth.currentUser;
|
||||
if (user == null) {
|
||||
throw StateError('No authenticated user');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/// Uploads a new track with an MP3 file and metadata.
|
||||
Future<void> uploadTrack({
|
||||
required Uint8List bytes,
|
||||
required String title,
|
||||
required String genreTag,
|
||||
required String extension,
|
||||
}) async {
|
||||
final user = _currentUser;
|
||||
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||
final random = Random().nextInt(99999);
|
||||
final fileName = '$ts-$random.$extension';
|
||||
final path = 'tracks/${user.id}/$fileName';
|
||||
|
||||
// Upload audio to Supabase Storage
|
||||
await _client.storage.from('tracks').uploadBinary(
|
||||
path,
|
||||
bytes,
|
||||
fileOptions: const FileOptions(upsert: false),
|
||||
);
|
||||
|
||||
final audioUrl = _client.storage.from('tracks').getPublicUrl(path);
|
||||
|
||||
// Insert track record into PostgreSQL
|
||||
await _client.from('tracks').insert({
|
||||
'user_id': user.id,
|
||||
'title': title.trim().isEmpty ? 'UNTITLED' : title.trim().toUpperCase(),
|
||||
'audio_url': audioUrl,
|
||||
'genre_tag': genreTag.trim().isEmpty ? 'UNKNOWN' : genreTag.trim().toUpperCase(),
|
||||
'plays': 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Lists all tracks in chronological order.
|
||||
Future<List<TrackModel>> fetchTracks() async {
|
||||
final response = await _client
|
||||
.from('tracks')
|
||||
.select('*, profiles(username)')
|
||||
.order('created_at', ascending: false);
|
||||
|
||||
return List<Map<String, dynamic>>.from(response).map((row) {
|
||||
final username = (row['profiles'] as Map<String, dynamic>?)?['username'] as String?;
|
||||
return TrackModel.fromJson(row, username: username);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Increments the play count of a track.
|
||||
Future<void> incrementPlays(String trackId) async {
|
||||
await _client.rpc('increment_track_plays', params: {'track_id': trackId});
|
||||
}
|
||||
|
||||
/// Fetches tracks for a specific user.
|
||||
Future<List<TrackModel>> fetchUserTracks(String userId) async {
|
||||
final response = await _client
|
||||
.from('tracks')
|
||||
.select('*, profiles(username)')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', ascending: false);
|
||||
|
||||
return List<Map<String, dynamic>>.from(response).map((row) {
|
||||
final username = (row['profiles'] as Map<String, dynamic>?)?['username'] as String?;
|
||||
return TrackModel.fromJson(row, username: username);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
54
lib/features/music/domain/models/track_model.dart
Normal file
54
lib/features/music/domain/models/track_model.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
class TrackModel {
|
||||
const TrackModel({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.title,
|
||||
required this.audioUrl,
|
||||
required this.genreTag,
|
||||
required this.plays,
|
||||
required this.createdAt,
|
||||
required this.username,
|
||||
this.featured = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String userId;
|
||||
final String title;
|
||||
final String audioUrl;
|
||||
final String genreTag;
|
||||
final int plays;
|
||||
final DateTime createdAt;
|
||||
final String username;
|
||||
final bool featured;
|
||||
|
||||
factory TrackModel.fromJson(Map<String, dynamic> json, {String? username}) {
|
||||
return TrackModel(
|
||||
id: json['id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
title: (json['title'] as String?) ?? 'UNTITLED',
|
||||
audioUrl: (json['audio_url'] as String?) ?? '',
|
||||
genreTag: (json['genre_tag'] as String?) ?? 'UNKNOWN',
|
||||
plays: (json['plays'] as int?) ?? 0,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
username: username ?? 'RIOTER',
|
||||
featured: (json['featured'] as bool?) ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
TrackModel copyWith({
|
||||
int? plays,
|
||||
bool? featured,
|
||||
}) {
|
||||
return TrackModel(
|
||||
id: id,
|
||||
userId: userId,
|
||||
title: title,
|
||||
audioUrl: audioUrl,
|
||||
genreTag: genreTag,
|
||||
plays: plays ?? this.plays,
|
||||
createdAt: createdAt,
|
||||
username: username,
|
||||
featured: featured ?? this.featured,
|
||||
);
|
||||
}
|
||||
}
|
||||
236
lib/features/music/presentation/pages/music_page.dart
Normal file
236
lib/features/music/presentation/pages/music_page.dart
Normal file
@@ -0,0 +1,236 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../providers/music_providers.dart';
|
||||
import '../widgets/track_card.dart';
|
||||
|
||||
class MusicPage extends ConsumerStatefulWidget {
|
||||
const MusicPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MusicPage> createState() => _MusicPageState();
|
||||
}
|
||||
|
||||
class _MusicPageState extends ConsumerState<MusicPage> {
|
||||
final _titleController = TextEditingController();
|
||||
final _genreController = TextEditingController();
|
||||
Uint8List? _selectedMp3Bytes;
|
||||
String? _selectedFileName;
|
||||
String _selectedExtension = 'mp3';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_genreController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tracksAsync = ref.watch(trackListProvider);
|
||||
final uploadState = ref.watch(musicControllerProvider);
|
||||
|
||||
ref.listen(musicControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
backgroundColor: AppColors.success,
|
||||
content: Text('SONIC CHAOS UPLOADED.', style: TextStyle(color: AppColors.black, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
);
|
||||
},
|
||||
error: (error, _) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppColors.bloodRed,
|
||||
content: Text(error.toString().toUpperCase()),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('RIOTZ // AUDIO'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
children: [
|
||||
Text(
|
||||
'UPLOAD TRANSMISSION',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_UploadComposer(
|
||||
titleController: _titleController,
|
||||
genreController: _genreController,
|
||||
selectedFileName: _selectedFileName,
|
||||
isBusy: uploadState.isLoading,
|
||||
onPickMp3: uploadState.isLoading ? null : _pickMp3,
|
||||
onUpload: uploadState.isLoading ? null : _upload,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
Text(
|
||||
'LATEST SOUNDS',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
tracksAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
|
||||
error: (error, _) => Center(child: Text('SIGNAL LOST: $error')),
|
||||
data: (tracks) {
|
||||
if (tracks.isEmpty) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Text('NO TRACKS FOUND. BROADCAST YOUR SOUND.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: tracks.map((track) {
|
||||
return TrackCard(track: track);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickMp3() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const ['mp3'],
|
||||
withData: true,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
|
||||
final file = result.files.first;
|
||||
if (file.bytes == null) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('UNREADABLE FILE.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_selectedMp3Bytes = file.bytes;
|
||||
_selectedFileName = file.name.toUpperCase();
|
||||
_selectedExtension = file.name.split('.').last.toLowerCase();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _upload() async {
|
||||
final bytes = _selectedMp3Bytes;
|
||||
if (bytes == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('SELECT AN MP3 FIRST.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(musicControllerProvider.notifier).uploadTrack(
|
||||
bytes: bytes,
|
||||
title: _titleController.text,
|
||||
genreTag: _genreController.text,
|
||||
extension: _selectedExtension,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_selectedMp3Bytes = null;
|
||||
_selectedFileName = null;
|
||||
_titleController.clear();
|
||||
_genreController.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _UploadComposer extends StatelessWidget {
|
||||
const _UploadComposer({
|
||||
required this.titleController,
|
||||
required this.genreController,
|
||||
required this.selectedFileName,
|
||||
required this.isBusy,
|
||||
required this.onPickMp3,
|
||||
required this.onUpload,
|
||||
});
|
||||
|
||||
final TextEditingController titleController;
|
||||
final TextEditingController genreController;
|
||||
final String? selectedFileName;
|
||||
final bool isBusy;
|
||||
final VoidCallback? onPickMp3;
|
||||
final VoidCallback? onUpload;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: titleController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'TRACK TITLE',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: genreController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'GENRE (RAGE, PUNK, PHONK)',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (selectedFileName != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
'READY: $selectedFileName',
|
||||
style: const TextStyle(color: AppColors.neonRed, fontWeight: FontWeight.bold, fontSize: 10),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: RiotzButton(
|
||||
label: 'SELECT FILE',
|
||||
style: RiotzButtonStyle.outline,
|
||||
onPressed: onPickMp3,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: RiotzButton(
|
||||
label: 'UPLOAD',
|
||||
isLoading: isBusy,
|
||||
onPressed: onUpload,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import '../../../../core/supabase/supabase_providers.dart';
|
||||
import '../../data/services/music_service.dart';
|
||||
import '../../domain/models/track_model.dart';
|
||||
|
||||
final musicServiceProvider = Provider<MusicService>((ref) {
|
||||
final client = ref.watch(supabaseProvider);
|
||||
return MusicService(client);
|
||||
});
|
||||
|
||||
final trackListProvider = FutureProvider<List<TrackModel>>((ref) {
|
||||
return ref.watch(musicServiceProvider).fetchTracks();
|
||||
});
|
||||
|
||||
final userTracksProvider = FutureProvider.family<List<TrackModel>, String>((ref, userId) {
|
||||
return ref.watch(musicServiceProvider).fetchUserTracks(userId);
|
||||
});
|
||||
|
||||
final musicControllerProvider = AutoDisposeAsyncNotifierProvider<MusicController, void>(
|
||||
MusicController.new,
|
||||
);
|
||||
|
||||
class MusicController extends AutoDisposeAsyncNotifier<void> {
|
||||
@override
|
||||
Future<void> build() async {}
|
||||
|
||||
Future<void> uploadTrack({
|
||||
required Uint8List bytes,
|
||||
required String title,
|
||||
required String genreTag,
|
||||
required String extension,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(musicServiceProvider).uploadTrack(
|
||||
bytes: bytes,
|
||||
title: title,
|
||||
genreTag: genreTag,
|
||||
extension: extension,
|
||||
);
|
||||
ref.invalidate(trackListProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> incrementPlays(String trackId) async {
|
||||
await AsyncValue.guard(() => ref.read(musicServiceProvider).incrementPlays(trackId));
|
||||
}
|
||||
}
|
||||
|
||||
final audioPlayerProvider = Provider.autoDispose<AudioPlayer>((ref) {
|
||||
final player = AudioPlayer();
|
||||
ref.onDispose(player.dispose);
|
||||
return player;
|
||||
});
|
||||
|
||||
final currentTrackProvider = StateProvider<TrackModel?>((ref) => null);
|
||||
final isPlayingProvider = StreamProvider<bool>((ref) {
|
||||
final player = ref.watch(audioPlayerProvider);
|
||||
return player.playingStream;
|
||||
});
|
||||
140
lib/features/music/presentation/screens/music_screen.dart
Normal file
140
lib/features/music/presentation/screens/music_screen.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../providers/music_providers.dart';
|
||||
import '../widgets/track_card.dart';
|
||||
|
||||
class MusicScreen extends ConsumerStatefulWidget {
|
||||
const MusicScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MusicScreen> createState() => _MusicScreenState();
|
||||
}
|
||||
|
||||
class _MusicScreenState extends ConsumerState<MusicScreen> {
|
||||
final _titleController = TextEditingController();
|
||||
final _genreController = TextEditingController();
|
||||
Uint8List? _selectedMp3Bytes;
|
||||
String? _selectedFileName;
|
||||
String _selectedExtension = 'mp3';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_genreController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tracksAsync = ref.watch(trackListProvider);
|
||||
final uploadState = ref.watch(musicControllerProvider);
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(title: const Text('RIOTZ // AUDIO')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
children: [
|
||||
Text('UPLOAD TRANSMISSION', style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed)),
|
||||
const SizedBox(height: 16),
|
||||
_UploadComposer(
|
||||
titleController: _titleController,
|
||||
genreController: _genreController,
|
||||
selectedFileName: _selectedFileName,
|
||||
isBusy: uploadState.isLoading,
|
||||
onPickMp3: uploadState.isLoading ? null : _pickMp3,
|
||||
onUpload: uploadState.isLoading ? null : _upload,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
Text('LATEST SOUNDS', style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed)),
|
||||
const SizedBox(height: 16),
|
||||
tracksAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
|
||||
error: (error, _) => Center(child: Text('SIGNAL LOST: $error')),
|
||||
data: (tracks) => Column(
|
||||
children: tracks.map((track) => TrackCard(track: track)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickMp3() async {
|
||||
final result = await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['mp3'], withData: true);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
final file = result.files.first;
|
||||
setState(() {
|
||||
_selectedMp3Bytes = file.bytes;
|
||||
_selectedFileName = file.name.toUpperCase();
|
||||
_selectedExtension = file.name.split('.').last.toLowerCase();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _upload() async {
|
||||
if (_selectedMp3Bytes == null) return;
|
||||
await ref.read(musicControllerProvider.notifier).uploadTrack(
|
||||
bytes: _selectedMp3Bytes!,
|
||||
title: _titleController.text,
|
||||
genreTag: _genreController.text,
|
||||
extension: _selectedExtension,
|
||||
);
|
||||
setState(() {
|
||||
_selectedMp3Bytes = null;
|
||||
_selectedFileName = null;
|
||||
_titleController.clear();
|
||||
_genreController.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _UploadComposer extends StatelessWidget {
|
||||
const _UploadComposer({
|
||||
required this.titleController,
|
||||
required this.genreController,
|
||||
required this.selectedFileName,
|
||||
required this.isBusy,
|
||||
required this.onPickMp3,
|
||||
required this.onUpload,
|
||||
});
|
||||
|
||||
final TextEditingController titleController;
|
||||
final TextEditingController genreController;
|
||||
final String? selectedFileName;
|
||||
final bool isBusy;
|
||||
final VoidCallback? onPickMp3;
|
||||
final VoidCallback? onUpload;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(color: AppColors.surface, border: Border.all(color: AppColors.border)),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(controller: titleController, decoration: const InputDecoration(hintText: 'TRACK TITLE')),
|
||||
const SizedBox(height: 16),
|
||||
TextField(controller: genreController, decoration: const InputDecoration(hintText: 'GENRE')),
|
||||
const SizedBox(height: 24),
|
||||
if (selectedFileName != null) Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text('READY: $selectedFileName', style: const TextStyle(color: AppColors.neonRed, fontWeight: FontWeight.bold, fontSize: 10)),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: RiotzButton(label: 'SELECT', style: RiotzButtonStyle.outline, onPressed: onPickMp3)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: RiotzButton(label: 'UPLOAD', isLoading: isBusy, onPressed: onUpload)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
109
lib/features/music/presentation/widgets/track_card.dart
Normal file
109
lib/features/music/presentation/widgets/track_card.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../domain/models/track_model.dart';
|
||||
import '../providers/music_providers.dart';
|
||||
|
||||
class TrackCard extends ConsumerWidget {
|
||||
const TrackCard({
|
||||
required this.track,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TrackModel track;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final player = ref.watch(audioPlayerProvider);
|
||||
final currentTrack = ref.watch(currentTrackProvider);
|
||||
final isPlaying = ref.watch(isPlayingProvider).value ?? false;
|
||||
final isCurrent = currentTrack?.id == track.id;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrent ? AppColors.surfaceLight : Colors.transparent,
|
||||
border: Border.all(color: isCurrent ? AppColors.neonRed : AppColors.border),
|
||||
),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: InkWell(
|
||||
onTap: () => _handlePlay(ref, player),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Cover / Icon
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: AppColors.black,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
isCurrent && isPlaying ? Icons.graphic_eq : Icons.music_note,
|
||||
color: isCurrent ? AppColors.neonRed : AppColors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
track.title.toUpperCase(),
|
||||
style: theme.textTheme.titleLarge?.copyWith(fontSize: 16, letterSpacing: 1),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${track.username.toUpperCase()} // ${track.genreTag.toUpperCase()}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: AppColors.grey, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Plays & Action
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${track.plays} PLAYS',
|
||||
style: theme.textTheme.bodySmall?.copyWith(fontSize: 9, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Icon(
|
||||
isCurrent && isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
|
||||
color: AppColors.white,
|
||||
size: 32,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handlePlay(WidgetRef ref, AudioPlayer player) async {
|
||||
final currentTrack = ref.read(currentTrackProvider);
|
||||
|
||||
if (currentTrack?.id == track.id) {
|
||||
if (player.playing) {
|
||||
await player.pause();
|
||||
} else {
|
||||
await player.play();
|
||||
}
|
||||
} else {
|
||||
ref.read(currentTrackProvider.notifier).state = track;
|
||||
await player.setUrl(track.audioUrl);
|
||||
await player.play();
|
||||
// Increment play count
|
||||
ref.read(musicControllerProvider.notifier).incrementPlays(track.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user