mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-05 04:08:02 +02:00
chore: cleanup unused code and dead imports
This commit is contained in:
@@ -12,11 +12,9 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize services - CoverCacheManager MUST complete before app starts
|
||||
await CoverCacheManager.initialize();
|
||||
debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}');
|
||||
|
||||
// These can run in parallel
|
||||
await Future.wait([
|
||||
NotificationService().initialize(),
|
||||
ShareIntentService().initialize(),
|
||||
|
||||
@@ -3,23 +3,21 @@ import 'package:spotiflac_android/models/track.dart';
|
||||
|
||||
part 'download_item.g.dart';
|
||||
|
||||
/// Download status enum
|
||||
enum DownloadStatus {
|
||||
queued,
|
||||
downloading,
|
||||
finalizing, // Embedding metadata, cover, lyrics
|
||||
finalizing,
|
||||
completed,
|
||||
failed,
|
||||
skipped,
|
||||
}
|
||||
|
||||
/// Error type enum for better error handling
|
||||
enum DownloadErrorType {
|
||||
unknown,
|
||||
notFound, // Track not found on any service
|
||||
rateLimit, // Rate limited by service
|
||||
network, // Network/connection error
|
||||
permission, // File/folder permission error
|
||||
notFound,
|
||||
rateLimit,
|
||||
network,
|
||||
permission,
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
@@ -29,7 +27,7 @@ class DownloadItem {
|
||||
final String service;
|
||||
final DownloadStatus status;
|
||||
final double progress;
|
||||
final double speedMBps; // Download speed in MB/s
|
||||
final double speedMBps;
|
||||
final String? filePath;
|
||||
final String? error;
|
||||
final DownloadErrorType? errorType;
|
||||
@@ -78,7 +76,6 @@ class DownloadItem {
|
||||
);
|
||||
}
|
||||
|
||||
/// Get user-friendly error message based on error type
|
||||
String get errorMessage {
|
||||
if (error == null) return '';
|
||||
|
||||
|
||||
+43
-43
@@ -12,27 +12,27 @@ class AppSettings {
|
||||
final bool embedLyrics;
|
||||
final bool maxQualityCover;
|
||||
final bool isFirstLaunch;
|
||||
final int concurrentDownloads; // 1 = sequential (default), max 3
|
||||
final bool checkForUpdates; // Check for updates on app start
|
||||
final String updateChannel; // stable, preview
|
||||
final bool hasSearchedBefore; // Hide helper text after first search
|
||||
final String folderOrganization; // none, artist, album, artist_album
|
||||
final String historyViewMode; // list, grid
|
||||
final String historyFilterMode; // all, albums, singles
|
||||
final bool askQualityBeforeDownload; // Show quality picker before each download
|
||||
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
||||
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
||||
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
||||
final String metadataSource; // spotify, deezer - source for search and metadata
|
||||
final bool enableLogging; // Enable detailed logging for debugging
|
||||
final bool useExtensionProviders; // Use extension providers for downloads when available
|
||||
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
|
||||
final bool separateSingles; // Separate singles/EPs into their own folder
|
||||
final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album
|
||||
final bool showExtensionStore; // Show Extension Store tab in navigation
|
||||
final String locale; // App language: 'system', 'en', 'id', etc.
|
||||
final bool enableMp3Option; // Enable MP3 quality option (default off, requires FFmpeg conversion)
|
||||
final String lyricsMode; // embed, external, both - how to save lyrics
|
||||
final int concurrentDownloads;
|
||||
final bool checkForUpdates;
|
||||
final String updateChannel;
|
||||
final bool hasSearchedBefore;
|
||||
final String folderOrganization;
|
||||
final String historyViewMode;
|
||||
final String historyFilterMode;
|
||||
final bool askQualityBeforeDownload;
|
||||
final String spotifyClientId;
|
||||
final String spotifyClientSecret;
|
||||
final bool useCustomSpotifyCredentials;
|
||||
final String metadataSource;
|
||||
final bool enableLogging;
|
||||
final bool useExtensionProviders;
|
||||
final String? searchProvider;
|
||||
final bool separateSingles;
|
||||
final String albumFolderStructure;
|
||||
final bool showExtensionStore;
|
||||
final String locale;
|
||||
final bool enableMp3Option;
|
||||
final String lyricsMode;
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
@@ -43,27 +43,27 @@ class AppSettings {
|
||||
this.embedLyrics = true,
|
||||
this.maxQualityCover = true,
|
||||
this.isFirstLaunch = true,
|
||||
this.concurrentDownloads = 1, // Default: sequential (off)
|
||||
this.checkForUpdates = true, // Default: enabled
|
||||
this.updateChannel = 'stable', // Default: stable releases only
|
||||
this.hasSearchedBefore = false, // Default: show helper text
|
||||
this.folderOrganization = 'none', // Default: no folder organization
|
||||
this.historyViewMode = 'grid', // Default: grid view
|
||||
this.historyFilterMode = 'all', // Default: show all
|
||||
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
||||
this.spotifyClientId = '', // Default: use built-in credentials
|
||||
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
||||
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
||||
this.enableLogging = false, // Default: disabled for performance
|
||||
this.useExtensionProviders = true, // Default: use extensions when available
|
||||
this.searchProvider, // Default: null (use Deezer/Spotify)
|
||||
this.separateSingles = false, // Default: disabled
|
||||
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
|
||||
this.showExtensionStore = true, // Default: show store
|
||||
this.locale = 'system', // Default: follow system language
|
||||
this.enableMp3Option = false, // Default: disabled
|
||||
this.lyricsMode = 'embed', // Default: embed lyrics into file
|
||||
this.concurrentDownloads = 1,
|
||||
this.checkForUpdates = true,
|
||||
this.updateChannel = 'stable',
|
||||
this.hasSearchedBefore = false,
|
||||
this.folderOrganization = 'none',
|
||||
this.historyViewMode = 'grid',
|
||||
this.historyFilterMode = 'all',
|
||||
this.askQualityBeforeDownload = true,
|
||||
this.spotifyClientId = '',
|
||||
this.spotifyClientSecret = '',
|
||||
this.useCustomSpotifyCredentials = true,
|
||||
this.metadataSource = 'deezer',
|
||||
this.enableLogging = false,
|
||||
this.useExtensionProviders = true,
|
||||
this.searchProvider,
|
||||
this.separateSingles = false,
|
||||
this.albumFolderStructure = 'artist_album',
|
||||
this.showExtensionStore = true,
|
||||
this.locale = 'system',
|
||||
this.enableMp3Option = false,
|
||||
this.lyricsMode = 'embed',
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -90,7 +90,7 @@ class AppSettings {
|
||||
bool? enableLogging,
|
||||
bool? useExtensionProviders,
|
||||
String? searchProvider,
|
||||
bool clearSearchProvider = false, // Set to true to clear searchProvider to null
|
||||
bool clearSearchProvider = false,
|
||||
bool? separateSingles,
|
||||
String? albumFolderStructure,
|
||||
bool? showExtensionStore,
|
||||
|
||||
@@ -9,7 +9,6 @@ const String kUseAmoledKey = 'use_amoled';
|
||||
/// Default Spotify green color for fallback
|
||||
const int kDefaultSeedColor = 0xFF1DB954;
|
||||
|
||||
/// Theme settings model for Material Expressive 3
|
||||
class ThemeSettings {
|
||||
final ThemeMode themeMode;
|
||||
final bool useDynamicColor;
|
||||
@@ -23,10 +22,8 @@ class ThemeSettings {
|
||||
this.useAmoled = false,
|
||||
});
|
||||
|
||||
/// Get seed color as Color object
|
||||
Color get seedColor => Color(seedColorValue);
|
||||
|
||||
/// Create a copy with updated values
|
||||
ThemeSettings copyWith({
|
||||
ThemeMode? themeMode,
|
||||
bool? useDynamicColor,
|
||||
@@ -41,7 +38,6 @@ class ThemeSettings {
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to JSON map for persistence
|
||||
Map<String, dynamic> toJson() => {
|
||||
kThemeModeKey: themeMode.name,
|
||||
kUseDynamicColorKey: useDynamicColor,
|
||||
@@ -49,7 +45,6 @@ class ThemeSettings {
|
||||
kUseAmoledKey: useAmoled,
|
||||
};
|
||||
|
||||
/// Create from JSON map
|
||||
factory ThemeSettings.fromJson(Map<String, dynamic> json) {
|
||||
return ThemeSettings(
|
||||
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
|
||||
@@ -74,7 +69,6 @@ class ThemeSettings {
|
||||
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
|
||||
}
|
||||
|
||||
/// Helper to convert string to ThemeMode
|
||||
ThemeMode _themeModeFromString(String? value) {
|
||||
if (value == null) return ThemeMode.system;
|
||||
return ThemeMode.values.firstWhere(
|
||||
|
||||
+3
-10
@@ -2,7 +2,6 @@ import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'track.g.dart';
|
||||
|
||||
/// Track model representing a music track
|
||||
@JsonSerializable()
|
||||
class Track {
|
||||
final String id;
|
||||
@@ -18,9 +17,9 @@ class Track {
|
||||
final String? releaseDate;
|
||||
final String? deezerId;
|
||||
final ServiceAvailability? availability;
|
||||
final String? source; // Extension ID that provided this track (null for built-in sources)
|
||||
final String? albumType; // album, single, ep, compilation (from metadata API)
|
||||
final String? itemType; // track, album, playlist - for extension search results
|
||||
final String? source;
|
||||
final String? albumType;
|
||||
final String? itemType;
|
||||
|
||||
const Track({
|
||||
required this.id,
|
||||
@@ -41,25 +40,19 @@ class Track {
|
||||
this.itemType,
|
||||
});
|
||||
|
||||
/// Check if this track is a single (based on album_type metadata)
|
||||
bool get isSingle => albumType == 'single' || albumType == 'ep';
|
||||
|
||||
/// Check if this is an album item (not a track)
|
||||
bool get isAlbumItem => itemType == 'album';
|
||||
|
||||
/// Check if this is a playlist item (not a track)
|
||||
bool get isPlaylistItem => itemType == 'playlist';
|
||||
|
||||
/// Check if this is an artist item (not a track)
|
||||
bool get isArtistItem => itemType == 'artist';
|
||||
|
||||
/// Check if this is a collection (album, playlist, or artist)
|
||||
bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem;
|
||||
|
||||
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
||||
|
||||
/// Check if this track is from an extension
|
||||
bool get isFromExtension => source != null && source!.isNotEmpty;
|
||||
}
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ class DownloadHistoryItem {
|
||||
|
||||
class DownloadHistoryState {
|
||||
final List<DownloadHistoryItem> items;
|
||||
final Set<String> _downloadedSpotifyIds; // Cache for O(1) lookup
|
||||
final Set<String> _downloadedSpotifyIds;
|
||||
|
||||
DownloadHistoryState({this.items = const []})
|
||||
: _downloadedSpotifyIds = items
|
||||
@@ -133,7 +133,6 @@ class DownloadHistoryState {
|
||||
.map((item) => item.spotifyId!)
|
||||
.toSet();
|
||||
|
||||
/// Check if a track has been downloaded (by Spotify ID)
|
||||
bool isDownloaded(String spotifyId) =>
|
||||
_downloadedSpotifyIds.contains(spotifyId);
|
||||
|
||||
@@ -188,7 +187,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deduplicate history items by spotifyId, deezerId, or ISRC
|
||||
/// Keeps the most recent entry (first occurrence since list is sorted by date desc)
|
||||
List<DownloadHistoryItem> _deduplicateHistory(List<DownloadHistoryItem> items) {
|
||||
final seen = <String, int>{}; // key -> index of first occurrence
|
||||
@@ -234,7 +232,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Force reload from storage (useful after app restart)
|
||||
Future<void> reloadFromStorage() async {
|
||||
await _loadFromStorage();
|
||||
}
|
||||
@@ -285,7 +282,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
_saveToStorage();
|
||||
}
|
||||
|
||||
/// Remove item from history by Spotify ID
|
||||
void removeBySpotifyId(String spotifyId) {
|
||||
state = state.copyWith(
|
||||
items: state.items.where((item) => item.spotifyId != spotifyId).toList(),
|
||||
@@ -294,7 +290,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
_historyLog.d('Removed item with spotifyId: $spotifyId');
|
||||
}
|
||||
|
||||
/// Get history item by Spotify ID
|
||||
DownloadHistoryItem? getBySpotifyId(String spotifyId) {
|
||||
return state.items.where((item) => item.spotifyId == spotifyId).firstOrNull;
|
||||
}
|
||||
@@ -314,12 +309,12 @@ class DownloadQueueState {
|
||||
final List<DownloadItem> items;
|
||||
final DownloadItem? currentDownload;
|
||||
final bool isProcessing;
|
||||
final bool isPaused; // NEW: pause state
|
||||
final bool isPaused;
|
||||
final String outputDir;
|
||||
final String filenameFormat;
|
||||
final String audioQuality; // LOSSLESS, HI_RES, HI_RES_LOSSLESS
|
||||
final String audioQuality;
|
||||
final bool autoFallback;
|
||||
final int concurrentDownloads; // 1 = sequential, max 3
|
||||
final int concurrentDownloads;
|
||||
|
||||
const DownloadQueueState({
|
||||
this.items = const [],
|
||||
@@ -386,14 +381,13 @@ class _ProgressUpdate {
|
||||
|
||||
class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
Timer? _progressTimer;
|
||||
int _downloadCount = 0; // Counter for connection cleanup
|
||||
static const _cleanupInterval = 50; // Cleanup every 50 downloads
|
||||
static const _queueStorageKey =
|
||||
'download_queue'; // Storage key for queue persistence
|
||||
int _downloadCount = 0;
|
||||
static const _cleanupInterval = 50;
|
||||
static const _queueStorageKey = 'download_queue';
|
||||
final NotificationService _notificationService = NotificationService();
|
||||
int _totalQueuedAtStart = 0; // Track total items when queue started
|
||||
int _completedInSession = 0; // Track completed downloads in current session
|
||||
int _failedInSession = 0; // Track failed downloads in current session
|
||||
int _totalQueuedAtStart = 0;
|
||||
int _completedInSession = 0;
|
||||
int _failedInSession = 0;
|
||||
bool _isLoaded = false;
|
||||
final Set<String> _ensuredDirs = {};
|
||||
|
||||
@@ -411,7 +405,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return const DownloadQueueState();
|
||||
}
|
||||
|
||||
/// Load persisted queue from storage (for app restart recovery)
|
||||
Future<void> _loadQueueFromStorage() async {
|
||||
if (_isLoaded) return;
|
||||
_isLoaded = true;
|
||||
@@ -453,7 +446,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Save current queue to storage (only pending items)
|
||||
Future<void> _saveQueueToStorage() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -479,7 +471,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start multi-progress polling for all downloads (sequential and parallel)
|
||||
void _startMultiProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (
|
||||
@@ -607,7 +598,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
trackName: finalizingTrackName,
|
||||
artistName: finalizingArtistName ?? '',
|
||||
);
|
||||
return; // Don't show download progress notification
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.isNotEmpty) {
|
||||
@@ -651,14 +642,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
progress: notifProgress,
|
||||
total: notifTotal > 0 ? notifTotal : 1,
|
||||
queueCount: state.queuedCount,
|
||||
).catchError((_) {}); // Ignore errors
|
||||
).catchError((_) {});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently ignore polling errors to avoid spamming logs
|
||||
// Polling is not critical and will retry on next interval
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -725,7 +713,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
state = state.copyWith(outputDir: dir);
|
||||
}
|
||||
|
||||
/// Build output directory based on folder organization setting and separateSingles
|
||||
Future<String> _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false, String albumFolderStructure = 'artist_album'}) async {
|
||||
String baseDir = state.outputDir;
|
||||
final albumArtist = _normalizeOptionalString(track.albumArtist) ?? track.artistName;
|
||||
@@ -794,7 +781,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return baseDir;
|
||||
}
|
||||
|
||||
/// Sanitize folder names (remove invalid characters)
|
||||
String _sanitizeFolderName(String name) {
|
||||
return name
|
||||
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_')
|
||||
@@ -866,7 +852,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}).toList();
|
||||
|
||||
state = state.copyWith(items: [...state.items, ...newItems]);
|
||||
_saveQueueToStorage(); // Persist queue
|
||||
_saveQueueToStorage();
|
||||
|
||||
if (!state.isProcessing) {
|
||||
Future.microtask(() => _processQueue());
|
||||
@@ -951,15 +937,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
.toList();
|
||||
|
||||
state = state.copyWith(items: items);
|
||||
_saveQueueToStorage(); // Persist queue
|
||||
_saveQueueToStorage();
|
||||
}
|
||||
|
||||
void clearAll() {
|
||||
state = state.copyWith(items: [], isPaused: false);
|
||||
_saveQueueToStorage(); // Clear persisted queue
|
||||
_saveQueueToStorage();
|
||||
}
|
||||
|
||||
/// Pause the download queue
|
||||
void pauseQueue() {
|
||||
if (state.isProcessing && !state.isPaused) {
|
||||
state = state.copyWith(isPaused: true);
|
||||
@@ -968,7 +953,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume the download queue
|
||||
void resumeQueue() {
|
||||
if (state.isPaused) {
|
||||
state = state.copyWith(isPaused: false);
|
||||
@@ -979,7 +963,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle pause/resume
|
||||
void togglePause() {
|
||||
if (state.isPaused) {
|
||||
resumeQueue();
|
||||
@@ -988,7 +971,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry a failed or skipped download
|
||||
void retryItem(String id) {
|
||||
final item = state.items.where((i) => i.id == id).firstOrNull;
|
||||
if (item == null) {
|
||||
@@ -1025,14 +1007,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a specific item from queue
|
||||
void removeItem(String id) {
|
||||
final items = state.items.where((item) => item.id != id).toList();
|
||||
state = state.copyWith(items: items);
|
||||
_saveQueueToStorage(); // Persist queue
|
||||
_saveQueueToStorage();
|
||||
}
|
||||
|
||||
/// Run post-processing hooks on a downloaded file
|
||||
Future<void> _runPostProcessingHooks(String filePath, Track track) async {
|
||||
try {
|
||||
final settings = ref.read(settingsProvider);
|
||||
@@ -1079,7 +1059,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Upgrade Spotify cover URL to max quality (~2000x2000)
|
||||
/// Same logic as Go backend cover.go
|
||||
String _upgradeToMaxQualityCover(String coverUrl) {
|
||||
const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small)
|
||||
@@ -1098,7 +1077,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Embed metadata and cover to a FLAC file after M4A conversion
|
||||
Future<void> _embedMetadataAndCover(
|
||||
String flacPath,
|
||||
Track track, {
|
||||
@@ -1155,12 +1133,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
if (track.trackNumber != null) {
|
||||
metadata['TRACKNUMBER'] = track.trackNumber.toString();
|
||||
metadata['TRACK'] = track.trackNumber.toString(); // Compatibility
|
||||
metadata['TRACK'] = track.trackNumber.toString();
|
||||
}
|
||||
|
||||
if (track.discNumber != null) {
|
||||
metadata['DISCNUMBER'] = track.discNumber.toString();
|
||||
metadata['DISC'] = track.discNumber.toString(); // Compatibility
|
||||
metadata['DISC'] = track.discNumber.toString();
|
||||
}
|
||||
|
||||
if (track.releaseDate != null) {
|
||||
@@ -1172,7 +1150,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
metadata['ISRC'] = track.isrc!;
|
||||
}
|
||||
|
||||
// Extended metadata from enrichment (genre, label, copyright)
|
||||
if (genre != null && genre.isNotEmpty) {
|
||||
metadata['GENRE'] = genre;
|
||||
_log.d('Adding GENRE: $genre');
|
||||
@@ -1189,20 +1166,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.d('Metadata map content: $metadata');
|
||||
|
||||
try {
|
||||
// Convert duration from seconds to milliseconds for better lyrics matching
|
||||
final durationMs = track.duration * 1000;
|
||||
|
||||
final lrcContent = await PlatformBridge.getLyricsLRC(
|
||||
track.id, // spotifyID
|
||||
track.id,
|
||||
track.name,
|
||||
track.artistName,
|
||||
filePath: '', // No local file path yet (processed in memory)
|
||||
filePath: '',
|
||||
durationMs: durationMs,
|
||||
);
|
||||
|
||||
if (lrcContent.isNotEmpty) {
|
||||
metadata['LYRICS'] = lrcContent;
|
||||
metadata['UNSYNCEDLYRICS'] = lrcContent; // Fallback for some players
|
||||
metadata['UNSYNCEDLYRICS'] = lrcContent;
|
||||
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1240,7 +1216,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Embed metadata, lyrics, and cover to a MP3 file
|
||||
Future<void> _embedMetadataToMp3(String mp3Path, Track track) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
@@ -1310,7 +1285,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
_log.d('MP3 Metadata map content: $metadata');
|
||||
|
||||
// Fetch lyrics if embedLyrics is enabled
|
||||
if (settings.embedLyrics) {
|
||||
try {
|
||||
final durationMs = track.duration * 1000;
|
||||
@@ -1365,7 +1339,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
Future<void> _processQueue() async {
|
||||
if (state.isProcessing) return; // Prevent multiple concurrent processing
|
||||
if (state.isProcessing) return;
|
||||
|
||||
state = state.copyWith(isProcessing: true);
|
||||
_log.i('Starting queue processing...');
|
||||
@@ -1462,7 +1436,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sequential download processing (uses multi-progress system with single item)
|
||||
Future<void> _processQueueSequential() async {
|
||||
_startMultiProgressPolling();
|
||||
|
||||
@@ -1508,10 +1481,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
|
||||
/// Parallel download processing with worker pool
|
||||
Future<void> _processQueueParallel() async {
|
||||
final maxConcurrent = state.concurrentDownloads;
|
||||
final activeDownloads = <String, Future<void>>{}; // Map item ID to future
|
||||
final activeDownloads = <String, Future<void>>{};
|
||||
|
||||
|
||||
_startMultiProgressPolling();
|
||||
|
||||
@@ -1565,7 +1538,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
|
||||
/// Download a single item (used by both sequential and parallel processing)
|
||||
Future<void> _downloadSingleItem(DownloadItem item) async {
|
||||
_log.d('Processing: ${item.track.name} by ${item.track.artistName}');
|
||||
_log.d('Cover URL: ${item.track.coverUrl}');
|
||||
@@ -1628,7 +1600,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
trackToDownload.albumName,
|
||||
albumArtist: data['album_artist'] as String?,
|
||||
coverUrl: data['images'] as String?,
|
||||
// duration_ms from Go is in milliseconds, Track.duration is in seconds
|
||||
duration:
|
||||
((data['duration_ms'] as int?) ??
|
||||
(trackToDownload.duration * 1000)) ~/
|
||||
@@ -1675,7 +1646,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
String? genre;
|
||||
String? label;
|
||||
|
||||
// Try to get Deezer track ID from various sources
|
||||
String? deezerTrackId = trackToDownload.deezerId;
|
||||
if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) {
|
||||
deezerTrackId = trackToDownload.id.split(':')[1];
|
||||
@@ -1696,7 +1666,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to fetch extended metadata from Deezer: $e');
|
||||
// Continue without extended metadata
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1728,7 +1697,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
releaseDate: trackToDownload.releaseDate,
|
||||
itemId: item.id,
|
||||
durationMs: trackToDownload.duration,
|
||||
source: trackToDownload.source, // Pass extension ID that provided this track
|
||||
source: trackToDownload.source,
|
||||
genre: genre,
|
||||
label: label,
|
||||
lyricsMode: settings.lyricsMode,
|
||||
@@ -1754,9 +1723,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
discNumber: trackToDownload.discNumber ?? 1,
|
||||
releaseDate: trackToDownload.releaseDate,
|
||||
preferredService: item.service,
|
||||
itemId: item.id, // Pass item ID for progress tracking
|
||||
durationMs:
|
||||
trackToDownload.duration, // Duration in ms for verification
|
||||
itemId: item.id,
|
||||
durationMs: trackToDownload.duration,
|
||||
genre: genre,
|
||||
label: label,
|
||||
lyricsMode: settings.lyricsMode,
|
||||
@@ -1809,8 +1777,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (result['success'] == true) {
|
||||
var filePath = result['file_path'] as String?;
|
||||
|
||||
// Track if this was an existing file (not a new download)
|
||||
// This is important to prevent converting existing FLAC files to MP3
|
||||
final wasExisting = filePath != null && filePath.startsWith('EXISTS:');
|
||||
if (wasExisting) {
|
||||
filePath = filePath.substring(7); // Remove "EXISTS:" prefix
|
||||
@@ -1912,7 +1878,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
}
|
||||
|
||||
// Get extended metadata from backend response
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
final backendCopyright = result['copyright'] as String?;
|
||||
@@ -1962,15 +1927,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert FLAC to MP3 if MP3 quality was selected
|
||||
// IMPORTANT: Only convert NEW downloads, never convert existing files
|
||||
// to prevent overwriting the user's existing FLAC files
|
||||
if (quality == 'MP3' && filePath != null && filePath.endsWith('.flac')) {
|
||||
if (wasExisting) {
|
||||
// User wanted MP3 but an existing FLAC file was found
|
||||
// Do NOT convert it - that would delete their existing FLAC
|
||||
_log.i('MP3 requested but existing FLAC found - skipping conversion to preserve original file');
|
||||
// Keep the existing FLAC file as-is
|
||||
} else {
|
||||
_log.i('MP3 quality selected, converting FLAC to MP3...');
|
||||
updateItemStatus(
|
||||
@@ -1991,7 +1950,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
actualQuality = 'MP3 320kbps';
|
||||
_log.i('Successfully converted to MP3: $mp3Path');
|
||||
|
||||
// Embed metadata, lyrics, and cover to the MP3 file
|
||||
_log.i('Embedding metadata to MP3...');
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
@@ -2050,7 +2008,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
? normalizedAlbumArtist
|
||||
: null;
|
||||
|
||||
// For MP3 files, don't save FLAC bitDepth/sampleRate - they're not applicable
|
||||
final isMp3 = filePath.endsWith('.mp3');
|
||||
final historyBitDepth = isMp3 ? null : backendBitDepth;
|
||||
final historySampleRate = isMp3 ? null : backendSampleRate;
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
|
||||
final _log = AppLogger('ExtensionProvider');
|
||||
|
||||
/// Represents an installed extension
|
||||
class Extension {
|
||||
final String id;
|
||||
final String name;
|
||||
@@ -14,19 +13,19 @@ class Extension {
|
||||
final String author;
|
||||
final String description;
|
||||
final bool enabled;
|
||||
final String status; // 'loaded', 'error', 'disabled'
|
||||
final String status;
|
||||
final String? errorMessage;
|
||||
final String? iconPath; // Path to extension icon
|
||||
final String? iconPath;
|
||||
final List<String> permissions;
|
||||
final List<ExtensionSetting> settings;
|
||||
final List<QualityOption> qualityOptions; // Custom quality options for download providers
|
||||
final List<QualityOption> qualityOptions;
|
||||
final bool hasMetadataProvider;
|
||||
final bool hasDownloadProvider;
|
||||
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
||||
final SearchBehavior? searchBehavior; // Custom search behavior
|
||||
final URLHandler? urlHandler; // Custom URL handling
|
||||
final TrackMatching? trackMatching; // Custom track matching
|
||||
final PostProcessing? postProcessing; // Post-processing hooks
|
||||
final SearchBehavior? searchBehavior;
|
||||
final URLHandler? urlHandler;
|
||||
final TrackMatching? trackMatching;
|
||||
final PostProcessing? postProcessing;
|
||||
|
||||
const Extension({
|
||||
required this.id,
|
||||
@@ -140,7 +139,6 @@ class Extension {
|
||||
bool get hasPostProcessing => postProcessing?.enabled ?? false;
|
||||
}
|
||||
|
||||
/// Custom search behavior configuration
|
||||
class SearchBehavior {
|
||||
final bool enabled;
|
||||
final String? placeholder;
|
||||
@@ -172,8 +170,6 @@ class SearchBehavior {
|
||||
);
|
||||
}
|
||||
|
||||
/// Get thumbnail size based on configuration
|
||||
/// Returns (width, height) tuple
|
||||
(double, double) getThumbnailSize({double defaultSize = 56}) {
|
||||
if (thumbnailWidth != null && thumbnailHeight != null) {
|
||||
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
|
||||
@@ -191,11 +187,10 @@ class SearchBehavior {
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom track matching configuration
|
||||
class TrackMatching {
|
||||
final bool customMatching;
|
||||
final String? strategy; // "isrc", "name", "duration", "custom"
|
||||
final int durationTolerance; // in seconds
|
||||
final String? strategy;
|
||||
final int durationTolerance;
|
||||
|
||||
const TrackMatching({
|
||||
required this.customMatching,
|
||||
@@ -212,7 +207,6 @@ class TrackMatching {
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-processing configuration
|
||||
class PostProcessing {
|
||||
final bool enabled;
|
||||
final List<PostProcessingHook> hooks;
|
||||
@@ -262,7 +256,6 @@ class URLHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// A post-processing hook
|
||||
class PostProcessingHook {
|
||||
final String id;
|
||||
final String name;
|
||||
@@ -289,12 +282,11 @@ class PostProcessingHook {
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a quality option for download providers
|
||||
class QualityOption {
|
||||
final String id;
|
||||
final String label;
|
||||
final String? description;
|
||||
final List<QualitySpecificSetting> settings; // Quality-specific settings
|
||||
final List<QualitySpecificSetting> settings;
|
||||
|
||||
const QualityOption({
|
||||
required this.id,
|
||||
@@ -315,14 +307,13 @@ class QualityOption {
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a setting that's specific to a quality option
|
||||
class QualitySpecificSetting {
|
||||
final String key;
|
||||
final String label;
|
||||
final String type; // 'string', 'number', 'boolean', 'select'
|
||||
final String type;
|
||||
final dynamic defaultValue;
|
||||
final String? description;
|
||||
final List<String>? options; // For select type
|
||||
final List<String>? options;
|
||||
final bool required;
|
||||
final bool secret;
|
||||
|
||||
@@ -351,16 +342,15 @@ class QualitySpecificSetting {
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a setting field for an extension
|
||||
class ExtensionSetting {
|
||||
final String key;
|
||||
final String label;
|
||||
final String type; // 'string', 'number', 'boolean', 'select', 'button'
|
||||
final String type;
|
||||
final dynamic defaultValue;
|
||||
final String? description;
|
||||
final List<String>? options; // For select type
|
||||
final List<String>? options;
|
||||
final bool required;
|
||||
final String? action; // For button type: JS function name to call
|
||||
final String? action;
|
||||
|
||||
const ExtensionSetting({
|
||||
required this.key,
|
||||
@@ -387,7 +377,6 @@ class ExtensionSetting {
|
||||
}
|
||||
}
|
||||
|
||||
/// State for extension management
|
||||
class ExtensionState {
|
||||
final List<Extension> extensions;
|
||||
final List<String> providerPriority;
|
||||
@@ -425,7 +414,6 @@ class ExtensionState {
|
||||
}
|
||||
|
||||
|
||||
/// Provider for managing extensions
|
||||
class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
@override
|
||||
ExtensionState build() {
|
||||
@@ -451,7 +439,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all extensions from directory
|
||||
Future<void> loadExtensions(String dirPath) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
@@ -486,12 +473,10 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear any error state
|
||||
void clearError() {
|
||||
state = state.copyWith(error: null);
|
||||
}
|
||||
|
||||
/// Install extension from file (auto-upgrades if already installed with newer version)
|
||||
Future<bool> installExtension(String filePath) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
@@ -508,8 +493,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a package file is an upgrade for an existing extension
|
||||
/// Returns: {extension_id, current_version, new_version, can_upgrade, is_installed}
|
||||
Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
|
||||
try {
|
||||
return await PlatformBridge.checkExtensionUpgrade(filePath);
|
||||
@@ -519,7 +502,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Upgrade an existing extension from a new package file
|
||||
Future<bool> upgradeExtension(String filePath) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
@@ -553,7 +535,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable an extension
|
||||
Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
|
||||
try {
|
||||
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
|
||||
@@ -600,7 +581,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update settings for an extension
|
||||
Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
|
||||
try {
|
||||
await PlatformBridge.setExtensionSettings(extensionId, settings);
|
||||
@@ -621,7 +601,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set provider priority order
|
||||
Future<void> setProviderPriority(List<String> priority) async {
|
||||
try {
|
||||
await PlatformBridge.setProviderPriority(priority);
|
||||
@@ -643,7 +622,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set metadata provider priority order
|
||||
Future<void> setMetadataProviderPriority(List<String> priority) async {
|
||||
try {
|
||||
await PlatformBridge.setMetadataProviderPriority(priority);
|
||||
@@ -665,7 +643,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get extension by ID
|
||||
Extension? getExtension(String extensionId) {
|
||||
try {
|
||||
return state.extensions.firstWhere((ext) => ext.id == extensionId);
|
||||
@@ -679,7 +656,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
return state.extensions.where((ext) => ext.enabled).toList();
|
||||
}
|
||||
|
||||
/// Get all download providers (built-in + extensions)
|
||||
List<String> getAllDownloadProviders() {
|
||||
final providers = ['tidal', 'qobuz', 'amazon'];
|
||||
for (final ext in state.extensions) {
|
||||
@@ -700,7 +676,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
/// Get all extensions that provide custom search
|
||||
List<Extension> get searchProviders {
|
||||
return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList();
|
||||
}
|
||||
|
||||
@@ -121,7 +121,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +265,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider instance
|
||||
final recentAccessProvider = NotifierProvider<RecentAccessNotifier, RecentAccessState>(
|
||||
RecentAccessNotifier.new,
|
||||
);
|
||||
|
||||
@@ -30,7 +30,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one-time migrations for settings
|
||||
Future<void> _runMigrations(SharedPreferences prefs) async {
|
||||
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
|
||||
|
||||
@@ -51,7 +50,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
||||
}
|
||||
|
||||
/// Apply current Spotify credentials to Go backend
|
||||
Future<void> _applySpotifyCredentials() async {
|
||||
if (state.spotifyClientId.isNotEmpty &&
|
||||
state.spotifyClientSecret.isNotEmpty) {
|
||||
@@ -93,7 +91,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
void setLyricsMode(String mode) {
|
||||
// Valid modes: embed, external, both
|
||||
if (mode == 'embed' || mode == 'external' || mode == 'both') {
|
||||
state = state.copyWith(lyricsMode: mode);
|
||||
_saveSettings();
|
||||
|
||||
@@ -52,7 +52,6 @@ class StoreCategory {
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an extension in the store
|
||||
class StoreExtension {
|
||||
final String id;
|
||||
final String name;
|
||||
@@ -118,7 +117,6 @@ class StoreExtension {
|
||||
}
|
||||
}
|
||||
|
||||
/// State for extension store
|
||||
class StoreState {
|
||||
final List<StoreExtension> extensions;
|
||||
final String? selectedCategory;
|
||||
@@ -200,7 +198,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
return const StoreState();
|
||||
}
|
||||
|
||||
/// Initialize the store
|
||||
Future<void> initialize(String cacheDir) async {
|
||||
if (state.isInitialized) return;
|
||||
|
||||
@@ -234,7 +231,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set category filter
|
||||
void setCategory(String? category) {
|
||||
if (category == null) {
|
||||
state = state.copyWith(clearCategory: true);
|
||||
@@ -248,7 +244,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
state = state.copyWith(searchQuery: query);
|
||||
}
|
||||
|
||||
/// Clear search
|
||||
void clearSearch() {
|
||||
state = state.copyWith(searchQuery: '', clearCategory: true);
|
||||
}
|
||||
@@ -279,7 +274,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update an installed extension
|
||||
Future<bool> updateExtension(String extensionId, String tempDir) async {
|
||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
||||
|
||||
@@ -305,7 +299,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear error
|
||||
void clearError() {
|
||||
state = state.copyWith(clearError: true);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Error loading theme settings: $e');
|
||||
// Keep default state on error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,6 @@ class TrackState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an album in artist discography
|
||||
class ArtistAlbum {
|
||||
final String id;
|
||||
final String name;
|
||||
@@ -112,7 +111,6 @@ class ArtistAlbum {
|
||||
});
|
||||
}
|
||||
|
||||
/// Represents an artist in search results
|
||||
class SearchArtist {
|
||||
final String id;
|
||||
final String name;
|
||||
@@ -130,7 +128,6 @@ class SearchArtist {
|
||||
}
|
||||
|
||||
class TrackNotifier extends Notifier<TrackState> {
|
||||
/// Request ID to track and cancel outdated requests
|
||||
int _currentRequestId = 0;
|
||||
|
||||
@override
|
||||
@@ -213,14 +210,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
Map<String, dynamic> metadata;
|
||||
|
||||
try {
|
||||
// ignore: avoid_print
|
||||
print('[FetchURL] Fetching $type with Deezer fallback enabled...');
|
||||
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||
// ignore: avoid_print
|
||||
print('[FetchURL] Metadata fetch success');
|
||||
} catch (e) {
|
||||
// ignore: avoid_print
|
||||
print('[FetchURL] Metadata fetch failed: $e');
|
||||
rethrow;
|
||||
}
|
||||
|
||||
@@ -263,7 +254,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
state = TrackState(
|
||||
tracks: [], // No tracks for artist view
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
artistId: artistInfo['id'] as String?,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
@@ -397,7 +388,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform custom search using a specific extension
|
||||
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
@@ -429,7 +419,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
searchArtists: [], // Custom search doesn't return artists
|
||||
searchArtists: [],
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
searchExtensionId: extensionId, // Store which extension was used
|
||||
@@ -477,8 +467,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
tracks[index] = updatedTrack;
|
||||
state = state.copyWith(tracks: tracks);
|
||||
} catch (e) {
|
||||
// Silently ignore availability check errors
|
||||
// This is a background operation that shouldn't disrupt the user
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,7 +482,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = state.copyWith(hasSearchText: hasText);
|
||||
}
|
||||
|
||||
/// Set recent access mode state
|
||||
void setShowingRecentAccess(bool showing) {
|
||||
state = state.copyWith(isShowingRecentAccess: showing);
|
||||
}
|
||||
@@ -584,8 +571,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Pre-warm track ID cache for faster downloads
|
||||
/// Runs in background, doesn't block UI
|
||||
void _preWarmCacheForTracks(List<Track> tracks) {
|
||||
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
|
||||
if (tracksWithIsrc.isEmpty) return;
|
||||
|
||||
@@ -13,7 +13,6 @@ import 'package:spotiflac_android/providers/recent_access_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
|
||||
/// Simple in-memory cache for album tracks
|
||||
class _AlbumCache {
|
||||
static final Map<String, _CacheEntry> _cache = {};
|
||||
static const Duration _ttl = Duration(minutes: 10);
|
||||
@@ -39,7 +38,6 @@ class _CacheEntry {
|
||||
_CacheEntry(this.tracks, this.expiresAt);
|
||||
}
|
||||
|
||||
/// Album detail screen with Material Expressive 3 design
|
||||
class AlbumScreen extends ConsumerStatefulWidget {
|
||||
final String albumId;
|
||||
final String albumName;
|
||||
@@ -99,7 +97,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
// Show title in AppBar when scrolled past the header (320 - kToolbarHeight + info card top)
|
||||
final shouldShow = _scrollController.offset > 280;
|
||||
if (shouldShow != _showTitleInAppBar) {
|
||||
setState(() => _showTitleInAppBar = shouldShow);
|
||||
@@ -121,7 +118,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore palette extraction errors
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,12 +128,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
|
||||
if (widget.albumId.startsWith('deezer:')) {
|
||||
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
|
||||
// ignore: avoid_print
|
||||
print('[AlbumScreen] Fetching from Deezer: $deezerAlbumId');
|
||||
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
|
||||
} else {
|
||||
// ignore: avoid_print
|
||||
print('[AlbumScreen] Fetching from Spotify with fallback: ${widget.albumId}');
|
||||
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
||||
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||
}
|
||||
@@ -219,7 +211,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
expandedHeight: 320,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
@@ -261,7 +253,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Cover image centered - fade out when collapsing
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: showContent ? 1.0 : 0.0,
|
||||
@@ -449,7 +440,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build error widget with special handling for rate limit (429)
|
||||
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||
final isRateLimit = error.contains('429') ||
|
||||
error.toLowerCase().contains('rate limit') ||
|
||||
@@ -512,7 +502,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||
class _AlbumTrackItem extends ConsumerWidget {
|
||||
final Track track;
|
||||
final VoidCallback onDownload;
|
||||
|
||||
@@ -97,7 +97,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
int? _monthlyListeners;
|
||||
String? _error;
|
||||
|
||||
// Sticky title state
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@@ -310,7 +309,6 @@ return Scaffold(
|
||||
);
|
||||
}
|
||||
|
||||
/// Build Spotify-style header with full-width image and artist name overlay
|
||||
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
|
||||
String? imageUrl = _headerImageUrl;
|
||||
if (imageUrl == null || imageUrl.isEmpty) {
|
||||
@@ -479,7 +477,6 @@ if (hasValidImage)
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a single popular track item with dynamic download status
|
||||
Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) {
|
||||
final queueItem = ref.watch(
|
||||
downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]),
|
||||
@@ -608,7 +605,6 @@ if (hasValidImage)
|
||||
_downloadTrack(track);
|
||||
}
|
||||
|
||||
/// Build download button with status indicator for popular tracks
|
||||
Widget _buildPopularDownloadButton({
|
||||
required Track track,
|
||||
required ColorScheme colorScheme,
|
||||
|
||||
@@ -77,7 +77,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore palette extraction errors
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,9 +507,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||
final discMap = _groupTracksByDisc(tracks);
|
||||
|
||||
// Single disc - use simple list
|
||||
if (discMap.length <= 1) {
|
||||
// Single disc - use simple list
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
@@ -525,7 +522,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// Multiple discs - build list with separators
|
||||
final discNumbers = discMap.keys.toList()..sort();
|
||||
final List<Widget> children = [];
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when trackState changes - used to sync search bar with state
|
||||
void _onTrackStateChanged(TrackState? previous, TrackState next) {
|
||||
if (previous != null &&
|
||||
!next.hasContent &&
|
||||
@@ -96,7 +95,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
|
||||
if (searchProvider == null || searchProvider.isEmpty) return false;
|
||||
|
||||
// Check if the extension is enabled and has search capability
|
||||
final extension = extState.extensions.where((e) => e.id == searchProvider && e.enabled).firstOrNull;
|
||||
return extension != null;
|
||||
}
|
||||
@@ -130,10 +128,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute live search with concurrency protection
|
||||
/// Prevents race conditions in extensions by ensuring only one search runs at a time
|
||||
Future<void> _executeLiveSearch(String query) async {
|
||||
// If a search is already in progress, queue this one
|
||||
if (_isLiveSearchInProgress) {
|
||||
_pendingLiveSearchQuery = query;
|
||||
return;
|
||||
@@ -151,13 +146,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final pending = _pendingLiveSearchQuery;
|
||||
_pendingLiveSearchQuery = null;
|
||||
|
||||
// Execute pending query if it's different from what we just searched
|
||||
// and still matches current text field content
|
||||
if (pending != null &&
|
||||
pending != query &&
|
||||
mounted &&
|
||||
_urlController.text.trim() == pending) {
|
||||
// Small delay to let extension's state settle
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (mounted && _urlController.text.trim() == pending) {
|
||||
_executeLiveSearch(pending);
|
||||
@@ -224,7 +216,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
}
|
||||
|
||||
/// Navigate to detail screen based on fetched content type
|
||||
void _navigateToDetailIfNeeded() {
|
||||
final trackState = ref.read(trackProvider);
|
||||
|
||||
@@ -356,7 +347,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
// ignore: use_build_context_synchronously
|
||||
final l10n = context.l10n;
|
||||
|
||||
// Show quality picker if enabled in settings
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
this.context,
|
||||
@@ -676,7 +666,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
);
|
||||
}
|
||||
|
||||
/// Build recent access history section (shown when search focused)
|
||||
Widget _buildRecentAccess(List<RecentAccessItem> items, ColorScheme colorScheme) {
|
||||
final historyItems = ref.read(downloadHistoryProvider).items;
|
||||
|
||||
@@ -690,9 +679,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
albumGroups.putIfAbsent(albumKey, () => []).add(h);
|
||||
}
|
||||
|
||||
// Convert to RecentAccessItem based on track count:
|
||||
// - 1 track: show as individual Track
|
||||
// - 2+ tracks: show as Album
|
||||
final downloadItems = <RecentAccessItem>[];
|
||||
for (final entry in albumGroups.entries) {
|
||||
final tracks = entry.value;
|
||||
@@ -703,7 +689,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
: mostRecent.artistName;
|
||||
|
||||
if (tracks.length == 1) {
|
||||
// Single track - show as Track
|
||||
downloadItems.add(RecentAccessItem(
|
||||
id: mostRecent.spotifyId ?? mostRecent.id,
|
||||
name: mostRecent.trackName,
|
||||
@@ -714,7 +699,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
providerId: 'download',
|
||||
));
|
||||
} else {
|
||||
// Multiple tracks - show as Album
|
||||
downloadItems.add(RecentAccessItem(
|
||||
id: '${mostRecent.albumName}|$artistForKey',
|
||||
name: mostRecent.albumName,
|
||||
@@ -727,10 +711,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by most recent and take top 10
|
||||
downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
|
||||
|
||||
// Filter out hidden downloads (use ref.watch for reactivity)
|
||||
|
||||
final hiddenIds = ref.watch(recentAccessProvider.select((s) => s.hiddenDownloadIds));
|
||||
final visibleDownloads = downloadItems
|
||||
.where((item) => !hiddenIds.contains(item.id))
|
||||
@@ -768,11 +750,9 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
if (uniqueItems.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// Hide ALL download items (not just visible ones)
|
||||
for (final item in downloadItems) {
|
||||
ref.read(recentAccessProvider.notifier).hideDownloadFromRecents(item.id);
|
||||
}
|
||||
// Clear non-download recent history
|
||||
ref.read(recentAccessProvider.notifier).clearHistory();
|
||||
},
|
||||
child: Text(
|
||||
@@ -784,7 +764,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (uniqueItems.isEmpty && hasHiddenDownloads)
|
||||
// Show "Show All" button when recents is empty but there are hidden downloads
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
@@ -897,10 +876,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
icon: Icon(Icons.close, size: 20, color: colorScheme.onSurfaceVariant),
|
||||
onPressed: () {
|
||||
if (item.providerId == 'download') {
|
||||
// For download items, hide from recents without deleting the file
|
||||
ref.read(recentAccessProvider.notifier).hideDownloadFromRecents(item.id);
|
||||
} else {
|
||||
// For other items, remove from recent history
|
||||
ref.read(recentAccessProvider.notifier).removeItem(item);
|
||||
}
|
||||
},
|
||||
@@ -936,7 +913,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
));
|
||||
}
|
||||
case RecentAccessType.album:
|
||||
// Handle downloaded albums - navigate to DownloadedAlbumScreen
|
||||
if (item.providerId == 'download') {
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => DownloadedAlbumScreen(
|
||||
@@ -1000,7 +976,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
);
|
||||
}
|
||||
|
||||
/// Build error widget with special handling for rate limit (429)
|
||||
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||
final isRateLimit = error.contains('429') ||
|
||||
error.toLowerCase().contains('rate limit') ||
|
||||
@@ -1427,7 +1402,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
));
|
||||
}
|
||||
|
||||
/// Get search hint based on selected provider
|
||||
String _getSearchHint() {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final searchProvider = settings.searchProvider;
|
||||
@@ -1474,11 +1448,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
),
|
||||
prefixIcon: _SearchProviderDropdown(
|
||||
onProviderChanged: () {
|
||||
// Reset search state when provider changes
|
||||
_lastSearchQuery = null;
|
||||
// Force rebuild to update hint text
|
||||
setState(() {});
|
||||
// Re-trigger search if there's text
|
||||
final text = _urlController.text.trim();
|
||||
if (text.isNotEmpty && text.length >= _minLiveSearchChars) {
|
||||
_performSearch(text);
|
||||
@@ -1514,9 +1485,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle Enter key press - search or fetch URL
|
||||
void _onSearchSubmitted() {
|
||||
// Cancel any pending live search since user explicitly pressed enter
|
||||
_liveSearchDebounce?.cancel();
|
||||
_pendingLiveSearchQuery = null;
|
||||
|
||||
@@ -1549,13 +1518,11 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Get current provider info
|
||||
final currentProvider = settings.searchProvider;
|
||||
final searchProviders = extState.extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.toList();
|
||||
|
||||
// Find current provider extension
|
||||
Extension? currentExt;
|
||||
if (currentProvider != null && currentProvider.isNotEmpty) {
|
||||
currentExt = searchProviders.where((e) => e.id == currentProvider).firstOrNull;
|
||||
@@ -1567,12 +1534,10 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
if (currentExt != null) {
|
||||
iconPath = currentExt.iconPath;
|
||||
if (currentExt.searchBehavior?.icon != null) {
|
||||
// Use search behavior icon if available
|
||||
displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't show dropdown if no custom search providers available
|
||||
if (searchProviders.isEmpty) {
|
||||
return const Icon(Icons.search);
|
||||
}
|
||||
@@ -1608,15 +1573,13 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
offset: const Offset(0, 40),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
onSelected: (String providerId) {
|
||||
// Empty string means default (Deezer/Spotify)
|
||||
final provider = providerId.isEmpty ? null : providerId;
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(provider);
|
||||
onProviderChanged?.call();
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
// Default option (Deezer/Spotify based on metadata source)
|
||||
PopupMenuItem<String>(
|
||||
value: '', // Empty string = default provider
|
||||
value: '',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
@@ -1716,7 +1679,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate Consumer widget for each track item - only rebuilds when this specific track's status changes
|
||||
class _TrackItemWithStatus extends ConsumerWidget {
|
||||
final Track track;
|
||||
final int index;
|
||||
@@ -2028,7 +1990,6 @@ class _CollectionItemWidget extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Screen for viewing extension album with track fetching
|
||||
class ExtensionAlbumScreen extends ConsumerStatefulWidget {
|
||||
final String extensionId;
|
||||
final String albumId;
|
||||
@@ -2299,7 +2260,6 @@ class _ExtensionPlaylistScreenState extends ConsumerState<ExtensionPlaylistScree
|
||||
}
|
||||
}
|
||||
|
||||
/// Screen for viewing extension artist with album fetching
|
||||
class ExtensionArtistScreen extends ConsumerStatefulWidget {
|
||||
final String extensionId;
|
||||
final String artistId;
|
||||
|
||||
@@ -120,7 +120,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle back press with double-tap to exit
|
||||
void _handleBackPress() {
|
||||
final trackState = ref.read(trackProvider);
|
||||
|
||||
@@ -174,9 +173,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
|
||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
|
||||
// Determine if we can pop (for predictive back animation)
|
||||
// canPop is true when we're at root with no content - enables predictive back gesture
|
||||
// IMPORTANT: Never allow pop when keyboard is visible to prevent accidental navigation
|
||||
final canPop = _currentIndex == 0 &&
|
||||
!trackState.hasSearchText &&
|
||||
!trackState.hasContent &&
|
||||
@@ -250,8 +246,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
canPop: canPop,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) {
|
||||
// System handled the pop - this means predictive back completed
|
||||
// We need to handle double-tap to exit here
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
|
||||
/// Playlist detail screen with Material Expressive 3 design
|
||||
class PlaylistScreen extends ConsumerStatefulWidget {
|
||||
final String playlistName;
|
||||
final String? coverUrl;
|
||||
@@ -69,7 +68,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore palette extraction errors
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||
|
||||
/// Grouped album data for history display
|
||||
class _GroupedAlbum {
|
||||
final String albumName;
|
||||
final String artistName;
|
||||
@@ -108,7 +107,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Enter selection mode with initial item
|
||||
void _enterSelectionMode(String itemId) {
|
||||
HapticFeedback.mediumImpact();
|
||||
setState(() {
|
||||
@@ -125,7 +123,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Toggle item selection
|
||||
void _toggleSelection(String itemId) {
|
||||
setState(() {
|
||||
if (_selectedIds.contains(itemId)) {
|
||||
@@ -146,7 +143,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Delete selected items
|
||||
Future<void> _deleteSelected() async {
|
||||
final count = _selectedIds.length;
|
||||
final confirmed = await showDialog<bool>(
|
||||
@@ -307,9 +303,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Filter history items based on current filter mode
|
||||
/// Album = track yang albumnya punya >1 track di history
|
||||
/// Single = track yang albumnya cuma 1 track di history
|
||||
List<DownloadHistoryItem> _filterHistoryItems(
|
||||
List<DownloadHistoryItem> items,
|
||||
String filterMode,
|
||||
@@ -725,7 +718,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build content for each filter tab
|
||||
Widget _buildFilterContent({
|
||||
required BuildContext context,
|
||||
required ColorScheme colorScheme,
|
||||
@@ -931,7 +923,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build album grid item for grouped albums view
|
||||
Widget _buildAlbumGridItem(
|
||||
BuildContext context,
|
||||
_GroupedAlbum album,
|
||||
@@ -1745,7 +1736,6 @@ child: CachedNetworkImage(
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter chip widget for history filtering
|
||||
class _FilterChip extends StatelessWidget {
|
||||
final String label;
|
||||
final int count;
|
||||
|
||||
@@ -15,7 +15,7 @@ class AboutPage extends StatelessWidget {
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
@@ -253,9 +253,9 @@ class _AppHeaderCard extends StatelessWidget {
|
||||
color: colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Image.asset(
|
||||
child: Image.asset(
|
||||
'assets/images/logo-transparant.png',
|
||||
color: colorScheme.onPrimary, // Tint with onPrimary color
|
||||
color: colorScheme.onPrimary,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
|
||||
@@ -17,7 +17,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
@@ -161,7 +161,7 @@ class _ThemePreviewCard extends StatelessWidget {
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme
|
||||
.surfaceContainerHighest, // Background similar to reference
|
||||
.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
@@ -203,7 +203,7 @@ class _ThemePreviewCard extends StatelessWidget {
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 12, // Reduced from 20 for performance
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -22,7 +22,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
||||
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
|
||||
@@ -581,7 +581,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
switch (step) {
|
||||
case 0: return _storagePermissionGranted;
|
||||
case 1: return _selectedDirectory != null;
|
||||
case 2: return false; // Spotify step never shows checkmark (optional)
|
||||
case 2: return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -122,7 +122,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
),
|
||||
onChanged: (value) {
|
||||
ref.read(storeProvider.notifier).setSearchQuery(value);
|
||||
setState(() {}); // Update suffix icon
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -13,8 +13,6 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
|
||||
/// Screen to display detailed metadata for a downloaded track
|
||||
/// Designed with Material Expressive 3 style
|
||||
class TrackMetadataScreen extends ConsumerStatefulWidget {
|
||||
final DownloadHistoryItem item;
|
||||
|
||||
@@ -101,7 +99,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore palette extraction errors
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +260,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Background with dominant color
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
decoration: BoxDecoration(
|
||||
@@ -280,7 +276,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
// Cover image centered - fade out when collapsing
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: showContent ? 1.0 : 0.0,
|
||||
@@ -683,7 +678,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Show 320kbps for MP3, bit depth/sample rate for FLAC
|
||||
if (fileExtension == 'MP3')
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
@@ -1057,8 +1051,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context); // Close dialog
|
||||
Navigator.pop(context); // Go back to history
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Text(context.l10n.dialogDelete, style: TextStyle(color: colorScheme.error)),
|
||||
|
||||
@@ -18,8 +18,6 @@ class CoverCacheManager {
|
||||
static bool _initialized = false;
|
||||
static String? _cachePath;
|
||||
|
||||
/// Get the singleton cache manager instance.
|
||||
/// Must call [initialize] before using this.
|
||||
static CacheManager get instance {
|
||||
if (!_initialized || _instance == null) {
|
||||
// Fallback to default cache manager if not initialized
|
||||
@@ -32,8 +30,6 @@ class CoverCacheManager {
|
||||
/// Check if cache manager is initialized
|
||||
static bool get isInitialized => _initialized && _instance != null;
|
||||
|
||||
/// Initialize the cache manager with persistent storage path.
|
||||
/// Call this once during app startup (in main.dart).
|
||||
static Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
@@ -73,7 +69,6 @@ class CoverCacheManager {
|
||||
await _instance!.emptyCache();
|
||||
}
|
||||
|
||||
/// Get cache statistics
|
||||
static Future<CacheStats> getStats() async {
|
||||
if (!_initialized || _cachePath == null) {
|
||||
return const CacheStats(fileCount: 0, totalSizeBytes: 0);
|
||||
@@ -113,7 +108,6 @@ class CacheStats {
|
||||
required this.totalSizeBytes,
|
||||
});
|
||||
|
||||
/// Get human-readable size string
|
||||
String get formattedSize {
|
||||
if (totalSizeBytes < 1024) {
|
||||
return '$totalSizeBytes B';
|
||||
|
||||
@@ -7,8 +7,6 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
class CsvImportService {
|
||||
static final _log = AppLogger('CsvImportService');
|
||||
|
||||
/// Pick and parse CSV file, then enrich metadata from Deezer
|
||||
/// [onProgress] callback receives (current, total) for progress updates
|
||||
static Future<List<Track>> pickAndParseCsv({
|
||||
void Function(int current, int total)? onProgress,
|
||||
}) async {
|
||||
@@ -34,8 +32,6 @@ class CsvImportService {
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Enrich tracks with metadata from Deezer using ISRC or search
|
||||
/// This fetches cover URL, duration, and other metadata that CSV doesn't have
|
||||
static Future<List<Track>> _enrichTracksMetadata(
|
||||
List<Track> tracks, {
|
||||
void Function(int current, int total)? onProgress,
|
||||
|
||||
@@ -10,7 +10,6 @@ final _log = AppLogger('FFmpeg');
|
||||
class FFmpegService {
|
||||
static const _channel = MethodChannel('com.zarz.spotiflac/ffmpeg');
|
||||
|
||||
/// Execute FFmpeg command and return result
|
||||
static Future<FFmpegResult> _execute(String command) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('execute', {'command': command});
|
||||
@@ -26,8 +25,6 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert M4A (DASH segments) to FLAC
|
||||
/// Returns the output file path on success, null on failure
|
||||
static Future<String?> convertM4aToFlac(String inputPath) async {
|
||||
final outputPath = inputPath.replaceAll('.m4a', '.flac');
|
||||
|
||||
@@ -47,14 +44,11 @@ class FFmpegService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Convert FLAC to MP3
|
||||
/// If deleteOriginal is true, deletes the FLAC file after conversion
|
||||
static Future<String?> convertFlacToMp3(
|
||||
String inputPath, {
|
||||
String bitrate = '320k',
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
// Convert in same folder, just change extension
|
||||
final outputPath = inputPath.replaceAll('.flac', '.mp3');
|
||||
|
||||
final command =
|
||||
@@ -63,7 +57,6 @@ class FFmpegService {
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) {
|
||||
// Delete original FLAC if requested
|
||||
if (deleteOriginal) {
|
||||
try {
|
||||
await File(inputPath).delete();
|
||||
@@ -76,7 +69,6 @@ class FFmpegService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Convert FLAC to M4A (AAC or ALAC)
|
||||
static Future<String?> convertFlacToM4a(
|
||||
String inputPath, {
|
||||
String codec = 'aac',
|
||||
@@ -110,7 +102,6 @@ class FFmpegService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Check if FFmpeg is available
|
||||
static Future<bool> isAvailable() async {
|
||||
try {
|
||||
final version = await _channel.invokeMethod('getVersion');
|
||||
@@ -120,7 +111,6 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get FFmpeg version info
|
||||
static Future<String?> getVersion() async {
|
||||
try {
|
||||
final version = await _channel.invokeMethod('getVersion');
|
||||
@@ -130,8 +120,6 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Embed metadata and cover art to FLAC file
|
||||
/// Returns the file path on success, null on failure
|
||||
static Future<String?> embedMetadata({
|
||||
required String flacPath,
|
||||
String? coverPath,
|
||||
@@ -211,8 +199,6 @@ class FFmpegService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Embed metadata and cover art to MP3 file using ID3v2 tags
|
||||
/// Returns the file path on success, null on failure
|
||||
static Future<String?> embedMetadataToMp3({
|
||||
required String mp3Path,
|
||||
String? coverPath,
|
||||
@@ -242,7 +228,6 @@ class FFmpegService {
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
|
||||
if (metadata != null) {
|
||||
// Convert FLAC/Vorbis tags to ID3v2 tags for MP3
|
||||
final id3Metadata = _convertToId3Tags(metadata);
|
||||
id3Metadata.forEach((key, value) {
|
||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||
@@ -295,7 +280,6 @@ class FFmpegService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags
|
||||
static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) {
|
||||
final id3Map = <String, String>{};
|
||||
|
||||
@@ -330,7 +314,7 @@ class FFmpegService {
|
||||
id3Map['date'] = value;
|
||||
break;
|
||||
case 'ISRC':
|
||||
id3Map['TSRC'] = value; // ID3v2 ISRC frame
|
||||
id3Map['TSRC'] = value;
|
||||
break;
|
||||
case 'LYRICS':
|
||||
case 'UNSYNCEDLYRICS':
|
||||
@@ -346,7 +330,6 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of FFmpeg command execution
|
||||
class FFmpegResult {
|
||||
final bool success;
|
||||
final int returnCode;
|
||||
|
||||
@@ -4,25 +4,21 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('PlatformBridge');
|
||||
|
||||
/// Bridge to communicate with Go backend via platform channels
|
||||
class PlatformBridge {
|
||||
static const _channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
|
||||
/// Parse and validate Spotify URL
|
||||
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
|
||||
_log.d('parseSpotifyUrl: $url');
|
||||
final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get Spotify metadata from URL
|
||||
static Future<Map<String, dynamic>> getSpotifyMetadata(String url) async {
|
||||
_log.d('getSpotifyMetadata: $url');
|
||||
final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Search Spotify
|
||||
static Future<Map<String, dynamic>> searchSpotify(String query, {int limit = 10}) async {
|
||||
_log.d('searchSpotify: "$query" (limit: $limit)');
|
||||
final result = await _channel.invokeMethod('searchSpotify', {
|
||||
@@ -32,7 +28,6 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Search Spotify for tracks and artists
|
||||
static Future<Map<String, dynamic>> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
|
||||
_log.d('searchSpotifyAll: "$query"');
|
||||
final result = await _channel.invokeMethod('searchSpotifyAll', {
|
||||
@@ -43,7 +38,6 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Check track availability on streaming services
|
||||
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
|
||||
_log.d('checkAvailability: $spotifyId (ISRC: $isrc)');
|
||||
final result = await _channel.invokeMethod('checkAvailability', {
|
||||
@@ -53,7 +47,6 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Download a track from specific service
|
||||
static Future<Map<String, dynamic>> downloadTrack({
|
||||
required String isrc,
|
||||
required String service,
|
||||
@@ -108,7 +101,6 @@ class PlatformBridge {
|
||||
return response;
|
||||
}
|
||||
|
||||
/// Download with automatic fallback to other services
|
||||
static Future<Map<String, dynamic>> downloadWithFallback({
|
||||
required String isrc,
|
||||
required String spotifyId,
|
||||
@@ -129,11 +121,9 @@ class PlatformBridge {
|
||||
String preferredService = 'tidal',
|
||||
String? itemId,
|
||||
int durationMs = 0,
|
||||
// Extended metadata for FLAC tagging
|
||||
String? genre,
|
||||
String? label,
|
||||
String? copyright,
|
||||
// Lyrics mode: "embed" (default), "external" (.lrc file), "both"
|
||||
String lyricsMode = 'embed',
|
||||
}) async {
|
||||
_log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)');
|
||||
@@ -157,11 +147,9 @@ class PlatformBridge {
|
||||
'release_date': releaseDate ?? '',
|
||||
'item_id': itemId ?? '',
|
||||
'duration_ms': durationMs,
|
||||
// Extended metadata
|
||||
'genre': genre ?? '',
|
||||
'label': label ?? '',
|
||||
'copyright': copyright ?? '',
|
||||
// Lyrics mode
|
||||
'lyrics_mode': lyricsMode,
|
||||
});
|
||||
|
||||
@@ -184,44 +172,36 @@ class PlatformBridge {
|
||||
return response;
|
||||
}
|
||||
|
||||
/// Get download progress (legacy single download)
|
||||
static Future<Map<String, dynamic>> getDownloadProgress() async {
|
||||
final result = await _channel.invokeMethod('getDownloadProgress');
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get progress for all active downloads (concurrent mode)
|
||||
static Future<Map<String, dynamic>> getAllDownloadProgress() async {
|
||||
final result = await _channel.invokeMethod('getAllDownloadProgress');
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Initialize progress tracking for a download item
|
||||
static Future<void> initItemProgress(String itemId) async {
|
||||
await _channel.invokeMethod('initItemProgress', {'item_id': itemId});
|
||||
}
|
||||
|
||||
/// Finish progress tracking for a download item
|
||||
static Future<void> finishItemProgress(String itemId) async {
|
||||
await _channel.invokeMethod('finishItemProgress', {'item_id': itemId});
|
||||
}
|
||||
|
||||
/// Clear progress tracking for a download item
|
||||
static Future<void> clearItemProgress(String itemId) async {
|
||||
await _channel.invokeMethod('clearItemProgress', {'item_id': itemId});
|
||||
}
|
||||
|
||||
/// Cancel an in-progress download
|
||||
static Future<void> cancelDownload(String itemId) async {
|
||||
await _channel.invokeMethod('cancelDownload', {'item_id': itemId});
|
||||
}
|
||||
|
||||
/// Set download directory
|
||||
static Future<void> setDownloadDirectory(String path) async {
|
||||
await _channel.invokeMethod('setDownloadDirectory', {'path': path});
|
||||
}
|
||||
|
||||
/// Check if file with ISRC already exists
|
||||
static Future<Map<String, dynamic>> checkDuplicate(String outputDir, String isrc) async {
|
||||
final result = await _channel.invokeMethod('checkDuplicate', {
|
||||
'output_dir': outputDir,
|
||||
@@ -230,7 +210,6 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Build filename from template
|
||||
static Future<String> buildFilename(String template, Map<String, dynamic> metadata) async {
|
||||
final result = await _channel.invokeMethod('buildFilename', {
|
||||
'template': template,
|
||||
@@ -239,7 +218,6 @@ class PlatformBridge {
|
||||
return result as String;
|
||||
}
|
||||
|
||||
/// Sanitize filename
|
||||
static Future<String> sanitizeFilename(String filename) async {
|
||||
final result = await _channel.invokeMethod('sanitizeFilename', {
|
||||
'filename': filename,
|
||||
@@ -247,8 +225,6 @@ class PlatformBridge {
|
||||
return result as String;
|
||||
}
|
||||
|
||||
/// Fetch lyrics for a track
|
||||
/// [durationMs] is the track duration in milliseconds for better matching
|
||||
static Future<Map<String, dynamic>> fetchLyrics(
|
||||
String spotifyId,
|
||||
String trackName,
|
||||
@@ -264,9 +240,6 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get lyrics in LRC format
|
||||
/// First tries to extract from embedded file, then falls back to internet
|
||||
/// [durationMs] is the track duration in milliseconds for better matching
|
||||
static Future<String> getLyricsLRC(
|
||||
String spotifyId,
|
||||
String trackName,
|
||||
@@ -284,7 +257,6 @@ class PlatformBridge {
|
||||
return result as String;
|
||||
}
|
||||
|
||||
/// Embed lyrics into an existing FLAC file
|
||||
static Future<Map<String, dynamic>> embedLyricsToFile(
|
||||
String filePath,
|
||||
String lyrics,
|
||||
@@ -296,15 +268,10 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Cleanup idle HTTP connections to prevent TCP exhaustion
|
||||
/// Call this periodically during large batch downloads
|
||||
static Future<void> cleanupConnections() async {
|
||||
await _channel.invokeMethod('cleanupConnections');
|
||||
}
|
||||
|
||||
/// Read metadata directly from a FLAC file
|
||||
/// Returns all embedded metadata (title, artist, album, track number, etc.)
|
||||
/// This reads from the actual file, not from cached/database data
|
||||
static Future<Map<String, dynamic>> readFileMetadata(String filePath) async {
|
||||
final result = await _channel.invokeMethod('readFileMetadata', {
|
||||
'file_path': filePath,
|
||||
@@ -312,7 +279,6 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Start foreground download service to keep downloads running in background
|
||||
static Future<void> startDownloadService({
|
||||
String trackName = '',
|
||||
String artistName = '',
|
||||
@@ -325,12 +291,10 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop foreground download service
|
||||
static Future<void> stopDownloadService() async {
|
||||
await _channel.invokeMethod('stopDownloadService');
|
||||
}
|
||||
|
||||
/// Update download service notification progress
|
||||
static Future<void> updateDownloadServiceProgress({
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
@@ -347,13 +311,11 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if download service is running
|
||||
static Future<bool> isDownloadServiceRunning() async {
|
||||
final result = await _channel.invokeMethod('isDownloadServiceRunning');
|
||||
return result as bool;
|
||||
}
|
||||
|
||||
/// Set custom Spotify API credentials
|
||||
static Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
|
||||
await _channel.invokeMethod('setSpotifyCredentials', {
|
||||
'client_id': clientId,
|
||||
@@ -361,35 +323,26 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if Spotify credentials are configured
|
||||
/// Returns true if credentials are available (custom or env vars)
|
||||
static Future<bool> hasSpotifyCredentials() async {
|
||||
final result = await _channel.invokeMethod('hasSpotifyCredentials');
|
||||
return result as bool;
|
||||
}
|
||||
|
||||
/// Pre-warm track ID cache for album/playlist tracks
|
||||
/// This runs in background and returns immediately
|
||||
/// Speeds up subsequent downloads by caching ISRC → Track ID mappings
|
||||
static Future<void> preWarmTrackCache(List<Map<String, String>> tracks) async {
|
||||
final tracksJson = jsonEncode(tracks);
|
||||
await _channel.invokeMethod('preWarmTrackCache', {'tracks': tracksJson});
|
||||
}
|
||||
|
||||
/// Get current track cache size
|
||||
static Future<int> getTrackCacheSize() async {
|
||||
final result = await _channel.invokeMethod('getTrackCacheSize');
|
||||
return result as int;
|
||||
}
|
||||
|
||||
/// Clear track ID cache
|
||||
static Future<void> clearTrackCache() async {
|
||||
await _channel.invokeMethod('clearTrackCache');
|
||||
}
|
||||
|
||||
// ==================== DEEZER API ====================
|
||||
|
||||
/// Search Deezer for tracks and artists (no API key required)
|
||||
static Future<Map<String, dynamic>> searchDeezerAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
|
||||
final result = await _channel.invokeMethod('searchDeezerAll', {
|
||||
'query': query,
|
||||
@@ -399,7 +352,6 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get Deezer metadata by type and ID
|
||||
static Future<Map<String, dynamic>> getDeezerMetadata(String resourceType, String resourceId) async {
|
||||
final result = await _channel.invokeMethod('getDeezerMetadata', {
|
||||
'resource_type': resourceType,
|
||||
@@ -411,20 +363,16 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Parse Deezer URL and return type and ID
|
||||
static Future<Map<String, dynamic>> parseDeezerUrl(String url) async {
|
||||
final result = await _channel.invokeMethod('parseDeezerUrl', {'url': url});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Search Deezer by ISRC
|
||||
static Future<Map<String, dynamic>> searchDeezerByISRC(String isrc) async {
|
||||
final result = await _channel.invokeMethod('searchDeezerByISRC', {'isrc': isrc});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get extended metadata (genre, label) from Deezer using track ID
|
||||
/// Returns {"genre": "...", "label": "..."} or null if not found
|
||||
static Future<Map<String, String>?> getDeezerExtendedMetadata(String trackId) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('getDeezerExtendedMetadata', {
|
||||
@@ -442,7 +390,6 @@ class PlatformBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Spotify track to Deezer and get metadata (for rate limit fallback)
|
||||
static Future<Map<String, dynamic>> convertSpotifyToDeezer(String resourceType, String spotifyId) async {
|
||||
final result = await _channel.invokeMethod('convertSpotifyToDeezer', {
|
||||
'resource_type': resourceType,
|
||||
@@ -451,15 +398,11 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get Spotify metadata with automatic Deezer fallback on rate limit
|
||||
static Future<Map<String, dynamic>> getSpotifyMetadataWithFallback(String url) async {
|
||||
final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
// ==================== GO BACKEND LOGS ====================
|
||||
|
||||
/// Get all logs from Go backend
|
||||
static Future<List<Map<String, dynamic>>> getGoLogs() async {
|
||||
final result = await _channel.invokeMethod('getLogs');
|
||||
final logs = jsonDecode(result as String) as List<dynamic>;
|
||||
@@ -472,25 +415,20 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Clear Go backend logs
|
||||
static Future<void> clearGoLogs() async {
|
||||
await _channel.invokeMethod('clearLogs');
|
||||
}
|
||||
|
||||
/// Get Go backend log count
|
||||
static Future<int> getGoLogCount() async {
|
||||
final result = await _channel.invokeMethod('getLogCount');
|
||||
return result as int;
|
||||
}
|
||||
|
||||
/// Enable or disable Go backend logging
|
||||
static Future<void> setGoLoggingEnabled(bool enabled) async {
|
||||
await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled});
|
||||
}
|
||||
|
||||
// ==================== EXTENSION SYSTEM ====================
|
||||
|
||||
/// Initialize the extension system
|
||||
static Future<void> initExtensionSystem(String extensionsDir, String dataDir) async {
|
||||
_log.d('initExtensionSystem: $extensionsDir, $dataDir');
|
||||
await _channel.invokeMethod('initExtensionSystem', {
|
||||
@@ -499,7 +437,6 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Load all extensions from directory
|
||||
static Future<Map<String, dynamic>> loadExtensionsFromDir(String dirPath) async {
|
||||
_log.d('loadExtensionsFromDir: $dirPath');
|
||||
final result = await _channel.invokeMethod('loadExtensionsFromDir', {
|
||||
@@ -508,7 +445,6 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Load a single extension from file
|
||||
static Future<Map<String, dynamic>> loadExtensionFromPath(String filePath) async {
|
||||
_log.d('loadExtensionFromPath: $filePath');
|
||||
final result = await _channel.invokeMethod('loadExtensionFromPath', {
|
||||
@@ -517,7 +453,6 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Unload an extension
|
||||
static Future<void> unloadExtension(String extensionId) async {
|
||||
_log.d('unloadExtension: $extensionId');
|
||||
await _channel.invokeMethod('unloadExtension', {
|
||||
@@ -525,7 +460,6 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove an extension completely (unload + delete files)
|
||||
static Future<void> removeExtension(String extensionId) async {
|
||||
_log.d('removeExtension: $extensionId');
|
||||
await _channel.invokeMethod('removeExtension', {
|
||||
@@ -533,7 +467,6 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Upgrade an existing extension from a new package file
|
||||
static Future<Map<String, dynamic>> upgradeExtension(String filePath) async {
|
||||
_log.d('upgradeExtension: $filePath');
|
||||
final result = await _channel.invokeMethod('upgradeExtension', {
|
||||
@@ -542,7 +475,6 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Check if a package file is an upgrade for an existing extension
|
||||
static Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
|
||||
_log.d('checkExtensionUpgrade: $filePath');
|
||||
final result = await _channel.invokeMethod('checkExtensionUpgrade', {
|
||||
@@ -551,14 +483,12 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get all installed extensions
|
||||
static Future<List<Map<String, dynamic>>> getInstalledExtensions() async {
|
||||
final result = await _channel.invokeMethod('getInstalledExtensions');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Enable or disable an extension
|
||||
static Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
|
||||
_log.d('setExtensionEnabled: $extensionId = $enabled');
|
||||
await _channel.invokeMethod('setExtensionEnabled', {
|
||||
@@ -567,7 +497,6 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Set provider priority order
|
||||
static Future<void> setProviderPriority(List<String> providerIds) async {
|
||||
_log.d('setProviderPriority: $providerIds');
|
||||
await _channel.invokeMethod('setProviderPriority', {
|
||||
@@ -575,14 +504,12 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Get provider priority order
|
||||
static Future<List<String>> getProviderPriority() async {
|
||||
final result = await _channel.invokeMethod('getProviderPriority');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as String).toList();
|
||||
}
|
||||
|
||||
/// Set metadata provider priority order
|
||||
static Future<void> setMetadataProviderPriority(List<String> providerIds) async {
|
||||
_log.d('setMetadataProviderPriority: $providerIds');
|
||||
await _channel.invokeMethod('setMetadataProviderPriority', {
|
||||
@@ -590,14 +517,12 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Get metadata provider priority order
|
||||
static Future<List<String>> getMetadataProviderPriority() async {
|
||||
final result = await _channel.invokeMethod('getMetadataProviderPriority');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as String).toList();
|
||||
}
|
||||
|
||||
/// Get extension settings
|
||||
static Future<Map<String, dynamic>> getExtensionSettings(String extensionId) async {
|
||||
final result = await _channel.invokeMethod('getExtensionSettings', {
|
||||
'extension_id': extensionId,
|
||||
@@ -605,7 +530,6 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Set extension settings
|
||||
static Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
|
||||
_log.d('setExtensionSettings: $extensionId');
|
||||
await _channel.invokeMethod('setExtensionSettings', {
|
||||
@@ -614,8 +538,6 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Invoke an action on an extension (e.g., button click handler like "startLogin")
|
||||
/// Returns the result from the JS function
|
||||
static Future<Map<String, dynamic>> invokeExtensionAction(String extensionId, String actionName) async {
|
||||
_log.d('invokeExtensionAction: $extensionId.$actionName');
|
||||
final result = await _channel.invokeMethod('invokeExtensionAction', {
|
||||
@@ -628,7 +550,6 @@ class PlatformBridge {
|
||||
return jsonDecode(result) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Search tracks using extension providers
|
||||
static Future<List<Map<String, dynamic>>> searchTracksWithExtensions(String query, {int limit = 20}) async {
|
||||
_log.d('searchTracksWithExtensions: "$query"');
|
||||
final result = await _channel.invokeMethod('searchTracksWithExtensions', {
|
||||
@@ -639,7 +560,6 @@ class PlatformBridge {
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Download with extension providers (includes fallback)
|
||||
static Future<Map<String, dynamic>> downloadWithExtensions({
|
||||
required String isrc,
|
||||
required String spotifyId,
|
||||
@@ -659,10 +579,9 @@ class PlatformBridge {
|
||||
String? releaseDate,
|
||||
String? itemId,
|
||||
int durationMs = 0,
|
||||
String? source, // Extension ID that provided this track (prioritize this extension)
|
||||
String? source,
|
||||
String? genre,
|
||||
String? label,
|
||||
// Lyrics mode: "embed" (default), "external" (.lrc file), "both"
|
||||
String lyricsMode = 'embed',
|
||||
}) async {
|
||||
_log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}');
|
||||
@@ -685,10 +604,9 @@ class PlatformBridge {
|
||||
'release_date': releaseDate ?? '',
|
||||
'item_id': itemId ?? '',
|
||||
'duration_ms': durationMs,
|
||||
'source': source ?? '', // Extension ID that provided this track
|
||||
'source': source ?? '',
|
||||
'genre': genre ?? '',
|
||||
'label': label ?? '',
|
||||
// Lyrics mode
|
||||
'lyrics_mode': lyricsMode,
|
||||
});
|
||||
|
||||
@@ -696,15 +614,11 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Cleanup all extensions (call on app close)
|
||||
static Future<void> cleanupExtensions() async {
|
||||
_log.d('cleanupExtensions');
|
||||
await _channel.invokeMethod('cleanupExtensions');
|
||||
}
|
||||
|
||||
// ==================== EXTENSION AUTH API ====================
|
||||
|
||||
/// Get pending auth request for an extension (if any)
|
||||
static Future<Map<String, dynamic>?> getExtensionPendingAuth(String extensionId) async {
|
||||
final result = await _channel.invokeMethod('getExtensionPendingAuth', {
|
||||
'extension_id': extensionId,
|
||||
@@ -713,7 +627,6 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Set auth code for an extension (after OAuth callback)
|
||||
static Future<void> setExtensionAuthCode(String extensionId, String authCode) async {
|
||||
_log.d('setExtensionAuthCode: $extensionId');
|
||||
await _channel.invokeMethod('setExtensionAuthCode', {
|
||||
@@ -722,7 +635,6 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Set tokens for an extension (after token exchange)
|
||||
static Future<void> setExtensionTokens(
|
||||
String extensionId, {
|
||||
required String accessToken,
|
||||
@@ -738,14 +650,12 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear pending auth request for an extension
|
||||
static Future<void> clearExtensionPendingAuth(String extensionId) async {
|
||||
await _channel.invokeMethod('clearExtensionPendingAuth', {
|
||||
'extension_id': extensionId,
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if extension is authenticated
|
||||
static Future<bool> isExtensionAuthenticated(String extensionId) async {
|
||||
final result = await _channel.invokeMethod('isExtensionAuthenticated', {
|
||||
'extension_id': extensionId,
|
||||
@@ -753,16 +663,12 @@ class PlatformBridge {
|
||||
return result as bool;
|
||||
}
|
||||
|
||||
/// Get all pending auth requests (for polling)
|
||||
static Future<List<Map<String, dynamic>>> getAllPendingAuthRequests() async {
|
||||
final result = await _channel.invokeMethod('getAllPendingAuthRequests');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
// ==================== EXTENSION FFMPEG API ====================
|
||||
|
||||
/// Get pending FFmpeg command for execution
|
||||
static Future<Map<String, dynamic>?> getPendingFFmpegCommand(String commandId) async {
|
||||
final result = await _channel.invokeMethod('getPendingFFmpegCommand', {
|
||||
'command_id': commandId,
|
||||
@@ -771,7 +677,6 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Set FFmpeg command result
|
||||
static Future<void> setFFmpegCommandResult(
|
||||
String commandId, {
|
||||
required bool success,
|
||||
@@ -786,16 +691,12 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Get all pending FFmpeg commands
|
||||
static Future<List<Map<String, dynamic>>> getAllPendingFFmpegCommands() async {
|
||||
final result = await _channel.invokeMethod('getAllPendingFFmpegCommands');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
// ==================== EXTENSION CUSTOM SEARCH ====================
|
||||
|
||||
/// Perform custom search using an extension
|
||||
static Future<List<Map<String, dynamic>>> customSearchWithExtension(
|
||||
String extensionId,
|
||||
String query, {
|
||||
@@ -810,17 +711,12 @@ class PlatformBridge {
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Get all extensions that provide custom search
|
||||
static Future<List<Map<String, dynamic>>> getSearchProviders() async {
|
||||
final result = await _channel.invokeMethod('getSearchProviders');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
// ==================== EXTENSION URL HANDLER ====================
|
||||
|
||||
/// Handle a URL with any matching extension
|
||||
/// Returns null if no extension can handle the URL
|
||||
static Future<Map<String, dynamic>?> handleURLWithExtension(String url) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('handleURLWithExtension', {
|
||||
@@ -833,8 +729,6 @@ class PlatformBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Find an extension that can handle the given URL
|
||||
/// Returns extension ID or null if none found
|
||||
static Future<String?> findURLHandler(String url) async {
|
||||
final result = await _channel.invokeMethod('findURLHandler', {
|
||||
'url': url,
|
||||
@@ -843,14 +737,12 @@ class PlatformBridge {
|
||||
return result as String;
|
||||
}
|
||||
|
||||
/// Get all extensions that handle custom URLs
|
||||
static Future<List<Map<String, dynamic>>> getURLHandlers() async {
|
||||
final result = await _channel.invokeMethod('getURLHandlers');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Get album tracks using an extension
|
||||
static Future<Map<String, dynamic>?> getAlbumWithExtension(
|
||||
String extensionId,
|
||||
String albumId,
|
||||
@@ -868,7 +760,6 @@ class PlatformBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get playlist tracks using an extension
|
||||
static Future<Map<String, dynamic>?> getPlaylistWithExtension(
|
||||
String extensionId,
|
||||
String playlistId,
|
||||
@@ -886,7 +777,6 @@ class PlatformBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get artist info and albums using an extension
|
||||
static Future<Map<String, dynamic>?> getArtistWithExtension(
|
||||
String extensionId,
|
||||
String artistId,
|
||||
@@ -904,9 +794,7 @@ class PlatformBridge {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== EXTENSION POST-PROCESSING ====================
|
||||
|
||||
/// Run post-processing hooks on a file
|
||||
static Future<Map<String, dynamic>> runPostProcessing(
|
||||
String filePath, {
|
||||
Map<String, dynamic>? metadata,
|
||||
@@ -918,22 +806,18 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get all extensions that provide post-processing
|
||||
static Future<List<Map<String, dynamic>>> getPostProcessingProviders() async {
|
||||
final result = await _channel.invokeMethod('getPostProcessingProviders');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
// ==================== EXTENSION STORE ====================
|
||||
|
||||
/// Initialize extension store
|
||||
static Future<void> initExtensionStore(String cacheDir) async {
|
||||
_log.d('initExtensionStore: $cacheDir');
|
||||
await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir});
|
||||
}
|
||||
|
||||
/// Get all extensions from store with installation status
|
||||
static Future<List<Map<String, dynamic>>> getStoreExtensions({bool forceRefresh = false}) async {
|
||||
_log.d('getStoreExtensions (forceRefresh: $forceRefresh)');
|
||||
final result = await _channel.invokeMethod('getStoreExtensions', {
|
||||
@@ -943,7 +827,6 @@ class PlatformBridge {
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Search extensions in store
|
||||
static Future<List<Map<String, dynamic>>> searchStoreExtensions(String query, {String? category}) async {
|
||||
_log.d('searchStoreExtensions: "$query" (category: $category)');
|
||||
final result = await _channel.invokeMethod('searchStoreExtensions', {
|
||||
@@ -954,14 +837,12 @@ class PlatformBridge {
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Get store categories
|
||||
static Future<List<String>> getStoreCategories() async {
|
||||
final result = await _channel.invokeMethod('getStoreCategories');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.cast<String>();
|
||||
}
|
||||
|
||||
/// Download extension from store
|
||||
static Future<String> downloadStoreExtension(String extensionId, String destDir) async {
|
||||
_log.i('downloadStoreExtension: $extensionId to $destDir');
|
||||
final result = await _channel.invokeMethod('downloadStoreExtension', {
|
||||
@@ -971,7 +852,6 @@ class PlatformBridge {
|
||||
return result as String;
|
||||
}
|
||||
|
||||
/// Clear store cache
|
||||
static Future<void> clearStoreCache() async {
|
||||
_log.d('clearStoreCache');
|
||||
await _channel.invokeMethod('clearStoreCache');
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('ShareIntent');
|
||||
|
||||
/// Service to handle incoming share intents from other apps (e.g., Spotify)
|
||||
class ShareIntentService {
|
||||
static final ShareIntentService _instance = ShareIntentService._internal();
|
||||
factory ShareIntentService() => _instance;
|
||||
@@ -15,17 +14,14 @@ class ShareIntentService {
|
||||
bool _initialized = false;
|
||||
String? _pendingUrl; // Store URL received before listener is ready
|
||||
|
||||
/// Stream of shared Spotify URLs
|
||||
Stream<String> get sharedUrlStream => _sharedUrlController.stream;
|
||||
|
||||
/// Get pending URL that was received before listener was ready
|
||||
String? consumePendingUrl() {
|
||||
final url = _pendingUrl;
|
||||
_pendingUrl = null;
|
||||
return url;
|
||||
}
|
||||
|
||||
/// Initialize the service and start listening for share intents
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
@@ -58,11 +54,6 @@ class ShareIntentService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract Spotify URL from shared text
|
||||
/// Handles various formats:
|
||||
/// - Direct URL: https://open.spotify.com/track/xxx
|
||||
/// - With text: "Check out this song! https://open.spotify.com/track/xxx"
|
||||
/// - Spotify URI: spotify:track:xxx
|
||||
String? _extractSpotifyUrl(String text) {
|
||||
if (text.isEmpty) return null;
|
||||
|
||||
@@ -83,7 +74,6 @@ class ShareIntentService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
_mediaSubscription?.cancel();
|
||||
_sharedUrlController.close();
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/models/theme_settings.dart';
|
||||
|
||||
/// App theme configuration for Material Expressive 3
|
||||
class AppTheme {
|
||||
/// Default seed color (Spotify green)
|
||||
static const Color defaultSeedColor = Color(kDefaultSeedColor);
|
||||
|
||||
/// Create light theme
|
||||
static ThemeData light({ColorScheme? dynamicScheme, Color? seedColor}) {
|
||||
final scheme =
|
||||
dynamicScheme ??
|
||||
@@ -73,7 +71,6 @@ class AppTheme {
|
||||
);
|
||||
}
|
||||
|
||||
/// AppBar theme
|
||||
static AppBarTheme _appBarTheme(
|
||||
ColorScheme scheme, {
|
||||
bool isAmoled = false,
|
||||
@@ -101,7 +98,6 @@ class AppTheme {
|
||||
surfaceTintColor: scheme.surfaceTint,
|
||||
);
|
||||
|
||||
/// Elevated button theme
|
||||
static ElevatedButtonThemeData _elevatedButtonTheme(ColorScheme scheme) =>
|
||||
ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
@@ -124,7 +120,6 @@ class AppTheme {
|
||||
),
|
||||
);
|
||||
|
||||
/// Outlined button theme
|
||||
static OutlinedButtonThemeData _outlinedButtonTheme(ColorScheme scheme) =>
|
||||
OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
@@ -146,7 +141,6 @@ class AppTheme {
|
||||
),
|
||||
);
|
||||
|
||||
/// FAB theme
|
||||
static FloatingActionButtonThemeData _fabTheme(ColorScheme scheme) =>
|
||||
FloatingActionButtonThemeData(
|
||||
elevation: 3,
|
||||
@@ -184,7 +178,6 @@ class AppTheme {
|
||||
), // consistent padding
|
||||
);
|
||||
|
||||
/// List tile theme
|
||||
static ListTileThemeData _listTileTheme(ColorScheme scheme) =>
|
||||
ListTileThemeData(
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -193,7 +186,6 @@ class AppTheme {
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
);
|
||||
|
||||
/// Dialog theme
|
||||
static DialogThemeData _dialogTheme(ColorScheme scheme) => DialogThemeData(
|
||||
elevation: 6,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||
@@ -213,7 +205,6 @@ class AppTheme {
|
||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
||||
);
|
||||
|
||||
/// SnackBar theme
|
||||
static SnackBarThemeData _snackBarTheme(ColorScheme scheme) =>
|
||||
SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
@@ -231,7 +222,6 @@ class AppTheme {
|
||||
circularTrackColor: scheme.surfaceContainerHighest,
|
||||
);
|
||||
|
||||
/// Switch theme
|
||||
static SwitchThemeData _switchTheme(ColorScheme scheme) => SwitchThemeData(
|
||||
thumbColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
@@ -260,7 +250,6 @@ class AppTheme {
|
||||
selectedColor: scheme.secondaryContainer,
|
||||
);
|
||||
|
||||
/// Divider theme
|
||||
static DividerThemeData _dividerTheme(ColorScheme scheme) =>
|
||||
DividerThemeData(color: scheme.outlineVariant, thickness: 1, space: 1);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
/// Log entry with timestamp and level
|
||||
class LogEntry {
|
||||
final DateTime timestamp;
|
||||
final String level;
|
||||
@@ -38,7 +37,6 @@ class LogEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Circular buffer for storing logs in memory
|
||||
class LogBuffer extends ChangeNotifier {
|
||||
static final LogBuffer _instance = LogBuffer._internal();
|
||||
factory LogBuffer() => _instance;
|
||||
@@ -134,7 +132,6 @@ class LogBuffer extends ChangeNotifier {
|
||||
|
||||
_lastGoLogIndex = nextIndex;
|
||||
} catch (e) {
|
||||
// Ignore errors - Go backend might not be ready
|
||||
if (kDebugMode) {
|
||||
debugPrint('Failed to fetch Go logs: $e');
|
||||
}
|
||||
@@ -180,7 +177,6 @@ class LogBuffer extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom log output that writes to both console and buffer
|
||||
class BufferedOutput extends LogOutput {
|
||||
final String tag;
|
||||
|
||||
@@ -236,9 +232,6 @@ final log = Logger(
|
||||
level: Level.debug,
|
||||
);
|
||||
|
||||
/// Logger with class/tag prefix for better traceability
|
||||
/// Now also writes to LogBuffer for in-app viewing
|
||||
/// Works in both debug and release mode
|
||||
class AppLogger {
|
||||
final String _tag;
|
||||
late final Logger? _logger;
|
||||
|
||||
@@ -2,10 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
|
||||
/// A wrapper around CachedNetworkImage that uses persistent cache storage.
|
||||
///
|
||||
/// This ensures cover images are cached to disk and persist across app restarts,
|
||||
/// instead of being stored in the temporary directory that can be cleared by the OS.
|
||||
class CachedCoverImage extends StatelessWidget {
|
||||
final String imageUrl;
|
||||
final double? width;
|
||||
@@ -57,8 +53,6 @@ class CachedCoverImage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for CachedNetworkImageProvider that uses persistent cache.
|
||||
/// Use this for precacheImage() calls.
|
||||
CachedNetworkImageProvider cachedCoverImageProvider(String url) {
|
||||
return CachedNetworkImageProvider(
|
||||
url,
|
||||
|
||||
Reference in New Issue
Block a user