first commit

This commit is contained in:
Lucas Saburido
2026-05-13 16:26:45 +01:00
commit cabf2025cd
252 changed files with 13524 additions and 0 deletions

View 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();
}
}

View 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,
);
}
}

View 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,
),
),
],
),
],
),
);
}
}

View File

@@ -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;
});

View 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)),
],
),
],
),
);
}
}

View 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);
}
}
}