🎉 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:
@@ -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);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user