a89c7894cf
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>
291 lines
8.6 KiB
Dart
291 lines
8.6 KiB
Dart
/// 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);
|
|
},
|
|
);
|