mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-17 16:37:17 +02:00
chore: clean up codebase
This commit is contained in:
@@ -874,10 +874,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
await _db.upsert(updated.toJson());
|
||||
}
|
||||
|
||||
/// Remove history entries where the file no longer exists on disk.
|
||||
/// Returns the number of orphaned entries removed.
|
||||
|
||||
/// Audio file extensions that the app commonly produces or converts between.
|
||||
static const _audioExtensions = [
|
||||
'.flac',
|
||||
'.m4a',
|
||||
@@ -888,9 +884,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
'.aac',
|
||||
];
|
||||
|
||||
/// When the original file is missing, check whether a sibling with a
|
||||
/// different audio extension exists (e.g. the user converted .flac → .opus).
|
||||
/// Returns the path of the first match found, or `null` if none exist.
|
||||
Future<String?> _findConvertedSibling(String originalPath) async {
|
||||
final dotIndex = originalPath.lastIndexOf('.');
|
||||
if (dotIndex < 0) return null;
|
||||
@@ -2711,7 +2704,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
static final _deezerSizeRegex = RegExp(r'/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$');
|
||||
|
||||
String _upgradeToMaxQualityCover(String coverUrl) {
|
||||
// Spotify CDN upgrade (hash-based size identifiers)
|
||||
const spotifySize300 = 'ab67616d00001e02';
|
||||
const spotifySize640 = 'ab67616d0000b273';
|
||||
const spotifySizeMax = 'ab67616d000082c1';
|
||||
@@ -2724,7 +2716,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
result = result.replaceFirst(spotifySize640, spotifySizeMax);
|
||||
}
|
||||
|
||||
// Deezer CDN upgrade (1000x1000 → 1800x1800)
|
||||
if (result.contains('cdn-images.dzcdn.net')) {
|
||||
final upgraded = result.replaceFirst(
|
||||
_deezerSizeRegex,
|
||||
@@ -3405,7 +3396,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
Future<void> _processQueue() async {
|
||||
if (state.isProcessing) return;
|
||||
|
||||
// Check network connectivity before starting
|
||||
final settings = ref.read(settingsProvider);
|
||||
updateSettings(settings);
|
||||
final isSafMode = _isSafMode(settings);
|
||||
@@ -3465,7 +3455,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
state = state.copyWith(outputDir: musicDir.path);
|
||||
ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path);
|
||||
} else if (!isValidIosWritablePath(state.outputDir)) {
|
||||
// Check for other invalid paths (like container root without Documents/)
|
||||
_log.w(
|
||||
'iOS: Invalid output path detected (container root?), falling back to app Documents folder',
|
||||
);
|
||||
@@ -3487,7 +3476,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.d('Output directory: ${state.outputDir}');
|
||||
} else {
|
||||
_log.d('Output directory: SAF (tree_uri=${settings.downloadTreeUri})');
|
||||
// Validate SAF permission is still accessible
|
||||
try {
|
||||
final testResult = await PlatformBridge.createSafFileFromPath(
|
||||
treeUri: settings.downloadTreeUri,
|
||||
@@ -3496,16 +3484,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
mimeType: 'application/octet-stream',
|
||||
srcPath: '',
|
||||
);
|
||||
// If we got a result, permission is valid (file creation may fail but that's ok)
|
||||
// If permission is revoked, this will throw
|
||||
if (testResult != null) {
|
||||
// Clean up test file
|
||||
await PlatformBridge.safDelete(testResult);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('SAF permission validation failed: $e');
|
||||
_log.w('SAF tree URI may be invalid or permission revoked');
|
||||
// Mark all queued items as failed
|
||||
for (final item in state.items) {
|
||||
if (item.status == DownloadStatus.queued) {
|
||||
updateItemStatus(
|
||||
@@ -3639,8 +3623,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
if (activeDownloads.isNotEmpty) {
|
||||
// Re-check queue/settings periodically so concurrency changes
|
||||
// (e.g. 1 -> 3) can take effect before any active item finishes.
|
||||
await Future.any([
|
||||
Future.any(activeDownloads.values),
|
||||
Future.delayed(_queueSchedulingInterval),
|
||||
@@ -3926,7 +3908,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (resolvedIsrc != null && _isValidISRC(resolvedIsrc)) {
|
||||
_log.d('Resolved ISRC from $provider: $resolvedIsrc');
|
||||
|
||||
// Enrich track with provider metadata
|
||||
final provReleaseDate = normalizeOptionalString(
|
||||
trackData['release_date'] as String?,
|
||||
);
|
||||
@@ -3962,7 +3943,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
source: trackToDownload.source,
|
||||
);
|
||||
|
||||
// Search Deezer by the resolved ISRC
|
||||
try {
|
||||
final deezerResult = await PlatformBridge.searchDeezerByISRC(
|
||||
resolvedIsrc,
|
||||
@@ -3988,9 +3968,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Use SongLink to convert Spotify ID to Deezer ID
|
||||
// Skip for tidal:/qobuz: IDs – they are not Spotify URLs and the
|
||||
// provider ISRC resolution above already handles them.
|
||||
if (!selectedExtensionDownloadProvider &&
|
||||
deezerTrackId == null &&
|
||||
!shouldSkipExtensionSongLinkPrelookup &&
|
||||
@@ -4011,7 +3988,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
'track',
|
||||
spotifyId,
|
||||
);
|
||||
// Response is TrackResponse: {"track": {"spotify_id": "deezer:XXXXX", ...}}
|
||||
final trackData = deezerData['track'];
|
||||
if (trackData is Map<String, dynamic>) {
|
||||
final rawId = trackData['spotify_id'] as String?;
|
||||
@@ -4317,7 +4293,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
finalSafFileName = reportedFileName;
|
||||
}
|
||||
|
||||
// Check if file already existed (detected via ISRC match in Go backend)
|
||||
final wasExisting = result['already_exists'] == true;
|
||||
if (wasExisting) {
|
||||
_log.i('File already exists in library: $filePath');
|
||||
@@ -4330,7 +4305,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
String actualQuality = quality;
|
||||
|
||||
if (actualBitDepth != null && actualBitDepth > 0) {
|
||||
// Format: "24-bit/96kHz" or "16-bit/44.1kHz"
|
||||
final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0
|
||||
? (actualSampleRate / 1000).toStringAsFixed(
|
||||
actualSampleRate % 1000 == 0 ? 0 : 1,
|
||||
@@ -4486,7 +4460,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
if (isM4aFile || shouldForceTidalSafM4aHandling) {
|
||||
// At this point filePath is guaranteed non-null by the checks above.
|
||||
final currentFilePath = filePath;
|
||||
|
||||
if (isContentUriPath && effectiveSafMode) {
|
||||
@@ -4979,9 +4952,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return;
|
||||
}
|
||||
|
||||
// SAF downloads should end with content URI. If we still have a
|
||||
// transient FD path, recover URI from SAF metadata to keep history
|
||||
// dedup/exclusion stable.
|
||||
if (effectiveSafMode &&
|
||||
filePath != null &&
|
||||
filePath.isNotEmpty &&
|
||||
@@ -5296,8 +5266,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
_failedInSession++;
|
||||
|
||||
// Immediately cleanup connections after failure to prevent
|
||||
// poisoned connection pool from affecting subsequent downloads
|
||||
try {
|
||||
await PlatformBridge.cleanupConnections();
|
||||
} catch (e) {
|
||||
@@ -5350,7 +5318,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
_failedInSession++;
|
||||
|
||||
// Immediately cleanup connections after exception
|
||||
try {
|
||||
await PlatformBridge.cleanupConnections();
|
||||
} catch (cleanupErr) {
|
||||
|
||||
@@ -11,7 +11,7 @@ final _log = AppLogger('ExploreProvider');
|
||||
class ExploreItem {
|
||||
final String id;
|
||||
final String uri;
|
||||
final String type; // track, album, playlist, artist, station
|
||||
final String type;
|
||||
final String name;
|
||||
final String artists;
|
||||
final String? description;
|
||||
@@ -168,7 +168,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
return const ExploreState();
|
||||
}
|
||||
|
||||
/// Restore cached home feed from SharedPreferences immediately on startup
|
||||
Future<void> _restoreFromCache() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -199,7 +198,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Save home feed to SharedPreferences for instant restore on next launch
|
||||
Future<void> _saveToCache(List<ExploreSection> sections) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -212,11 +210,9 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch home feed from spotify-web extension
|
||||
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
|
||||
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
|
||||
|
||||
// If we have cached content and it's fresh enough, skip network fetch
|
||||
if (!forceRefresh &&
|
||||
state.hasContent &&
|
||||
state.lastFetched != null &&
|
||||
@@ -230,7 +226,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only show loading spinner if we have no cached content to display
|
||||
final showLoading = !state.hasContent;
|
||||
state = state.copyWith(isLoading: showLoading, error: null);
|
||||
|
||||
@@ -247,14 +242,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
if (!extension.enabled || !extension.hasHomeFeed) {
|
||||
continue;
|
||||
}
|
||||
// If user has a preference, use that
|
||||
if (preferredId != null &&
|
||||
preferredId.isNotEmpty &&
|
||||
extension.id == preferredId) {
|
||||
targetExt = extension;
|
||||
break;
|
||||
}
|
||||
// Otherwise take the first available (fallback to spotify-web if found)
|
||||
if (targetExt == null || extension.id == 'spotify-web') {
|
||||
targetExt = extension;
|
||||
if (preferredId == null && extension.id == 'spotify-web') {
|
||||
@@ -317,7 +310,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
lastFetched: DateTime.now(),
|
||||
);
|
||||
|
||||
// Save to disk cache for instant restore on next app launch
|
||||
_saveToCache(sections);
|
||||
} catch (e, stack) {
|
||||
_log.e('Error fetching home feed: $e', e, stack);
|
||||
|
||||
@@ -32,14 +32,12 @@ class Extension {
|
||||
final bool hasMetadataProvider;
|
||||
final bool hasDownloadProvider;
|
||||
final bool hasLyricsProvider;
|
||||
final bool
|
||||
skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
||||
final bool skipMetadataEnrichment;
|
||||
final SearchBehavior? searchBehavior;
|
||||
final URLHandler? urlHandler;
|
||||
final TrackMatching? trackMatching;
|
||||
final PostProcessing? postProcessing;
|
||||
final Map<String, dynamic>
|
||||
capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
|
||||
final Map<String, dynamic> capabilities;
|
||||
|
||||
const Extension({
|
||||
required this.id,
|
||||
@@ -198,12 +196,10 @@ class SearchBehavior {
|
||||
final String? placeholder;
|
||||
final bool primary;
|
||||
final String? icon;
|
||||
final String?
|
||||
thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
||||
final String? thumbnailRatio;
|
||||
final int? thumbnailWidth;
|
||||
final int? thumbnailHeight;
|
||||
final List<SearchFilter>
|
||||
filters; // Available search filters (e.g., track, album, artist, playlist)
|
||||
final List<SearchFilter> filters;
|
||||
|
||||
const SearchBehavior({
|
||||
required this.enabled,
|
||||
@@ -239,11 +235,11 @@ class SearchBehavior {
|
||||
}
|
||||
|
||||
switch (thumbnailRatio) {
|
||||
case 'wide': // 16:9 - YouTube style
|
||||
case 'wide':
|
||||
return (defaultSize * 16 / 9, defaultSize);
|
||||
case 'portrait': // 2:3 - Poster style
|
||||
case 'portrait':
|
||||
return (defaultSize * 2 / 3, defaultSize);
|
||||
case 'square': // 1:1 - Album art style
|
||||
case 'square':
|
||||
default:
|
||||
return (defaultSize, defaultSize);
|
||||
}
|
||||
@@ -290,7 +286,6 @@ class PostProcessing {
|
||||
}
|
||||
}
|
||||
|
||||
/// URL handler configuration for custom URL patterns
|
||||
class URLHandler {
|
||||
final bool enabled;
|
||||
final List<String> patterns;
|
||||
@@ -304,7 +299,6 @@ class URLHandler {
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if a URL matches any of the patterns
|
||||
bool matchesURL(String url) {
|
||||
if (!enabled || patterns.isEmpty) return false;
|
||||
final lowerUrl = url.toLowerCase();
|
||||
|
||||
@@ -666,7 +666,6 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
final destPath = p.join(coversDir.path, '$playlistId$ext');
|
||||
if (playlist.coverImagePath == destPath) return;
|
||||
|
||||
// Copy image to persistent location
|
||||
await File(sourceFilePath).copy(destPath);
|
||||
|
||||
final now = DateTime.now();
|
||||
@@ -686,7 +685,6 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
final playlist = state.playlistById(playlistId);
|
||||
if (playlist == null || playlist.coverImagePath == null) return;
|
||||
|
||||
// Delete the file if it exists
|
||||
final path = playlist.coverImagePath;
|
||||
if (path != null) {
|
||||
final file = File(path);
|
||||
|
||||
@@ -252,8 +252,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
_startProgressPolling();
|
||||
|
||||
// On iOS, start accessing the security-scoped bookmark so the Go backend
|
||||
// can read files outside the app sandbox.
|
||||
String? resolvedPath;
|
||||
bool didStartSecurityAccess = false;
|
||||
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
|
||||
@@ -275,9 +273,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
try {
|
||||
final isSaf = effectiveFolderPath.startsWith('content://');
|
||||
|
||||
// Get all file paths from download history to exclude them.
|
||||
// Merge DB + in-memory state to avoid race when a fresh download has not
|
||||
// been flushed to SQLite yet.
|
||||
final downloadedPaths = await _historyDb.getAllFilePaths();
|
||||
final inMemoryHistoryPaths = ref
|
||||
.read(downloadHistoryProvider)
|
||||
@@ -298,7 +293,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
);
|
||||
|
||||
if (forceFullScan) {
|
||||
// Full scan path - ignores existing data
|
||||
final results = isSaf
|
||||
? await PlatformBridge.scanSafTree(effectiveFolderPath)
|
||||
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
|
||||
@@ -324,7 +318,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_log.i('Skipped $skippedDownloads files already in download history');
|
||||
}
|
||||
|
||||
// Full scan should replace library index atomically.
|
||||
await _db.replaceAll(items.map((e) => e.toJson()).toList());
|
||||
final persistedItems = [...items]..sort(_compareLibraryItems);
|
||||
|
||||
@@ -357,7 +350,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
errorCount: state.scanErrorCount,
|
||||
);
|
||||
} else {
|
||||
// Incremental scan path - only scans new/modified files
|
||||
final existingFiles = await _db.getFileModTimes();
|
||||
_log.i(
|
||||
'Incremental scan: ${existingFiles.length} existing files in database',
|
||||
@@ -416,7 +408,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
return;
|
||||
}
|
||||
|
||||
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
|
||||
final scannedList =
|
||||
(result['files'] as List<dynamic>?) ??
|
||||
(result['scanned'] as List<dynamic>?) ??
|
||||
@@ -437,10 +428,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
|
||||
);
|
||||
|
||||
// Build the incremental merge base from SQLite, not the current
|
||||
// provider state. Startup auto-scan can fire before `state.items` has
|
||||
// finished loading, which would otherwise drop unchanged rows from the
|
||||
// in-memory library until a manual full rescan.
|
||||
final existingJson = await _db.getAll();
|
||||
final currentByPath = <String, LocalLibraryItem>{
|
||||
for (final item in existingJson.map(LocalLibraryItem.fromJson))
|
||||
@@ -461,7 +448,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
);
|
||||
}
|
||||
|
||||
// Upsert new/modified items (excluding downloaded files)
|
||||
final updatedItems = <LocalLibraryItem>[];
|
||||
int skippedDownloads = existingDownloadedPaths.length;
|
||||
if (scannedList.isNotEmpty) {
|
||||
|
||||
@@ -5,18 +5,16 @@ import 'package:spotiflac_android/services/app_state_database.dart';
|
||||
|
||||
const _maxRecentItems = 20;
|
||||
|
||||
/// Types of items that can be accessed
|
||||
enum RecentAccessType { artist, album, track, playlist }
|
||||
|
||||
/// Represents a recently accessed item
|
||||
class RecentAccessItem {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? subtitle; // Artist name for tracks/albums, null for artists
|
||||
final String? subtitle;
|
||||
final String? imageUrl;
|
||||
final RecentAccessType type;
|
||||
final DateTime accessedAt;
|
||||
final String? providerId; // Extension ID or 'deezer' for built-in
|
||||
final String? providerId;
|
||||
|
||||
const RecentAccessItem({
|
||||
required this.id,
|
||||
@@ -53,7 +51,6 @@ class RecentAccessItem {
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a unique key for deduplication
|
||||
String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id';
|
||||
|
||||
@override
|
||||
@@ -67,7 +64,6 @@ class RecentAccessItem {
|
||||
int get hashCode => uniqueKey.hashCode;
|
||||
}
|
||||
|
||||
/// State for recent access history
|
||||
class RecentAccessState {
|
||||
final List<RecentAccessItem> items;
|
||||
final Set<String> hiddenDownloadIds;
|
||||
@@ -92,7 +88,6 @@ class RecentAccessState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for managing recent access history
|
||||
class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
final AppStateDatabase _appStateDb = AppStateDatabase.instance;
|
||||
|
||||
@@ -135,7 +130,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an access to an artist
|
||||
void recordArtistAccess({
|
||||
required String id,
|
||||
required String name,
|
||||
@@ -154,7 +148,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Record an access to an album
|
||||
void recordAlbumAccess({
|
||||
required String id,
|
||||
required String name,
|
||||
@@ -175,7 +168,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Record an access to a track
|
||||
void recordTrackAccess({
|
||||
required String id,
|
||||
required String name,
|
||||
@@ -196,7 +188,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Record an access to a playlist
|
||||
void recordPlaylistAccess({
|
||||
required String id,
|
||||
required String name,
|
||||
@@ -242,7 +233,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a specific item from history
|
||||
void removeItem(RecentAccessItem item) {
|
||||
final updatedItems = state.items
|
||||
.where((e) => e.uniqueKey != item.uniqueKey)
|
||||
@@ -251,25 +241,21 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey));
|
||||
}
|
||||
|
||||
/// Hide a download item from recents (without deleting the actual download)
|
||||
void hideDownloadFromRecents(String downloadId) {
|
||||
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
|
||||
state = state.copyWith(hiddenDownloadIds: updatedHidden);
|
||||
unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId));
|
||||
}
|
||||
|
||||
/// Check if a download is hidden from recents
|
||||
bool isDownloadHidden(String downloadId) {
|
||||
return state.hiddenDownloadIds.contains(downloadId);
|
||||
}
|
||||
|
||||
/// Clear all history
|
||||
void clearHistory() {
|
||||
state = state.copyWith(items: []);
|
||||
unawaited(_appStateDb.clearRecentAccessRows());
|
||||
}
|
||||
|
||||
/// Clear hidden downloads (show all again)
|
||||
void clearHiddenDownloads() {
|
||||
state = state.copyWith(hiddenDownloadIds: {});
|
||||
unawaited(_appStateDb.clearHiddenRecentDownloadIds());
|
||||
|
||||
@@ -264,13 +264,12 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
// Read back the resolved URL (may differ from input after normalisation).
|
||||
final resolvedUrl = await PlatformBridge.getStoreRegistryUrl();
|
||||
|
||||
// Persist to SharedPreferences
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_registryUrlPrefKey, resolvedUrl);
|
||||
|
||||
state = state.copyWith(
|
||||
registryUrl: resolvedUrl,
|
||||
extensions: const [], // Clear old extensions
|
||||
extensions: const [],
|
||||
);
|
||||
|
||||
_log.i('Registry URL set to: $resolvedUrl');
|
||||
|
||||
@@ -57,7 +57,6 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
await _saveToStorage();
|
||||
}
|
||||
|
||||
/// Set custom seed color (used when dynamic color is disabled)
|
||||
Future<void> setSeedColor(Color color) async {
|
||||
state = state.copyWith(seedColorValue: color.toARGB32());
|
||||
await _saveToStorage();
|
||||
@@ -81,4 +80,3 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,21 +18,18 @@ class TrackState {
|
||||
final String? artistId;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
final String? headerImageUrl; // Artist header image for background
|
||||
final String? headerImageUrl;
|
||||
final int? monthlyListeners;
|
||||
final List<ArtistAlbum>? artistAlbums; // For artist page
|
||||
final List<Track>? artistTopTracks; // Artist's popular tracks
|
||||
final List<SearchArtist>? searchArtists; // For search results
|
||||
final List<SearchAlbum>? searchAlbums; // For search results (albums)
|
||||
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
|
||||
final bool hasSearchText; // For back button handling
|
||||
final bool isShowingRecentAccess; // For recent access mode
|
||||
final String?
|
||||
searchExtensionId; // Extension ID used for current search results
|
||||
final String?
|
||||
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
||||
final String?
|
||||
searchSource; // Built-in search provider used for current results (e.g., "deezer", "tidal", "qobuz")
|
||||
final List<ArtistAlbum>? artistAlbums;
|
||||
final List<Track>? artistTopTracks;
|
||||
final List<SearchArtist>? searchArtists;
|
||||
final List<SearchAlbum>? searchAlbums;
|
||||
final List<SearchPlaylist>? searchPlaylists;
|
||||
final bool hasSearchText;
|
||||
final bool isShowingRecentAccess;
|
||||
final String? searchExtensionId;
|
||||
final String? selectedSearchFilter;
|
||||
final String? searchSource;
|
||||
|
||||
const TrackState({
|
||||
this.tracks = const [],
|
||||
@@ -127,9 +124,9 @@ class ArtistAlbum {
|
||||
final String releaseDate;
|
||||
final int totalTracks;
|
||||
final String? coverUrl;
|
||||
final String albumType; // album, single, compilation
|
||||
final String albumType;
|
||||
final String artists;
|
||||
final String? providerId; // Extension ID if from extension
|
||||
final String? providerId;
|
||||
|
||||
const ArtistAlbum({
|
||||
required this.id,
|
||||
@@ -204,7 +201,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
return const TrackState();
|
||||
}
|
||||
|
||||
/// Check if request is still valid (not cancelled by newer request)
|
||||
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
||||
|
||||
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
||||
@@ -217,7 +213,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
if (extensionHandler != null) {
|
||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||
|
||||
// Retry logic for extension URL handlers (up to 3 attempts)
|
||||
Map<String, dynamic>? result;
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
result = await PlatformBridge.handleURLWithExtension(url);
|
||||
@@ -541,7 +536,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
return;
|
||||
}
|
||||
|
||||
// If URL doesn't match any known service, it's unrecognized
|
||||
final isSpotifyUrl =
|
||||
url.contains('open.spotify.com') ||
|
||||
url.contains('spotify.link') ||
|
||||
@@ -643,7 +637,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
// Preserve selected filter during loading
|
||||
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
||||
|
||||
state = TrackState(
|
||||
@@ -662,7 +655,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final includeExtensions =
|
||||
settings.useExtensionProviders && hasActiveMetadataExtensions;
|
||||
|
||||
// Determine the effective search provider
|
||||
final effectiveProvider = builtInSearchProvider ?? 'deezer';
|
||||
|
||||
_log.i(
|
||||
@@ -672,7 +664,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
Map<String, dynamic> results;
|
||||
List<Map<String, dynamic>> metadataTrackResults = [];
|
||||
|
||||
// Only use metadata providers for Deezer search (default behavior)
|
||||
if (effectiveProvider == 'deezer') {
|
||||
try {
|
||||
_log.d('Calling metadata provider search API...');
|
||||
@@ -692,7 +683,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Call the appropriate search API
|
||||
switch (effectiveProvider) {
|
||||
case 'tidal':
|
||||
_log.d('Calling Tidal search API...');
|
||||
@@ -808,9 +798,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter: currentFilter, // Preserve filter in results
|
||||
searchSource:
|
||||
effectiveProvider, // Track which service was used for search
|
||||
selectedSearchFilter: currentFilter,
|
||||
searchSource: effectiveProvider,
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
@@ -837,7 +826,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter:
|
||||
state.selectedSearchFilter, // Preserve filter during loading
|
||||
state.selectedSearchFilter,
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -876,9 +865,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
searchExtensionId: extensionId, // Store which extension was used
|
||||
selectedSearchFilter:
|
||||
state.selectedSearchFilter, // Preserve selected filter
|
||||
searchExtensionId: extensionId,
|
||||
selectedSearchFilter: state.selectedSearchFilter,
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
@@ -934,7 +922,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
tracks[index] = updatedTrack;
|
||||
state = state.copyWith(tracks: tracks);
|
||||
} catch (_) {
|
||||
// Silently ignore update failures - track may have been removed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -942,7 +929,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = const TrackState();
|
||||
}
|
||||
|
||||
/// Set selected search filter for extension search
|
||||
void setSearchFilter(String? filter) {
|
||||
if (state.selectedSearchFilter == filter) return;
|
||||
state = state.copyWith(
|
||||
@@ -951,7 +937,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Set search text state for back button handling
|
||||
void setSearchText(bool hasText) {
|
||||
if (state.hasSearchText == hasText) {
|
||||
return;
|
||||
@@ -966,7 +951,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = state.copyWith(isShowingRecentAccess: showing);
|
||||
}
|
||||
|
||||
/// Set tracks from a collection (album/playlist) opened from search results
|
||||
void setTracksFromCollection({
|
||||
required List<Track> tracks,
|
||||
String? albumName,
|
||||
@@ -1127,7 +1111,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
'isrc': isrc,
|
||||
'track_name': track.name,
|
||||
'artist_name': track.artistName,
|
||||
'spotify_id': track.id, // Include Spotify ID for Amazon lookup
|
||||
'spotify_id': track.id,
|
||||
'service': 'tidal',
|
||||
});
|
||||
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
|
||||
|
||||
@@ -1105,7 +1105,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
? 'Opus'
|
||||
: null;
|
||||
if (ext == null || ext == targetFormat) continue;
|
||||
// Skip lossy sources when target is lossless (pointless re-encoding)
|
||||
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) continue;
|
||||
|
||||
@@ -1367,7 +1367,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
}
|
||||
}
|
||||
if (currentFormat == null || currentFormat == targetFormat) continue;
|
||||
// Skip lossy sources when target is lossless (pointless re-encoding)
|
||||
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final isLosslessSource =
|
||||
currentFormat == 'FLAC' || currentFormat == 'M4A';
|
||||
@@ -1488,7 +1487,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
bitrate: bitrate,
|
||||
metadata: metadata,
|
||||
coverPath: coverPath,
|
||||
deleteOriginal: !isSaf, // Only delete original for regular files
|
||||
deleteOriginal: !isSaf,
|
||||
);
|
||||
|
||||
if (coverPath != null) {
|
||||
@@ -1507,15 +1506,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
}
|
||||
|
||||
if (isSaf) {
|
||||
// For SAF: derive the parent tree URI and relative dir from the content URI,
|
||||
// then create new SAF file and delete old one
|
||||
// Parse the SAF URI to get the tree document path:
|
||||
// content://...tree/...document/.../oldName.flac
|
||||
// We need tree URI and relative dir to create the new file
|
||||
final uri = Uri.parse(item.filePath);
|
||||
final pathSegments = uri.pathSegments;
|
||||
|
||||
// Try to find 'tree' and 'document' segments
|
||||
String? treeUri;
|
||||
String relativeDir = '';
|
||||
String oldFileName = '';
|
||||
|
||||
@@ -162,7 +162,6 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
if (!Platform.isAndroid) return;
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
// Only show if user is still on legacy storage mode with a download dir set
|
||||
if (settings.storageMode == 'saf') return;
|
||||
if (settings.downloadDirectory.isEmpty) return;
|
||||
|
||||
|
||||
@@ -97,7 +97,6 @@ class UnifiedLibraryItem {
|
||||
} else if (item.bitDepth != null &&
|
||||
item.bitDepth! > 0 &&
|
||||
item.sampleRate != null) {
|
||||
// Lossless format with actual bit depth
|
||||
quality = buildDisplayAudioQuality(
|
||||
bitDepth: item.bitDepth,
|
||||
sampleRate: item.sampleRate,
|
||||
@@ -108,7 +107,7 @@ class UnifiedLibraryItem {
|
||||
trackName: item.trackName,
|
||||
artistName: item.artistName,
|
||||
albumName: item.albumName,
|
||||
coverUrl: null, // Local library doesn't have cover URLs
|
||||
coverUrl: null,
|
||||
localCoverPath: item.coverPath,
|
||||
filePath: item.filePath,
|
||||
quality: quality,
|
||||
@@ -170,9 +169,6 @@ class UnifiedLibraryItem {
|
||||
}
|
||||
if (localItem != null) {
|
||||
final l = localItem!;
|
||||
// Store coverPath (even local file paths) in coverUrl so playlist
|
||||
// entries retain the cover. All renderers must check whether the
|
||||
// value is a URL or a local path and use the appropriate widget.
|
||||
return Track(
|
||||
id: l.id,
|
||||
name: l.trackName,
|
||||
@@ -188,7 +184,6 @@ class UnifiedLibraryItem {
|
||||
source: 'local',
|
||||
);
|
||||
}
|
||||
// Fallback — should not happen
|
||||
return Track(
|
||||
id: id,
|
||||
name: trackName,
|
||||
@@ -4889,15 +4884,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
for (final id in _selectedIds) {
|
||||
final item = itemsById[id];
|
||||
if (item == null) continue;
|
||||
// Detect format: use safFileName for download history SAF items,
|
||||
// item.localItem?.format for local library items, file extension as fallback
|
||||
String nameToCheck;
|
||||
if (item.historyItem?.safFileName != null &&
|
||||
item.historyItem!.safFileName!.isNotEmpty) {
|
||||
nameToCheck = item.historyItem!.safFileName!.toLowerCase();
|
||||
} else if (item.localItem?.format != null &&
|
||||
item.localItem!.format!.isNotEmpty) {
|
||||
// Synthesize a fake extension to keep detection unified
|
||||
nameToCheck = '.${item.localItem!.format!.toLowerCase()}';
|
||||
} else {
|
||||
nameToCheck = item.filePath.toLowerCase();
|
||||
@@ -4912,7 +4904,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
? 'Opus'
|
||||
: null;
|
||||
if (ext == null || ext == targetFormat) continue;
|
||||
// Skip lossy sources when target is lossless (pointless re-encoding)
|
||||
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) continue;
|
||||
@@ -5060,7 +5051,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle SAF write-back
|
||||
if (isSaf && item.historyItem != null) {
|
||||
final hi = item.historyItem!;
|
||||
final treeUri = hi.downloadTreeUri;
|
||||
@@ -5113,12 +5103,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delete old SAF file
|
||||
try {
|
||||
await PlatformBridge.safDelete(item.filePath);
|
||||
} catch (_) {}
|
||||
|
||||
// Update history
|
||||
await historyDb.updateFilePath(
|
||||
hi.id,
|
||||
safUri,
|
||||
@@ -5127,7 +5115,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
clearAudioSpecs: true,
|
||||
);
|
||||
}
|
||||
// Cleanup temp files
|
||||
try {
|
||||
await File(newPath).delete();
|
||||
} catch (_) {}
|
||||
@@ -5137,7 +5124,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
} catch (_) {}
|
||||
}
|
||||
} else if (isSaf && item.localItem != null) {
|
||||
// Local library SAF item: parse content URI to derive tree and dir
|
||||
final uri = Uri.parse(item.filePath);
|
||||
final pathSegments = uri.pathSegments;
|
||||
|
||||
@@ -5214,7 +5200,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
await LibraryDatabase.instance.deleteByPath(item.filePath);
|
||||
}
|
||||
|
||||
// Cleanup temp files
|
||||
try {
|
||||
await File(newPath).delete();
|
||||
} catch (_) {}
|
||||
@@ -5224,7 +5209,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
} catch (_) {}
|
||||
}
|
||||
} else if (item.historyItem != null) {
|
||||
// Regular file - update history path
|
||||
await historyDb.updateFilePath(
|
||||
item.historyItem!.id,
|
||||
newPath,
|
||||
@@ -5232,17 +5216,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
clearAudioSpecs: true,
|
||||
);
|
||||
} else if (item.localItem != null) {
|
||||
// Regular local library file - delete old db entry, rescan picks up new file
|
||||
await LibraryDatabase.instance.deleteByPath(item.filePath);
|
||||
}
|
||||
|
||||
successCount++;
|
||||
} catch (_) {
|
||||
// Continue to next item on error
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Reload history and local library to reflect path changes in UI
|
||||
ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
||||
ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
||||
|
||||
@@ -5264,7 +5244,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bottom action bar for selection mode (Material Design 3 style)
|
||||
Widget _buildSelectionBottomBar(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
|
||||
@@ -61,7 +61,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
final hasError = extension.status == 'error';
|
||||
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
|
||||
@@ -61,7 +61,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
@@ -600,14 +600,12 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
.where((e) => e.enabled && e.hasCustomSearch)
|
||||
.toList();
|
||||
|
||||
// Always allow tapping: built-in providers are always available
|
||||
final hasAnyProvider =
|
||||
searchProviders.isNotEmpty || _builtInProviders.isNotEmpty;
|
||||
|
||||
String currentProviderName = context.l10n.extensionDefaultProvider;
|
||||
if (settings.searchProvider != null &&
|
||||
settings.searchProvider!.isNotEmpty) {
|
||||
// Check built-in first
|
||||
if (_builtInProviders.containsKey(settings.searchProvider)) {
|
||||
currentProviderName = _builtInProviders[settings.searchProvider]!;
|
||||
} else {
|
||||
|
||||
@@ -23,21 +23,15 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
int _androidSdkVersion = 0;
|
||||
bool _hasStoragePermission = false;
|
||||
|
||||
/// Convert SAF content URI to a readable display path
|
||||
String _getDisplayPath(String path) {
|
||||
if (!path.startsWith('content://')) return path;
|
||||
// Extract the path portion from SAF tree URI
|
||||
// e.g. content://com.android.externalstorage.documents/tree/primary%3AMusic
|
||||
// -> /storage/emulated/0/Music
|
||||
try {
|
||||
final uri = Uri.parse(path);
|
||||
final treePath =
|
||||
uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic"
|
||||
final treePath = uri.pathSegments.last;
|
||||
final decoded = Uri.decodeComponent(treePath);
|
||||
if (decoded.startsWith('primary:')) {
|
||||
return '/storage/emulated/0/${decoded.substring('primary:'.length)}';
|
||||
}
|
||||
// For SD card or other volumes, just show the decoded path
|
||||
return decoded;
|
||||
} catch (_) {
|
||||
return path;
|
||||
|
||||
@@ -136,7 +136,7 @@ class _LogScreenState extends State<LogScreen> {
|
||||
final logs = _filteredLogs;
|
||||
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
|
||||
@@ -19,7 +19,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
|
||||
@@ -441,14 +441,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
|
||||
void _nextPage() {
|
||||
bool canProceed = false;
|
||||
// Step 0 is Welcome, always can proceed
|
||||
if (_currentStep == 0) {
|
||||
canProceed = true;
|
||||
} else {
|
||||
// Logic for other steps (offset by 1 because of welcome step)
|
||||
// Step 1: Storage
|
||||
// Step 2: Notification (if android 13+) OR Directory
|
||||
// etc.
|
||||
canProceed = _isStepCompleted(_currentStep);
|
||||
}
|
||||
|
||||
@@ -470,9 +465,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
}
|
||||
|
||||
bool _isStepCompleted(int step) {
|
||||
if (step == 0) return true; // Welcome
|
||||
if (step == 0) return true;
|
||||
|
||||
// Adjust step index for logic because we added Welcome at 0
|
||||
final logicStep = step - 1;
|
||||
|
||||
if (_androidSdkVersion >= 33) {
|
||||
|
||||
@@ -60,19 +60,19 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
bool _fileExists = false;
|
||||
bool _hasCheckedFile = false;
|
||||
int? _fileSize;
|
||||
String? _lyrics; // Cleaned lyrics for display (no timestamps)
|
||||
String? _rawLyrics; // Raw LRC with timestamps for embedding
|
||||
String? _lyrics;
|
||||
String? _rawLyrics;
|
||||
bool _lyricsLoading = false;
|
||||
String? _lyricsError;
|
||||
String? _lyricsSource;
|
||||
bool _showTitleInAppBar = false;
|
||||
bool _lyricsEmbedded = false;
|
||||
bool _isEmbedding = false; // Track embed operation in progress
|
||||
bool _isEmbedding = false;
|
||||
bool _isInstrumental = false;
|
||||
bool _isConverting = false; // Track convert operation in progress
|
||||
bool _isConverting = false;
|
||||
bool _hasMetadataChanges = false;
|
||||
bool _hasLoadedResolvedAudioMetadata = false;
|
||||
Map<String, dynamic>? _editedMetadata; // Overrides after metadata edit
|
||||
Map<String, dynamic>? _editedMetadata;
|
||||
String? _embeddedCoverPreviewPath;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
static final RegExp _lrcTimestampPattern = RegExp(
|
||||
@@ -577,7 +577,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
String get cleanFilePath {
|
||||
var path = _filePath;
|
||||
if (path.startsWith('EXISTS:')) path = path.substring(7);
|
||||
// Strip CUE virtual path suffix for filesystem operations
|
||||
if (isCueVirtualPath(path)) path = stripCueTrackSuffix(path);
|
||||
return path;
|
||||
}
|
||||
@@ -1707,7 +1706,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_spotifyId ?? '',
|
||||
trackName,
|
||||
artistName,
|
||||
filePath: null, // Don't check file again
|
||||
filePath: null,
|
||||
durationMs: durationMs,
|
||||
).timeout(const Duration(seconds: 20));
|
||||
|
||||
@@ -1733,9 +1732,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
final cleanLyrics = _cleanLrcForDisplay(lrcText);
|
||||
setState(() {
|
||||
_lyrics = cleanLyrics;
|
||||
_rawLyrics = lrcText; // Keep raw LRC with timestamps for embedding
|
||||
_rawLyrics = lrcText;
|
||||
_lyricsSource = source.isNotEmpty ? source : null;
|
||||
_lyricsEmbedded = false; // Lyrics from online, not embedded
|
||||
_lyricsEmbedded = false;
|
||||
_lyricsLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -1762,7 +1761,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
setState(() => _isEmbedding = true);
|
||||
|
||||
// Capture l10n strings before async gaps to avoid use_build_context_synchronously
|
||||
final l10nFailedToWriteStorage = context.l10n.snackbarFailedToWriteStorage;
|
||||
final l10nFailedToEmbedLyrics = context.l10n.snackbarFailedToEmbedLyrics;
|
||||
final l10nUnsupportedFormat = context.l10n.snackbarUnsupportedAudioFormat;
|
||||
@@ -3556,7 +3554,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
bitrate: bitrate,
|
||||
metadata: metadata,
|
||||
coverPath: coverPath,
|
||||
deleteOriginal: !isSaf, // Don't delete temp copy for SAF, we handle it
|
||||
deleteOriginal: !isSaf,
|
||||
);
|
||||
|
||||
if (coverPath != null) {
|
||||
@@ -3627,7 +3625,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
newExt = '.flac';
|
||||
mimeType = 'audio/flac';
|
||||
break;
|
||||
default: // mp3
|
||||
default:
|
||||
newExt = '.mp3';
|
||||
mimeType = 'audio/mpeg';
|
||||
break;
|
||||
|
||||
@@ -198,8 +198,8 @@ class CsvImportService {
|
||||
artistName: artistName ?? 'Unknown Artist',
|
||||
albumName: albumName ?? 'Unknown Album',
|
||||
isrc: isrc,
|
||||
duration: 0, // Will be updated by enrichment later
|
||||
coverUrl: null, // Will be fetched by enrichment
|
||||
duration: 0,
|
||||
coverUrl: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1437,7 +1437,6 @@ class FFmpegService {
|
||||
final cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$inputPath" ');
|
||||
|
||||
// Cover art as second input for M4A attached picture
|
||||
final hasCover =
|
||||
coverPath != null &&
|
||||
coverPath.trim().isNotEmpty &&
|
||||
@@ -1455,7 +1454,6 @@ class FFmpegService {
|
||||
cmdBuffer.write('-c:a alac ');
|
||||
cmdBuffer.write('-map_metadata -1 ');
|
||||
|
||||
// Embed M4A metadata tags
|
||||
final m4aTags = _convertToM4aTags(metadata);
|
||||
for (final entry in m4aTags.entries) {
|
||||
final sanitized = entry.value.replaceAll('"', '\\"');
|
||||
@@ -1764,7 +1762,6 @@ class FFmpegService {
|
||||
|
||||
final outputPaths = <String>[];
|
||||
final inputExt = audioPath.toLowerCase().split('.').last;
|
||||
// For lossless formats, keep as FLAC; for others, keep original format
|
||||
final outputExt =
|
||||
(inputExt == 'flac' ||
|
||||
inputExt == 'wav' ||
|
||||
@@ -1836,14 +1833,10 @@ class FFmpegService {
|
||||
final result = await _execute(command);
|
||||
if (!result.success) {
|
||||
_log.e('CUE split failed for track ${track.number}: ${result.output}');
|
||||
// Continue with remaining tracks instead of failing completely
|
||||
continue;
|
||||
}
|
||||
|
||||
// Embed cover art if available (for FLAC output)
|
||||
if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') {
|
||||
// Use the Go backend for FLAC cover embedding via PlatformBridge
|
||||
// (handled by the caller)
|
||||
}
|
||||
|
||||
outputPaths.add(outputPath);
|
||||
|
||||
@@ -1081,7 +1081,6 @@ class PlatformBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the directory for caching extracted cover art
|
||||
static Future<void> setLibraryCoverCacheDir(String cacheDir) async {
|
||||
_log.i('setLibraryCoverCacheDir: $cacheDir');
|
||||
await _channel.invokeMethod('setLibraryCoverCacheDir', {
|
||||
@@ -1089,8 +1088,6 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Scan a folder for audio files and read their metadata
|
||||
/// Returns a list of track metadata
|
||||
static Future<List<Map<String, dynamic>>> scanLibraryFolder(
|
||||
String folderPath,
|
||||
) async {
|
||||
@@ -1102,10 +1099,6 @@ class PlatformBridge {
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Perform an incremental scan of the library folder
|
||||
/// Only scans files that are new or have changed since last scan
|
||||
/// [existingFiles] is a map of filePath -> modTime (unix millis)
|
||||
/// Returns IncrementalScanResult with scanned items, deleted paths, and skip count
|
||||
static Future<Map<String, dynamic>> scanLibraryFolderIncremental(
|
||||
String folderPath,
|
||||
Map<String, int> existingFiles,
|
||||
@@ -1140,8 +1133,6 @@ class PlatformBridge {
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Incremental SAF tree scan - only scans new or modified files
|
||||
/// Returns a map with 'files' (new/changed) and 'removedUris' (deleted files)
|
||||
static Future<Map<String, dynamic>> scanSafTreeIncremental(
|
||||
String treeUri,
|
||||
Map<String, int> existingFiles,
|
||||
@@ -1167,8 +1158,6 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get last-modified timestamps for a list of SAF file URIs.
|
||||
/// Returns map uri -> modTime (unix millis), only for files that still exist.
|
||||
static Future<Map<String, int>> getSafFileModTimes(List<String> uris) async {
|
||||
final result = await _channel.invokeMethod('getSafFileModTimes', {
|
||||
'uris': jsonEncode(uris),
|
||||
@@ -1177,7 +1166,6 @@ class PlatformBridge {
|
||||
return map.map((key, value) => MapEntry(key, (value as num).toInt()));
|
||||
}
|
||||
|
||||
/// Get current library scan progress
|
||||
static Future<Map<String, dynamic>> getLibraryScanProgress() async {
|
||||
final result = await _channel.invokeMethod('getLibraryScanProgress');
|
||||
return _decodeMapResult(result);
|
||||
@@ -1189,7 +1177,6 @@ class PlatformBridge {
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancel ongoing library scan
|
||||
static Future<void> cancelLibraryScan() async {
|
||||
await _channel.invokeMethod('cancelLibraryScan');
|
||||
}
|
||||
@@ -1249,7 +1236,6 @@ class PlatformBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read metadata from a single audio file
|
||||
static Future<Map<String, dynamic>?> readAudioMetadata(
|
||||
String filePath,
|
||||
) async {
|
||||
@@ -1369,10 +1355,6 @@ class PlatformBridge {
|
||||
await _channel.invokeMethod('clearStoreCache');
|
||||
}
|
||||
|
||||
/// Parse a .cue file and return split information (track listing, timing, metadata).
|
||||
/// Returns a map with: cue_path, audio_path, album, artist, genre, date, tracks[]
|
||||
/// Each track has: number, title, artist, isrc, composer, start_sec, end_sec
|
||||
/// [audioDir] optionally overrides the directory for audio file resolution (used for SAF).
|
||||
static Future<Map<String, dynamic>> parseCueSheet(
|
||||
String cuePath, {
|
||||
String audioDir = '',
|
||||
|
||||
@@ -80,7 +80,6 @@ class ShareIntentService {
|
||||
bool isInitial = false,
|
||||
}) {
|
||||
for (final file in files) {
|
||||
// Check both path and message - apps may share URL in either field
|
||||
final textsToCheck = [file.path, if (file.message != null) file.message!];
|
||||
|
||||
for (final textToCheck in textsToCheck) {
|
||||
@@ -100,13 +99,11 @@ class ShareIntentService {
|
||||
String? _extractMusicUrl(String text) {
|
||||
if (text.isEmpty) return null;
|
||||
|
||||
// Try Spotify URI first
|
||||
final uriMatch = _spotifyUriPattern.firstMatch(text);
|
||||
if (uriMatch != null) {
|
||||
return uriMatch.group(0);
|
||||
}
|
||||
|
||||
// Try all URL patterns
|
||||
final patterns = [
|
||||
_spotifyUrlPattern,
|
||||
_deezerUrlPattern,
|
||||
|
||||
@@ -27,7 +27,6 @@ Future<void> navigateToArtist(
|
||||
|
||||
final normalizedArtistId = _normalizeArtistId(artistId);
|
||||
|
||||
// If we have a valid artist ID already, navigate directly
|
||||
if (normalizedArtistId != null &&
|
||||
_canNavigateArtistDirectly(
|
||||
artistId: normalizedArtistId,
|
||||
@@ -43,7 +42,6 @@ Future<void> navigateToArtist(
|
||||
return;
|
||||
}
|
||||
|
||||
// Search Deezer to resolve the artist ID
|
||||
_showLoadingSnackBar(context, 'Looking up artist...');
|
||||
try {
|
||||
final results = await PlatformBridge.searchDeezerAll(
|
||||
@@ -60,7 +58,6 @@ Future<void> navigateToArtist(
|
||||
return;
|
||||
}
|
||||
|
||||
// Find best match - prefer exact name match (case-insensitive)
|
||||
Map<String, dynamic>? bestMatch;
|
||||
final lowerName = artistName.toLowerCase().trim();
|
||||
for (final a in artistList) {
|
||||
@@ -113,7 +110,6 @@ Future<void> navigateToAlbum(
|
||||
}) async {
|
||||
if (albumName.isEmpty) return;
|
||||
|
||||
// If we have a valid album ID already, navigate directly
|
||||
if (albumId != null &&
|
||||
albumId.isNotEmpty &&
|
||||
albumId != 'unknown' &&
|
||||
@@ -128,16 +124,13 @@ Future<void> navigateToAlbum(
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's extension-based content without an ID, can't search Deezer for it
|
||||
if (extensionId != null) {
|
||||
_showUnavailable(context, 'Album');
|
||||
return;
|
||||
}
|
||||
|
||||
// Search Deezer to resolve the album ID
|
||||
_showLoadingSnackBar(context, 'Looking up album...');
|
||||
try {
|
||||
// Build search query: "albumName artistName" for better accuracy
|
||||
final query = artistName != null && artistName.isNotEmpty
|
||||
? '$albumName $artistName'
|
||||
: albumName;
|
||||
@@ -156,7 +149,6 @@ Future<void> navigateToAlbum(
|
||||
return;
|
||||
}
|
||||
|
||||
// Find best match - prefer exact name match (case-insensitive)
|
||||
Map<String, dynamic>? bestMatch;
|
||||
final lowerName = albumName.toLowerCase().trim();
|
||||
for (final a in albumList) {
|
||||
|
||||
@@ -14,8 +14,6 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
// Data models
|
||||
|
||||
class AudioAnalysisData {
|
||||
final String filePath;
|
||||
final int fileSize;
|
||||
@@ -98,8 +96,6 @@ class SpectrogramData {
|
||||
});
|
||||
}
|
||||
|
||||
// Audio Analysis Card Widget
|
||||
|
||||
class AudioAnalysisCard extends StatefulWidget {
|
||||
final String filePath;
|
||||
|
||||
@@ -179,7 +175,6 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
});
|
||||
|
||||
try {
|
||||
// Try loading from cache first
|
||||
final cached = await _loadFromCache(widget.filePath);
|
||||
AudioAnalysisData data;
|
||||
|
||||
@@ -187,7 +182,6 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
data = cached;
|
||||
} else {
|
||||
data = await _runAnalysis(widget.filePath);
|
||||
// Save to cache (fire-and-forget)
|
||||
_saveToCache(widget.filePath, data);
|
||||
}
|
||||
|
||||
@@ -214,8 +208,6 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
}
|
||||
}
|
||||
|
||||
// Analysis cache
|
||||
|
||||
static String _cacheKey(String filePath) {
|
||||
var hash = 0xcbf29ce484222325;
|
||||
for (final byte in utf8.encode(filePath)) {
|
||||
@@ -267,8 +259,6 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Analysis pipeline
|
||||
|
||||
Future<AudioAnalysisData> _runAnalysis(String filePath) async {
|
||||
await FFmpegKitConfig.setLogLevel(Level.avLogError);
|
||||
|
||||
@@ -302,7 +292,6 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
),
|
||||
);
|
||||
|
||||
// Total samples from file metadata (not truncated PCM)
|
||||
final trueTotalSamples =
|
||||
(info.duration * info.sampleRate * info.channels).round();
|
||||
|
||||
@@ -468,7 +457,6 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final l10n = context.l10n;
|
||||
|
||||
// Still checking cache, show nothing yet
|
||||
if (_checkingCache) return const SizedBox.shrink();
|
||||
|
||||
if (_analyzing) {
|
||||
@@ -575,8 +563,6 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
}
|
||||
}
|
||||
|
||||
// Internal types
|
||||
|
||||
class _MediaInfo {
|
||||
final int fileSize;
|
||||
final int sampleRate;
|
||||
@@ -623,8 +609,6 @@ class _AnalysisResult {
|
||||
});
|
||||
}
|
||||
|
||||
// Isolate: PCM analysis + FFT spectrogram
|
||||
|
||||
_AnalysisResult _analyzeInIsolate(_AnalysisParams params) {
|
||||
final byteData = ByteData.sublistView(params.pcmBytes);
|
||||
final sampleCount = params.pcmBytes.length ~/ 2;
|
||||
@@ -767,8 +751,6 @@ Float64List _fft(Float64List realInput) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Audio Info Card
|
||||
|
||||
class _AudioInfoCard extends StatelessWidget {
|
||||
final AudioAnalysisData data;
|
||||
|
||||
@@ -945,8 +927,6 @@ class _MetricChip extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// Spectrogram View
|
||||
|
||||
class _SpectrogramView extends StatelessWidget {
|
||||
final ui.Image image;
|
||||
final SpectrogramData spectrum;
|
||||
@@ -1011,8 +991,6 @@ class _ImagePainter extends CustomPainter {
|
||||
bool shouldRepaint(covariant _ImagePainter old) => old.image != image;
|
||||
}
|
||||
|
||||
// Spectrogram pixel-buffer rendering (runs in isolate)
|
||||
|
||||
class _SpectrogramRenderParams {
|
||||
final SpectrogramData spectrum;
|
||||
final int width;
|
||||
@@ -1031,7 +1009,6 @@ Uint8List _renderSpectrogramPixels(_SpectrogramRenderParams params) {
|
||||
final spectrum = params.spectrum;
|
||||
final pixels = Uint8List(w * h * 4);
|
||||
|
||||
// Fill black
|
||||
for (int i = 3; i < pixels.length; i += 4) {
|
||||
pixels[i] = 255;
|
||||
}
|
||||
@@ -1041,7 +1018,6 @@ Uint8List _renderSpectrogramPixels(_SpectrogramRenderParams params) {
|
||||
|
||||
final freqBins = spectrum.freqBins;
|
||||
|
||||
// dB range
|
||||
double minDB = 0;
|
||||
double maxDB = -200;
|
||||
for (final slice in slices) {
|
||||
|
||||
@@ -166,7 +166,6 @@ class _GitHubPainter extends CustomPainter {
|
||||
9.47 * scale, 17.93 * scale,
|
||||
9.81 * scale, 17.63 * scale,
|
||||
);
|
||||
// Bottom
|
||||
path.cubicTo(
|
||||
7.15 * scale, 17.33 * scale,
|
||||
4.34 * scale, 16.33 * scale,
|
||||
|
||||
Reference in New Issue
Block a user