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>
This commit is contained in:
root
2026-01-18 20:08:36 +00:00
commit a89c7894cf
132 changed files with 23178 additions and 0 deletions
@@ -0,0 +1,166 @@
/// Album Provider - Album details state management
library;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:math';
import '../../../infrastructure/datasources/remote/music_api_service.dart';
import '../../../domain/entities/album.dart';
import '../../../domain/entities/track.dart';
import 'music_provider.dart';
/// Album state
class AlbumState {
final Album? album;
final List<Track> tracks;
final bool isLoading;
final String? error;
const AlbumState({
this.album,
this.tracks = const [],
this.isLoading = false,
this.error,
});
AlbumState copyWith({
Album? album,
List<Track>? tracks,
bool? isLoading,
String? error,
}) {
return AlbumState(
album: album ?? this.album,
tracks: tracks ?? this.tracks,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
/// Get total duration of all tracks in seconds
int get totalDuration {
return tracks.fold(0, (sum, track) {
return sum + (track.duration ?? 0);
});
}
/// Get formatted total duration (hours:minutes:seconds)
String get formattedTotalDuration {
final totalSeconds = totalDuration;
final hours = totalSeconds ~/ 3600;
final minutes = (totalSeconds % 3600) ~/ 60;
final seconds = totalSeconds % 60;
if (hours > 0) {
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} else {
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
}
/// Album notifier
class AlbumNotifier extends StateNotifier<AlbumState> {
AlbumNotifier(this._musicApiService) : super(const AlbumState());
final MusicApiService _musicApiService;
/// Load complete album information with tracks
Future<void> loadAlbum(String albumId) async {
state = state.copyWith(isLoading: true, error: null);
try {
// Load album details and tracks in parallel
final results = await Future.wait([
_musicApiService.getAlbum(albumId),
_musicApiService.getAlbumTracks(albumId),
]);
final album = Album.fromJson(results[0] as Map<String, dynamic>);
final tracks = (results[1] as List)
.map((json) => Track.fromJson(json as Map<String, dynamic>))
.toList();
// Sort tracks by track number
tracks.sort((a, b) {
final aNum = a.trackNumber ?? 0;
final bNum = b.trackNumber ?? 0;
return aNum.compareTo(bNum);
});
state = AlbumState(
album: album,
tracks: tracks,
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
/// Play all tracks from album
Future<void> playAll(PlayerNotifier playerNotifier) async {
if (state.tracks.isEmpty) return;
playerNotifier.setQueue(state.tracks, startIndex: 0);
await playerNotifier.loadTrack(state.tracks.first);
await playerNotifier.play();
}
/// Shuffle and play all tracks from album
Future<void> shuffle(PlayerNotifier playerNotifier) async {
if (state.tracks.isEmpty) return;
// Create shuffled list
final shuffledTracks = List<Track>.from(state.tracks);
final random = Random();
for (int i = shuffledTracks.length - 1; i > 0; i--) {
final j = random.nextInt(i + 1);
final temp = shuffledTracks[i];
shuffledTracks[i] = shuffledTracks[j];
shuffledTracks[j] = temp;
}
playerNotifier.setQueue(shuffledTracks, startIndex: 0);
await playerNotifier.loadTrack(shuffledTracks.first);
await playerNotifier.play();
}
/// Play specific track from album
Future<void> playTrack(
PlayerNotifier playerNotifier,
Track track,
) async {
final index = state.tracks.indexWhere((t) => t.id == track.id);
if (index == -1) return;
playerNotifier.setQueue(state.tracks, startIndex: index);
await playerNotifier.loadTrack(track);
await playerNotifier.play();
}
/// Clear state
void clear() {
state = const AlbumState();
}
}
/// Album provider
final albumProvider =
StateNotifierProvider<AlbumNotifier, AlbumState>((ref) {
final musicApiService = ref.watch(musicApiServiceProvider);
return AlbumNotifier(musicApiService);
});
/// Album data provider for a specific album ID
final albumDataProvider = Provider.family<AlbumState, String>((ref, albumId) {
final notifier = ref.watch(albumProvider.notifier);
// Load data when first accessed
if (notifier.state.album?.id != albumId) {
Future.microtask(() => notifier.loadAlbum(albumId));
}
return notifier.state;
});
@@ -0,0 +1,196 @@
/// Artist Provider - Artist details state management
library;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../infrastructure/datasources/remote/music_api_service.dart';
import '../../../domain/entities/artist.dart';
import '../../../domain/entities/track.dart';
import '../../../domain/entities/album.dart';
/// Artist state
class ArtistState {
final Artist? artist;
final List<Track> topTracks;
final List<Album> albums;
final List<Track> relatedTracks;
final bool isLoading;
final String? error;
const ArtistState({
this.artist,
this.topTracks = const [],
this.albums = const [],
this.relatedTracks = const [],
this.isLoading = false,
this.error,
});
ArtistState copyWith({
Artist? artist,
List<Track>? topTracks,
List<Album>? albums,
List<Track>? relatedTracks,
bool? isLoading,
String? error,
}) {
return ArtistState(
artist: artist ?? this.artist,
topTracks: topTracks ?? this.topTracks,
albums: albums ?? this.albums,
relatedTracks: relatedTracks ?? this.relatedTracks,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
}
/// Artist notifier
class ArtistNotifier extends StateNotifier<ArtistState> {
ArtistNotifier(this._musicApiService) : super(const ArtistState());
final MusicApiService _musicApiService;
/// Load complete artist information
Future<void> loadArtist(String artistId) async {
state = state.copyWith(isLoading: true, error: null);
try {
// Load artist details
final artistData = await _musicApiService.getArtist(artistId);
final artist = Artist.fromJson(artistData);
state = state.copyWith(
artist: artist,
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
/// Load artist's top tracks
Future<void> loadTopTracks(String artistId) async {
try {
final tracksData = await _musicApiService.getArtistTopTracks(artistId);
final tracks = tracksData
.map((json) => Track.fromJson(json as Map<String, dynamic>))
.toList();
state = state.copyWith(topTracks: tracks);
} catch (e) {
state = state.copyWith(error: e.toString());
}
}
/// Load artist's albums
Future<void> loadAlbums(String artistId) async {
try {
final albumsData = await _musicApiService.getArtistAlbums(artistId);
final albums = albumsData
.map((json) => Album.fromJson(json as Map<String, dynamic>))
.toList();
state = state.copyWith(albums: albums);
} catch (e) {
state = state.copyWith(error: e.toString());
}
}
/// Load related tracks (based on artist's top track)
Future<void> loadRelatedTracks(String artistId) async {
try {
// Get first track from top tracks for recommendations
if (state.topTracks.isEmpty) {
await loadTopTracks(artistId);
}
if (state.topTracks.isNotEmpty) {
final firstTrack = state.topTracks.first;
final relatedData =
await _musicApiService.getRecommendations(firstTrack.id);
final related = relatedData
.map((json) => Track.fromJson(json as Map<String, dynamic>))
.toList();
state = state.copyWith(relatedTracks: related);
}
} catch (e) {
state = state.copyWith(error: e.toString());
}
}
/// Load all artist data at once
Future<void> loadAllArtistData(String artistId) async {
state = state.copyWith(isLoading: true, error: null);
try {
// Load all data in parallel
final results = await Future.wait([
_musicApiService.getArtist(artistId),
_musicApiService.getArtistTopTracks(artistId),
_musicApiService.getArtistAlbums(artistId),
]);
final artist = Artist.fromJson(results[0] as Map<String, dynamic>);
final topTracks = (results[1] as List)
.map((json) => Track.fromJson(json as Map<String, dynamic>))
.toList();
final albums = (results[2] as List)
.map((json) => Album.fromJson(json as Map<String, dynamic>))
.toList();
// Load related tracks based on first top track
List<Track> relatedTracks = [];
if (topTracks.isNotEmpty) {
try {
final relatedData =
await _musicApiService.getRecommendations(topTracks.first.id);
relatedTracks = relatedData
.map((json) => Track.fromJson(json as Map<String, dynamic>))
.toList();
} catch (_) {
// Don't fail if recommendations fail
}
}
state = ArtistState(
artist: artist,
topTracks: topTracks,
albums: albums,
relatedTracks: relatedTracks,
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
/// Clear state
void clear() {
state = const ArtistState();
}
}
/// Artist provider
final artistProvider =
StateNotifierProvider<ArtistNotifier, ArtistState>((ref) {
final musicApiService = ref.watch(musicApiServiceProvider);
return ArtistNotifier(musicApiService);
});
/// Artist data provider for a specific artist ID
final artistDataProvider = Provider.family<ArtistState, String>((ref, artistId) {
final notifier = ref.watch(artistProvider.notifier);
// Load data when first accessed
if (notifier.state.artist?.id != artistId) {
Future.microtask(() => notifier.loadAllArtistData(artistId));
}
return notifier.state;
});
@@ -0,0 +1,224 @@
/// Auth Provider
library;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../../domain/entities/user.dart';
import '../../../infrastructure/datasources/remote/auth_api_service.dart';
/// Auth state
class AuthState {
final User? user;
final String? accessToken;
final String? refreshToken;
final bool isLoading;
final String? error;
const AuthState({
this.user,
this.accessToken,
this.refreshToken,
this.isLoading = false,
this.error,
});
AuthState copyWith({
User? user,
String? accessToken,
String? refreshToken,
bool? isLoading,
String? error,
}) {
return AuthState(
user: user ?? this.user,
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
bool get isAuthenticated => user != null && accessToken != null;
}
/// Auth notifier
class AuthNotifier extends StateNotifier<AuthState> {
AuthNotifier(this._authApiService, this._storage) : super(const AuthState()) {
_loadFromStorage();
}
final AuthApiService _authApiService;
final FlutterSecureStorage _storage;
static const String _accessTokenKey = 'access_token';
static const String _refreshTokenKey = 'refresh_token';
static const String _userKey = 'user';
Future<void> _loadFromStorage() async {
state = state.copyWith(isLoading: true);
try {
final accessToken = await _storage.read(key: _accessTokenKey);
final refreshToken = await _storage.read(key: _refreshTokenKey);
final userJson = await _storage.read(key: _userKey);
if (accessToken != null && refreshToken != null && userJson != null) {
// Parse user from JSON
final user = UserJson.fromJson(
// ignore: avoid_dynamic_calls
_jsonDecode(userJson),
);
state = AuthState(
user: user,
accessToken: accessToken,
refreshToken: refreshToken,
);
} else {
state = const AuthState();
}
} catch (e) {
state = AuthState(error: e.toString());
} finally {
state = state.copyWith(isLoading: false);
}
}
Future<void> login(String email, String password) async {
state = state.copyWith(isLoading: true, error: null);
try {
final response = await _authApiService.login(email, password);
await _storage.write(key: _accessTokenKey, value: response.accessToken);
await _storage.write(key: _refreshTokenKey, value: response.refreshToken);
await _storage.write(
key: _userKey,
value: _jsonEncode(response.user.toJson()),
);
state = AuthState(
user: response.user,
accessToken: response.accessToken,
refreshToken: response.refreshToken,
);
} catch (e) {
state = AuthState(error: e.toString());
} finally {
state = state.copyWith(isLoading: false);
}
}
Future<void> register({
required String email,
required String username,
required String password,
String? displayName,
}) async {
state = state.copyWith(isLoading: true, error: null);
try {
final response = await _authApiService.register(
email: email,
username: username,
password: password,
displayName: displayName,
);
await _storage.write(key: _accessTokenKey, value: response.accessToken);
await _storage.write(key: _refreshTokenKey, value: response.refreshToken);
await _storage.write(
key: _userKey,
value: _jsonEncode(response.user.toJson()),
);
state = AuthState(
user: response.user,
accessToken: response.accessToken,
refreshToken: response.refreshToken,
);
} catch (e) {
state = AuthState(error: e.toString());
} finally {
state = state.copyWith(isLoading: false);
}
}
Future<String?> refreshToken() async {
if (state.refreshToken == null) return null;
try {
final response = await _authApiService.refreshToken(state.refreshToken!);
final newAccessToken = response['access_token'] as String;
final newRefreshToken = response['refresh_token'] as String;
await _storage.write(key: _accessTokenKey, value: newAccessToken);
await _storage.write(key: _refreshTokenKey, value: newRefreshToken);
state = state.copyWith(
accessToken: newAccessToken,
refreshToken: newRefreshToken,
);
return newAccessToken;
} catch (e) {
await logout();
return null;
}
}
Future<void> logout() async {
try {
await _authApiService.logout();
} catch (e) {
// Ignore logout errors
} finally {
await _storage.delete(key: _accessTokenKey);
await _storage.delete(key: _refreshTokenKey);
await _storage.delete(key: _userKey);
state = const AuthState();
}
}
Future<void> updateProfile({String? displayName, String? avatarUrl}) async {
state = state.copyWith(isLoading: true);
try {
final updatedUser = await _authApiService.updateProfile(
displayName: displayName,
avatarUrl: avatarUrl,
);
await _storage.write(
key: _userKey,
value: _jsonEncode(updatedUser.toJson()),
);
state = state.copyWith(user: updatedUser);
} catch (e) {
state = state.copyWith(error: e.toString());
} finally {
state = state.copyWith(isLoading: false);
}
}
String _jsonEncode(Object obj) {
// Simple JSON encode
return obj.toString();
}
Object _jsonDecode(String str) {
// Simple JSON decode
return str;
}
}
/// Auth provider
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
final authApiService = ref.watch(authApiServiceProvider);
const storage = FlutterSecureStorage();
return AuthNotifier(authApiService, storage);
});
@@ -0,0 +1,169 @@
/// Library Provider - Library state management
library;
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../infrastructure/datasources/remote/playlist_api_service.dart';
import '../../../infrastructure/datasources/remote/music_api_service.dart';
import '../../../domain/entities/playlist.dart';
import '../../../domain/entities/track.dart';
import '../../../domain/entities/album.dart';
import '../../../domain/entities/artist.dart';
/// Library state
class LibraryState {
final List<Playlist> playlists;
final List<Track> likedSongs;
final List<Album> savedAlbums;
final List<Artist> followedArtists;
final bool isLoading;
final String? error;
const LibraryState({
this.playlists = const [],
this.likedSongs = const [],
this.savedAlbums = const [],
this.followedArtists = const [],
this.isLoading = false,
this.error,
});
LibraryState copyWith({
List<Playlist>? playlists,
List<Track>? likedSongs,
List<Album>? savedAlbums,
List<Artist>? followedArtists,
bool? isLoading,
String? error,
}) {
return LibraryState(
playlists: playlists ?? this.playlists,
likedSongs: likedSongs ?? this.likedSongs,
savedAlbums: savedAlbums ?? this.savedAlbums,
followedArtists: followedArtists ?? this.followedArtists,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
int get totalItems => playlists.length + likedSongs.length + savedAlbums.length + followedArtists.length;
}
/// Library notifier
class LibraryNotifier extends StateNotifier<LibraryState> {
LibraryNotifier(this._playlistApiService, this._musicApiService)
: super(const LibraryState());
final PlaylistApiService _playlistApiService;
final MusicApiService _musicApiService;
Future<void> loadLibrary() async {
state = state.copyWith(isLoading: true, error: null);
try {
// Load playlists in parallel
final results = await Future.wait([
_loadPlaylists(),
// TODO: Implement these endpoints when backend is ready
// _loadLikedSongs(),
// _loadSavedAlbums(),
// _loadFollowedArtists(),
]);
state = LibraryState(
playlists: results[0] as List<Playlist>,
likedSongs: [],
savedAlbums: [],
followedArtists: [],
);
} catch (e) {
state = LibraryState(
playlists: state.playlists,
likedSongs: state.likedSongs,
savedAlbums: state.savedAlbums,
followedArtists: state.followedArtists,
error: e.toString(),
);
} finally {
state = state.copyWith(isLoading: false);
}
}
Future<List<Playlist>> _loadPlaylists() async {
final playlistsJson = await _playlistApiService.getPlaylists();
return playlistsJson
.map((json) => Playlist.fromJson(json as Map<String, dynamic>))
.toList();
}
Future<void> addLikedSong(Track track) async {
// TODO: Implement when backend endpoint is ready
final updated = [...state.likedSongs, track];
state = state.copyWith(likedSongs: updated);
}
Future<void> removeLikedSong(String trackId) async {
// TODO: Implement when backend endpoint is ready
final updated = state.likedSongs.where((t) => t.id != trackId).toList();
state = state.copyWith(likedSongs: updated);
}
Future<void> addSavedAlbum(Album album) async {
// TODO: Implement when backend endpoint is ready
final updated = [...state.savedAlbums, album];
state = state.copyWith(savedAlbums: updated);
}
Future<void> removeSavedAlbum(String albumId) async {
// TODO: Implement when backend endpoint is ready
final updated = state.savedAlbums.where((a) => a.id != albumId).toList();
state = state.copyWith(savedAlbums: updated);
}
Future<void> followArtist(Artist artist) async {
// TODO: Implement when backend endpoint is ready
final updated = [...state.followedArtists, artist];
state = state.copyWith(followedArtists: updated);
}
Future<void> unfollowArtist(String artistId) async {
// TODO: Implement when backend endpoint is ready
final updated = state.followedArtists.where((a) => a.id != artistId).toList();
state = state.copyWith(followedArtists: updated);
}
Future<void> deletePlaylist(String playlistId) async {
await _playlistApiService.deletePlaylist(playlistId);
final updated = state.playlists.where((p) => p.id != playlistId).toList();
state = state.copyWith(playlists: updated);
}
Future<void> createPlaylist({
required String name,
String? description,
String? imageUrl,
bool isPublic = false,
}) async {
final playlistJson = await _playlistApiService.createPlaylist(
name: name,
description: description,
imageUrl: imageUrl,
isPublic: isPublic,
);
final playlist = Playlist.fromJson(playlistJson as Map<String, dynamic>);
final updated = [...state.playlists, playlist];
state = state.copyWith(playlists: updated);
}
Future<void> refresh() async {
await loadLibrary();
}
}
/// Library provider
final libraryProvider = StateNotifierProvider<LibraryNotifier, LibraryState>((ref) {
final playlistApiService = ref.watch(playlistApiServiceProvider);
final musicApiService = ref.watch(musicApiServiceProvider);
return LibraryNotifier(playlistApiService, musicApiService);
});
@@ -0,0 +1,224 @@
/// Music Provider - Player state management
library;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart';
import '../../../domain/entities/track.dart';
/// Player state
class PlayerState {
final List<Track> queue;
final int currentIndex;
final bool isPlaying;
final Duration position;
final Duration duration;
final bool isLoading;
final String? errorMessage;
const PlayerState({
this.queue = const [],
this.currentIndex = -1,
this.isPlaying = false,
this.position = Duration.zero,
this.duration = Duration.zero,
this.isLoading = false,
this.errorMessage,
});
Track? get currentTrack =>
currentIndex >= 0 && currentIndex < queue.length
? queue[currentIndex]
: null;
PlayerState copyWith({
List<Track>? queue,
int? currentIndex,
bool? isPlaying,
Duration? position,
Duration? duration,
bool? isLoading,
String? errorMessage,
}) {
return PlayerState(
queue: queue ?? this.queue,
currentIndex: currentIndex ?? this.currentIndex,
isPlaying: isPlaying ?? this.isPlaying,
position: position ?? this.position,
duration: duration ?? this.duration,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage,
);
}
}
/// Player notifier
class PlayerNotifier extends StateNotifier<PlayerState> {
PlayerNotifier() : super(const PlayerState()) {
_player = AudioPlayer();
_init();
}
late final AudioPlayer _player;
void _init() {
_player.positionStream.listen((position) {
state = state.copyWith(position: position);
});
_player.durationStream.listen((duration) {
state = state.copyWith(duration: duration ?? Duration.zero);
});
_player.playerStateStream.listen((playerState) {
state = state.copyWith(
isPlaying: playerState.playing,
isLoading: playerState.processingState == ProcessingState.loading,
);
});
}
Future<void> loadTrack(Track track) async {
state = state.copyWith(isLoading: true);
try {
// Get stream URL from API
final streamUrl = track.audioUrl ?? '';
await _player.setUrl(streamUrl);
if (state.queue.isEmpty) {
state = state.copyWith(queue: [track], currentIndex: 0);
}
} catch (e) {
state = state.copyWith(
isLoading: false,
errorMessage: e.toString(),
);
}
}
Future<void> play() async {
if (state.currentTrack != null) {
await _player.play();
state = state.copyWith(isPlaying: true);
}
}
Future<void> pause() async {
await _player.pause();
state = state.copyWith(isPlaying: false);
}
Future<void> seek(Duration position) async {
await _player.seek(position);
}
Future<void> next() async {
if (state.currentIndex < state.queue.length - 1) {
final nextTrack = state.queue[state.currentIndex + 1];
await loadTrack(nextTrack);
state = state.copyWith(currentIndex: state.currentIndex + 1);
await play();
}
}
Future<void> previous() async {
if (state.currentIndex > 0) {
final previousTrack = state.queue[state.currentIndex - 1];
await loadTrack(previousTrack);
state = state.copyWith(currentIndex: state.currentIndex - 1);
await play();
}
}
void setQueue(List<Track> tracks, {int startIndex = 0}) {
state = state.copyWith(
queue: tracks,
currentIndex: startIndex,
);
}
void addToQueue(Track track) {
final newQueue = [...state.queue, track];
state = state.copyWith(queue: newQueue);
}
void removeFromQueue(int index) {
if (index >= 0 && index < state.queue.length) {
final newQueue = [...state.queue]..removeAt(index);
state = state.copyWith(queue: newQueue);
}
}
@override
void dispose() {
_player.dispose();
super.dispose();
}
}
/// Player provider
final playerProvider =
StateNotifierProvider<PlayerNotifier, PlayerState>((ref) {
return PlayerNotifier();
});
/// Current track provider
final currentTrackProvider = Provider<Track?>((ref) {
return ref.watch(playerProvider).currentTrack;
});
/// Queue view data class
class QueueViewData {
final Track? currentTrack;
final List<Track> queue;
final int currentIndex;
final bool isPlaying;
const QueueViewData({
required this.currentTrack,
required this.queue,
required this.currentIndex,
required this.isPlaying,
});
/// Get upcoming tracks (after current)
List<Track> get nextTracks {
if (currentIndex < 0 || currentIndex >= queue.length - 1) {
return [];
}
return queue.sublist(currentIndex + 1);
}
/// Get previously played tracks (before current)
List<Track> get previousTracks {
if (currentIndex <= 0) {
return [];
}
return queue.sublist(0, currentIndex);
}
/// Check if queue has tracks
bool get hasQueue => queue.isNotEmpty;
/// Check if there are upcoming tracks
bool get hasNextTracks => nextTracks.isNotEmpty;
/// Check if there are previous tracks
bool get hasPreviousTracks => previousTracks.isNotEmpty;
/// Get total queue count excluding current
int get queueCount => queue.length - 1;
}
/// Queue view provider
final queueProvider = Provider<QueueViewData>((ref) {
final playerState = ref.watch(playerProvider);
return QueueViewData(
currentTrack: playerState.currentTrack,
queue: playerState.queue,
currentIndex: playerState.currentIndex,
isPlaying: playerState.isPlaying,
);
});
@@ -0,0 +1,41 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Navigation state
class NavigationState {
final String currentPage;
const NavigationState({
this.currentPage = 'home',
});
NavigationState copyWith({String? currentPage}) {
return NavigationState(
currentPage: currentPage ?? this.currentPage,
);
}
}
/// Navigation notifier
class NavigationNotifier extends StateNotifier<NavigationState> {
NavigationNotifier() : super(const NavigationState());
void navigateTo(String page) {
if (state.currentPage != page) {
state = state.copyWith(currentPage: page);
}
}
void goBack() {
// Simple navigation: always go to home
state = const NavigationState(currentPage: 'home');
}
}
/// Navigation provider
final navigationProvider =
StateNotifierProvider<NavigationNotifier, NavigationState>((ref) {
return NavigationNotifier();
});
/// Current page provider
final currentPageProvider = navigationProvider.select((state) => state.currentPage);
@@ -0,0 +1,243 @@
/// Playlist Provider - State management for playlist details
library;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../domain/entities/playlist.dart';
import '../../../domain/entities/track.dart';
import '../../../infrastructure/datasources/remote/playlist_api_service.dart';
import '../../providers/music_provider.dart';
/// Playlist state
class PlaylistState {
final Playlist? playlist;
final List<Track> tracks;
final bool isLoading;
final bool isReordering;
final String? error;
const PlaylistState({
this.playlist,
this.tracks = const [],
this.isLoading = false,
this.isReordering = false,
this.error,
});
PlaylistState copyWith({
Playlist? playlist,
List<Track>? tracks,
bool? isLoading,
bool? isReordering,
String? error,
}) {
return PlaylistState(
playlist: playlist ?? this.playlist,
tracks: tracks ?? this.tracks,
isLoading: isLoading ?? this.isLoading,
isReordering: isReordering ?? this.isReordering,
error: error,
);
}
/// Get total duration of all tracks
Duration get totalDuration {
final totalSeconds = tracks.fold<int>(
0,
(sum, track) => sum + (track.duration ?? 0),
);
return Duration(seconds: totalSeconds);
}
/// Format total duration
String get formattedTotalDuration {
final duration = totalDuration;
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
} else {
return '${minutes}m';
}
}
}
/// Playlist notifier
class PlaylistNotifier extends StateNotifier<PlaylistState> {
PlaylistNotifier(this._playlistApiService) : super(const PlaylistState());
final PlaylistApiService _playlistApiService;
/// Load playlist with tracks
Future<void> loadPlaylist(String playlistId) async {
state = state.copyWith(isLoading: true, error: null);
try {
final response = await _playlistApiService.getPlaylist(playlistId);
// Parse playlist
final playlist = Playlist.fromJson(response);
// Parse tracks from response
final tracks = <Track>[];
if (response['tracks'] != null) {
for (final trackData in response['tracks'] as List) {
if (trackData is Map<String, dynamic> && trackData['track'] != null) {
final track = Track.fromJson(trackData['track'] as Map<String, dynamic>);
tracks.add(track);
}
}
}
state = PlaylistState(
playlist: playlist,
tracks: tracks,
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
/// Add track to playlist
Future<void> addTrack(Track track, {int? position}) async {
if (state.playlist == null) return;
try {
await _playlistApiService.addTracks(
state.playlist!.id,
[track.id],
position: position,
);
// Reload playlist to get updated tracks
await loadPlaylist(state.playlist!.id);
} catch (e) {
state = state.copyWith(error: e.toString());
}
}
/// Remove track from playlist
Future<void> removeTrack(String trackId) async {
if (state.playlist == null) return;
try {
await _playlistApiService.removeTrack(state.playlist!.id, trackId);
// Update local state
final updatedTracks = state.tracks.where((t) => t.id != trackId).toList();
state = state.copyWith(tracks: updatedTracks);
} catch (e) {
state = state.copyWith(error: e.toString());
}
}
/// Reorder tracks
Future<void> reorderTracks(int oldIndex, int newIndex) async {
if (state.playlist == null || state.tracks.isEmpty) return;
// Update local state immediately for responsiveness
final updatedTracks = List<Track>.from(state.tracks);
final track = updatedTracks.removeAt(oldIndex);
updatedTracks.insert(newIndex, track);
state = state.copyWith(tracks: updatedTracks, isReordering: true);
try {
// Call API to update position
await _playlistApiService.reorderTrack(
state.playlist!.id,
track.id,
newIndex,
);
state = state.copyWith(isReordering: false);
} catch (e) {
// Revert on error
state = state.copyWith(
tracks: state.tracks,
isReordering: false,
error: e.toString(),
);
}
}
/// Update playlist details
Future<void> updatePlaylist({
String? name,
String? description,
String? imageUrl,
bool? isPublic,
}) async {
if (state.playlist == null) return;
try {
final response = await _playlistApiService.updatePlaylist(
state.playlist!.id,
name: name,
description: description,
imageUrl: imageUrl,
isPublic: isPublic,
);
final updatedPlaylist = Playlist.fromJson(response);
state = state.copyWith(playlist: updatedPlaylist);
} catch (e) {
state = state.copyWith(error: e.toString());
}
}
/// Delete playlist
Future<void> deletePlaylist() async {
if (state.playlist == null) return;
try {
await _playlistApiService.deletePlaylist(state.playlist!.id);
state = const PlaylistState();
} catch (e) {
state = state.copyWith(error: e.toString());
}
}
/// Shuffle and play playlist
void shufflePlaylist(PlayerNotifier playerNotifier) {
if (state.tracks.isEmpty) return;
final shuffledTracks = List<Track>.from(state.tracks)..shuffle();
playerNotifier.setQueue(shuffledTracks, startIndex: 0);
}
/// Play playlist from start
void playPlaylist(PlayerNotifier playerNotifier) {
if (state.tracks.isEmpty) return;
playerNotifier.setQueue(state.tracks, startIndex: 0);
}
}
/// Playlist provider
final playlistProvider =
StateNotifierProvider.family<PlaylistNotifier, PlaylistState, String>(
(ref, playlistId) {
final playlistApiService = ref.watch(playlistApiServiceProvider);
final notifier = PlaylistNotifier(playlistApiService);
// Auto-load playlist
Future.microtask(() => notifier.loadPlaylist(playlistId));
return notifier;
},
);
/// Current playlist tracks provider (for easy access)
final playlistTracksProvider = Provider.family<List<Track>, String>(
(ref, playlistId) {
final playlistState = ref.watch(playlistProvider(playlistId));
return playlistState.tracks;
},
);
@@ -0,0 +1,125 @@
/// Search Provider - Search state management
library;
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../infrastructure/datasources/remote/music_api_service.dart';
import '../../../domain/entities/track.dart';
import '../../../domain/entities/artist.dart';
import '../../../domain/entities/album.dart';
/// Search state
class SearchState {
final String query;
final bool isSearching;
final List<Track> tracks;
final List<Artist> artists;
final List<Album> albums;
final String? error;
const SearchState({
this.query = '',
this.isSearching = false,
this.tracks = const [],
this.artists = const [],
this.albums = const [],
this.error,
});
SearchState copyWith({
String? query,
bool? isSearching,
List<Track>? tracks,
List<Artist>? artists,
List<Album>? albums,
String? error,
}) {
return SearchState(
query: query ?? this.query,
isSearching: isSearching ?? this.isSearching,
tracks: tracks ?? this.tracks,
artists: artists ?? this.artists,
albums: albums ?? this.albums,
error: error,
);
}
int get totalResults => tracks.length + artists.length + albums.length;
}
/// Search notifier with debouncing
class SearchNotifier extends StateNotifier<SearchState> {
SearchNotifier(this._musicApiService) : super(const SearchState());
final MusicApiService _musicApiService;
Timer? _debounceTimer;
static const _debounceDuration = Duration(milliseconds: 500);
void search(String query) {
if (query.trim().isEmpty) {
state = const SearchState();
_debounceTimer?.cancel();
return;
}
_debounceTimer?.cancel();
state = state.copyWith(query: query, isSearching: true);
_debounceTimer = Timer(_debounceDuration, () => _performSearch(query));
}
Future<void> _performSearch(String query) async {
try {
final results = await _musicApiService.search(
query,
type: 'all',
limit: 20,
);
state = SearchState(
query: query,
tracks: (results['tracks'] as List?)
?.map((json) => Track.fromJson(json as Map<String, dynamic>))
.toList() ??
[],
artists: (results['artists'] as List?)
?.map((json) => Artist.fromJson(json as Map<String, dynamic>))
.toList() ??
[],
albums: (results['albums'] as List?)
?.map((json) => Album.fromJson(json as Map<String, dynamic>))
.toList() ??
[],
);
} catch (e) {
state = SearchState(
query: query,
error: e.toString(),
);
} finally {
// Keep isSearching false if this was the latest search
if (state.query == query) {
state = state.copyWith(isSearching: false);
}
}
}
void clear() {
_debounceTimer?.cancel();
state = const SearchState();
}
@override
void dispose() {
_debounceTimer?.cancel();
super.dispose();
}
}
/// Search provider
final searchProvider = StateNotifierProvider<SearchNotifier, SearchState>((ref) {
final musicApiService = ref.watch(musicApiServiceProvider);
return SearchNotifier(musicApiService);
});
@@ -0,0 +1,290 @@
/// Settings Provider
library;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';
import '../../../domain/entities/user.dart';
import '../../../infrastructure/datasources/remote/auth_api_service.dart';
/// Audio Quality enum
enum AudioQuality { low, medium, high, lossless }
/// Settings state
class SettingsState {
final User? user;
final AudioQuality audioQuality;
final bool downloadOnMobileData;
final bool showExplicitContent;
final bool crossfadeEnabled;
final double crossfadeDuration;
final bool gaplessPlayback;
final bool normalizeVolume;
final String cacheSize;
final bool isLoading;
final String? error;
const SettingsState({
this.user,
this.audioQuality = AudioQuality.high,
this.downloadOnMobileData = false,
this.showExplicitContent = true,
this.crossfadeEnabled = false,
this.crossfadeDuration = 5.0,
this.gaplessPlayback = true,
this.normalizeVolume = false,
this.cacheSize = '0 MB',
this.isLoading = false,
this.error,
});
SettingsState copyWith({
User? user,
AudioQuality? audioQuality,
bool? downloadOnMobileData,
bool? showExplicitContent,
bool? crossfadeEnabled,
double? crossfadeDuration,
bool? gaplessPlayback,
bool? normalizeVolume,
String? cacheSize,
bool? isLoading,
String? error,
}) {
return SettingsState(
user: user ?? this.user,
audioQuality: audioQuality ?? this.audioQuality,
downloadOnMobileData: downloadOnMobileData ?? this.downloadOnMobileData,
showExplicitContent: showExplicitContent ?? this.showExplicitContent,
crossfadeEnabled: crossfadeEnabled ?? this.crossfadeEnabled,
crossfadeDuration: crossfadeDuration ?? this.crossfadeDuration,
gaplessPlayback: gaplessPlayback ?? this.gaplessPlayback,
normalizeVolume: normalizeVolume ?? this.normalizeVolume,
cacheSize: cacheSize ?? this.cacheSize,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
}
/// Settings notifier
class SettingsNotifier extends StateNotifier<SettingsState> {
SettingsNotifier(this._authApiService) : super(const SettingsState()) {
_loadSettingsFromPrefs();
}
final AuthApiService _authApiService;
SharedPreferences? _prefs;
// Keys for shared preferences
static const String _audioQualityKey = 'audio_quality';
static const String _downloadOnMobileDataKey = 'download_on_mobile_data';
static const String _showExplicitContentKey = 'show_explicit_content';
static const String _crossfadeEnabledKey = 'crossfade_enabled';
static const String _crossfadeDurationKey = 'crossfade_duration';
static const String _gaplessPlaybackKey = 'gapless_playback';
static const String _normalizeVolumeKey = 'normalize_volume';
/// Initialize shared preferences and load settings
Future<void> _loadSettingsFromPrefs() async {
_prefs = await SharedPreferences.getInstance();
final audioQualityIndex = _prefs?.getInt(_audioQualityKey) ?? 2;
final downloadOnMobileData = _prefs?.getBool(_downloadOnMobileDataKey) ?? false;
final showExplicitContent = _prefs?.getBool(_showExplicitContentKey) ?? true;
final crossfadeEnabled = _prefs?.getBool(_crossfadeEnabledKey) ?? false;
final crossfadeDuration = _prefs?.getDouble(_crossfadeDurationKey) ?? 5.0;
final gaplessPlayback = _prefs?.getBool(_gaplessPlaybackKey) ?? true;
final normalizeVolume = _prefs?.getBool(_normalizeVolumeKey) ?? false;
state = state.copyWith(
audioQuality: AudioQuality.values[audioQualityIndex],
downloadOnMobileData: downloadOnMobileData,
showExplicitContent: showExplicitContent,
crossfadeEnabled: crossfadeEnabled,
crossfadeDuration: crossfadeDuration,
gaplessPlayback: gaplessPlayback,
normalizeVolume: normalizeVolume,
);
await _calculateCacheSize();
}
/// Load user profile from API
Future<void> loadSettings() async {
state = state.copyWith(isLoading: true, error: null);
try {
final user = await _authApiService.getCurrentUser();
state = state.copyWith(user: user, isLoading: false);
} catch (e) {
state = state.copyWith(
error: e.toString(),
isLoading: false,
);
}
}
/// Update user profile
Future<void> updateProfile({
String? displayName,
String? avatarUrl,
}) async {
state = state.copyWith(isLoading: true, error: null);
try {
final updatedUser = await _authApiService.updateProfile(
displayName: displayName,
avatarUrl: avatarUrl,
);
state = state.copyWith(
user: updatedUser,
isLoading: false,
);
} catch (e) {
state = state.copyWith(
error: e.toString(),
isLoading: false,
);
rethrow;
}
}
/// Set audio quality
Future<void> setAudioQuality(AudioQuality quality) async {
await _prefs?.setInt(_audioQualityKey, quality.index);
state = state.copyWith(audioQuality: quality);
}
/// Toggle download on mobile data
Future<void> toggleDownloadOnMobileData(bool value) async {
await _prefs?.setBool(_downloadOnMobileDataKey, value);
state = state.copyWith(downloadOnMobileData: value);
}
/// Toggle explicit content
Future<void> toggleShowExplicitContent(bool value) async {
await _prefs?.setBool(_showExplicitContentKey, value);
state = state.copyWith(showExplicitContent: value);
}
/// Toggle crossfade
Future<void> toggleCrossfade(bool value) async {
await _prefs?.setBool(_crossfadeEnabledKey, value);
state = state.copyWith(crossfadeEnabled: value);
}
/// Set crossfade duration
Future<void> setCrossfadeDuration(double duration) async {
await _prefs?.setDouble(_crossfadeDurationKey, duration);
state = state.copyWith(crossfadeDuration: duration);
}
/// Toggle gapless playback
Future<void> toggleGaplessPlayback(bool value) async {
await _prefs?.setBool(_gaplessPlaybackKey, value);
state = state.copyWith(gaplessPlayback: value);
}
/// Toggle normalize volume
Future<void> toggleNormalizeVolume(bool value) async {
await _prefs?.setBool(_normalizeVolumeKey, value);
state = state.copyWith(normalizeVolume: value);
}
/// Calculate cache size
Future<void> _calculateCacheSize() async {
try {
final tempDir = await getTemporaryDirectory();
final appDocDir = await getApplicationDocumentsDirectory();
final tempSize = _getFolderSize(tempDir);
final docSize = _getFolderSize(appDocDir);
final totalSize = tempSize + docSize;
final cacheSizeStr = _formatBytes(totalSize);
state = state.copyWith(cacheSize: cacheSizeStr);
} catch (e) {
state = state.copyWith(cacheSize: 'Unknown');
}
}
/// Get folder size in bytes
int _getFolderSize(Directory dir) {
int size = 0;
try {
if (dir.existsSync()) {
dir
.listSync(recursive: true, followLinks: false)
.forEach((FileSystemEntity entity) {
if (entity is File) {
size += entity.lengthSync();
}
});
}
} catch (e) {
// Ignore errors
}
return size;
}
/// Format bytes to human readable string
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
/// Clear cache
Future<void> clearCache() async {
state = state.copyWith(isLoading: true);
try {
final tempDir = await getTemporaryDirectory();
if (await tempDir.exists()) {
await _deleteFolderContents(tempDir);
}
await _calculateCacheSize();
state = state.copyWith(isLoading: false);
} catch (e) {
state = state.copyWith(
error: e.toString(),
isLoading: false,
);
rethrow;
}
}
/// Delete folder contents
Future<void> _deleteFolderContents(Directory dir) async {
try {
if (await dir.exists()) {
await for (final entity in dir.list()) {
if (entity is File) {
await entity.delete();
} else if (entity is Directory) {
await entity.delete(recursive: true);
}
}
}
} catch (e) {
// Ignore errors
}
}
}
/// Settings provider
final settingsProvider = StateNotifierProvider<SettingsNotifier, SettingsState>(
(ref) {
final authApiService = ref.watch(authApiServiceProvider);
return SettingsNotifier(authApiService);
},
);