feat: add resolve API with SongLink fallback, fix multi-artist tags (#288), and cleanup

Resolve API (api.zarz.moe):
- Refactor songlink.go: Spotify URLs use resolve API, non-Spotify uses SongLink API
- Add SongLink fallback when resolve API fails for Spotify (two-layer resilience)
- Remove dead code: page parser, XOR-obfuscated keys, legacy helpers

Multi-artist tag fix (#288):
- Add RewriteSplitArtistTags() in Go to rewrite ARTIST/ALBUMARTIST as split Vorbis comments
- Wire method channel handler in Android (MainActivity.kt) and iOS (AppDelegate.swift)
- Add PlatformBridge.rewriteSplitArtistTags() in Dart
- Call native FLAC rewriter after FFmpeg embed when split_vorbis mode is active
- Extract deezerTrackArtistDisplay() helper to use Contributors in album/playlist tracks

Code cleanup:
- Remove unused imports, dead code, and redundant comments across Go and Dart
- Fix build: remove stale getQobuzDebugKey() reference in deezer_download.go
This commit is contained in:
zarzet
2026-04-01 02:45:19 +07:00
parent 7c4309955e
commit d034144e9c
63 changed files with 427 additions and 1046 deletions
-6
View File
@@ -82,7 +82,6 @@ class _RuntimeProfile {
});
}
/// Widget to eagerly initialize providers that need to load data on startup
class _EagerInitialization extends ConsumerStatefulWidget {
const _EagerInitialization({required this.child});
final Widget child;
@@ -170,10 +169,8 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
const Duration(milliseconds: 1600),
() {
ref.read(localLibraryProvider);
// Trigger auto-scan after initial warmup on first app launch.
if (!_autoScanTriggeredOnLaunch) {
_autoScanTriggeredOnLaunch = true;
// Give the provider a moment to load existing data before scanning.
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) _maybeAutoScanLocalLibrary();
});
@@ -182,8 +179,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
);
}
/// Checks whether an automatic incremental scan should be triggered based on
/// the user's auto-scan preference and the time since the last scan.
Future<void> _maybeAutoScanLocalLibrary() async {
if (!mounted) return;
@@ -204,7 +199,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
switch (settings.localLibraryAutoScan) {
case 'on_open':
// Cooldown of 10 minutes to prevent rapid re-scans.
if (elapsed.inMinutes < 10) return;
break;
case 'daily':
@@ -20,6 +20,7 @@ import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
final _log = AppLogger('DownloadQueue');
final _historyLog = AppLogger('DownloadHistory');
@@ -3010,6 +3011,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.w('FFmpeg metadata/cover embed failed');
}
// After FFmpeg embed, fix split artist tags using native FLAC writer.
// FFmpeg deduplicates repeated metadata keys, so multiple ARTIST entries
// collapse into one. The Go FLAC writer rewrites them properly.
if (settings.artistTagMode == artistTagModeSplitVorbis) {
try {
await PlatformBridge.rewriteSplitArtistTags(
flacPath,
track.artistName,
albumArtist,
);
_log.d('Split artist tags rewritten via native FLAC writer');
} catch (e) {
_log.w('Failed to rewrite split artist tags: $e');
}
}
if (coverPath != null) {
try {
final coverFile = File(coverPath);
-8
View File
@@ -146,7 +146,6 @@ class StoreState {
this.registryUrl = '',
});
/// Whether a registry URL has been configured by the user.
bool get hasRegistryUrl => registryUrl.isNotEmpty;
StoreState copyWith({
@@ -218,7 +217,6 @@ class StoreNotifier extends Notifier<StoreState> {
Future<void> initialize(String cacheDir) async {
if (state.isInitialized) return;
// Load saved registry URL early to avoid UI flash (empty → setup screen)
final prefs = await SharedPreferences.getInstance();
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
@@ -246,8 +244,6 @@ class StoreNotifier extends Notifier<StoreState> {
}
}
/// Sets the registry URL, saves it, and refreshes the store.
/// The Go backend handles URL normalisation (GitHub repo → raw URL, branch detection).
Future<void> setRegistryUrl(String url) async {
final trimmed = url.trim();
if (trimmed.isEmpty) {
@@ -258,10 +254,8 @@ class StoreNotifier extends Notifier<StoreState> {
state = state.copyWith(isLoading: true, clearError: true);
try {
// Go backend resolves GitHub URLs (detects default branch) and validates HTTPS.
await PlatformBridge.setStoreRegistryUrl(trimmed);
// Read back the resolved URL (may differ from input after normalisation).
final resolvedUrl = await PlatformBridge.getStoreRegistryUrl();
final prefs = await SharedPreferences.getInstance();
@@ -280,13 +274,11 @@ class StoreNotifier extends Notifier<StoreState> {
}
}
/// Removes the saved registry URL and fully detaches the repo from backend.
Future<void> removeRegistryUrl() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_registryUrlPrefKey);
// Reset the URL in Go backend memory AND clear its cache
await PlatformBridge.clearStoreRegistryUrl();
state = state.copyWith(
-3
View File
@@ -138,14 +138,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
/// Upgrade cover URL to a higher resolution for full-screen display.
String? _highResCoverUrl(String? url) {
if (url == null) return null;
// Spotify CDN: upgrade 300 → 640 only (no intermediate between 640 and 2000)
if (url.contains('ab67616d00001e02')) {
return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273');
}
// Deezer CDN: upgrade to 1000x1000
final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$');
if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) {
return url.replaceAllMapped(
-3
View File
@@ -228,7 +228,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
void _onScroll() {
// Show title when scrolled past the header (280px trigger)
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
@@ -2013,7 +2012,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
}
/// Option tile for discography download bottom sheet
class _DiscographyOptionTile extends StatelessWidget {
final IconData icon;
final String title;
@@ -2051,7 +2049,6 @@ class _DiscographyOptionTile extends StatelessWidget {
}
}
/// Progress dialog shown while fetching album tracks
class _FetchingProgressDialog extends StatefulWidget {
final int totalAlbums;
final VoidCallback onCancel;
-5
View File
@@ -95,7 +95,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
/// Upgrade cover URL to a reasonable resolution for full-screen display.
String? _highResCoverUrl(String? url) {
if (url == null) return null;
if (url.contains('ab67616d00001e02')) {
@@ -111,7 +110,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return url;
}
/// Get tracks for this album from history provider (reactive)
List<DownloadHistoryItem> _getAlbumTracks(
List<DownloadHistoryItem> allItems,
) {
@@ -641,7 +639,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
// Info is now displayed in the full-screen cover overlay
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
@@ -848,7 +845,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
);
}
/// Share selected tracks via system share sheet
Future<void> _shareSelected(List<DownloadHistoryItem> allTracks) async {
final tracksById = {for (final t in allTracks) t.id: t};
final safUris = <String>[];
@@ -1091,7 +1087,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
// For SAF items, use safFileName to detect format (filePath is content:// URI)
final nameToCheck =
(item.safFileName != null && item.safFileName!.isNotEmpty)
? item.safFileName!.toLowerCase()
-2
View File
@@ -2412,8 +2412,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
}
// ── Search result sorting ──────────────────────────────────────────────
String _sortOptionLabel(_SearchSortOption option) {
switch (option) {
case _SearchSortOption.defaultOrder:
@@ -597,7 +597,6 @@ class _LibraryTracksFolderScreenState
final customCoverPath = playlist?.coverImagePath;
final isLovedMode = widget.mode == LibraryTracksFolderMode.loved;
final isPlaylistMode = widget.mode == LibraryTracksFolderMode.playlist;
// Loved always shows the heart icon (like Spotify's Liked Songs)
final coverUrl = isLovedMode ? null : _firstCoverUrl(entries, localState);
final hasCustomCover =
customCoverPath != null && customCoverPath.isNotEmpty;
@@ -667,7 +666,6 @@ class _LibraryTracksFolderScreenState
background: Stack(
fit: StackFit.expand,
children: [
// Cover background: custom > first track URL > icon
if (hasCustomCover)
Image.file(
File(customCoverPath),
@@ -1364,15 +1362,12 @@ class _CollectionTrackTile extends ConsumerWidget {
final track = entry.track;
final historyState = ref.read(downloadHistoryProvider);
// 1. Download history by Spotify ID
var historyItem = historyState.getBySpotifyId(track.id);
// 2. Download history by ISRC
if (historyItem == null && track.isrc != null && track.isrc!.isNotEmpty) {
historyItem = historyState.getByIsrc(track.isrc!);
}
// 3. Download history by track name + artist (handles ID/ISRC mismatch)
historyItem ??= historyState.findByTrackAndArtist(
track.name,
track.artistName,
@@ -1385,14 +1380,12 @@ class _CollectionTrackTile extends ConsumerWidget {
return;
}
// 4. Local library by ISRC
final localState = ref.read(localLibraryProvider);
LocalLibraryItem? localItem;
if (track.isrc != null && track.isrc!.isNotEmpty) {
localItem = localState.getByIsrc(track.isrc!);
}
// 5. Local library by track name + artist
localItem ??= localState.findByTrackAndArtist(track.name, track.artistName);
if (localItem != null) {
@@ -1402,7 +1395,6 @@ class _CollectionTrackTile extends ConsumerWidget {
return;
}
// 6. Not found anywhere — offer to download
_downloadTrack(context, ref);
}
}
-2
View File
@@ -526,7 +526,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
// Info is now displayed in the full-screen cover overlay
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
@@ -1057,7 +1056,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return;
}
// Temporarily hide selection bar so it doesn't overlap the bottom sheet.
// The bar uses AnimatedPositioned (250ms), so wait for the slide-out.
setState(() => _isSelectionMode = false);
await Future<void>.delayed(const Duration(milliseconds: 300));
-2
View File
@@ -348,7 +348,6 @@ class _MainShellState extends ConsumerState<MainShell>
trackState.isShowingRecentAccess &&
!trackState.isLoading &&
(trackState.hasSearchText || trackState.hasContent)) {
// Has recent access AND search content clear everything at once
_log.i(
'Back: step 3a - dismiss recent access + clear search/content '
'(hasSearchText=${trackState.hasSearchText}, hasContent=${trackState.hasContent})',
@@ -360,7 +359,6 @@ class _MainShellState extends ConsumerState<MainShell>
}
if (_currentIndex == 0 && trackState.isShowingRecentAccess) {
// Recent access overlay only (no search content) just dismiss it
_log.i('Back: step 3b - dismiss recent access only');
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
FocusManager.instance.primaryFocus?.unfocus();
-5
View File
@@ -117,7 +117,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
final playlistInfo = result['playlist_info'] as Map<String, dynamic>?;
final owner = playlistInfo?['owner'] as Map<String, dynamic>?;
// Go backend returns 'track_list' not 'tracks'
final trackList = result['track_list'] as List<dynamic>? ?? [];
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
@@ -182,14 +181,11 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
/// Upgrade cover URL to a reasonable resolution for full-screen display.
String? _highResCoverUrl(String? url) {
if (url == null) return null;
// Spotify CDN: upgrade 300 640 only
if (url.contains('ab67616d00001e02')) {
return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273');
}
// Deezer CDN: upgrade to 1000x1000
final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$');
if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) {
return url.replaceAllMapped(
@@ -729,7 +725,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
}
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
class _PlaylistTrackItem extends ConsumerWidget {
final Track track;
final VoidCallback onDownload;
-24
View File
@@ -156,7 +156,6 @@ class UnifiedLibraryItem {
return 'builtin:$id';
}
/// Convert to a [Track] for adding to collections/playlists.
Track toTrack() {
if (historyItem != null) {
final h = historyItem!;
@@ -2101,7 +2100,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
}
// Reload local library if we deleted any local items
if (allItems.any(
(i) =>
_selectedIds.contains(i.id) && i.source == LibraryItemSource.local,
@@ -2121,7 +2119,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
}
/// Strip EXISTS: prefix from file path (legacy history items)
String _cleanFilePath(String? filePath) {
return DownloadedEmbeddedCoverResolver.cleanFilePath(filePath);
}
@@ -2971,7 +2968,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
}
/// Navigate with unfocus pattern unfocuses search before and after navigation.
void _navigateWithUnfocus(Route<dynamic> route) {
_searchFocusNode.unfocus();
Navigator.of(context).push(route).then((_) => _searchFocusNode.unfocus());
@@ -3201,14 +3197,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}) async {
final notifier = ref.read(libraryCollectionsProvider.notifier);
// If in selection mode and the dragged item is selected, add ALL selected
if (_isSelectionMode &&
_selectedIds.isNotEmpty &&
_selectedIds.contains(item.id)) {
final selectedItems = allItems
.where((e) => _selectedIds.contains(e.id))
.toList();
// Fallback: if allItems is empty or no match, at least add the dragged item
if (selectedItems.isEmpty) {
selectedItems.add(item);
}
@@ -3247,8 +3241,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
/// Build a compact floating feedback widget shown while dragging a track.
/// Shows the count when multiple tracks are selected and being dragged.
Widget _buildDragFeedback(
BuildContext context,
UnifiedLibraryItem item,
@@ -3777,7 +3769,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
/// Build a Spotify-style collection list item (Wishlist, Loved, Playlists)
Widget _buildCollectionListItem({
required BuildContext context,
required ColorScheme colorScheme,
@@ -3854,7 +3845,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
/// Build a collection grid item for grid view mode
Widget _buildCollectionGridItem({
required BuildContext context,
required ColorScheme colorScheme,
@@ -3937,7 +3927,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return entries;
}
/// Build a collection item for the unified "All" tab grid view.
Widget _buildAllTabGridCollectionItem({
required BuildContext context,
required ColorScheme colorScheme,
@@ -4055,7 +4044,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
}
/// Build a collection item for the unified "All" tab list view.
Widget _buildAllTabListCollectionItem({
required BuildContext context,
required ColorScheme colorScheme,
@@ -4208,8 +4196,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
// Collection folders as list items (Spotify-style) in "All" tab
// are now rendered inline with tracks below (unified sliver)
if ((filteredGroupedAlbums.isNotEmpty ||
filteredGroupedLocalAlbums.isNotEmpty) &&
filterMode == 'albums')
@@ -4696,7 +4682,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
/// Album grid item for local library albums
Widget _buildLocalAlbumGridItem(
BuildContext context,
_GroupedLocalAlbum album,
@@ -5275,7 +5260,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return;
}
// Share SAF content URIs via native intent
if (safUris.isNotEmpty) {
try {
if (safUris.length == 1) {
@@ -5286,7 +5270,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
} catch (_) {}
}
// Share regular files via SharePlus
if (filesToShare.isNotEmpty) {
await SharePlus.instance.share(ShareParams(files: filesToShare));
}
@@ -6400,7 +6383,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
}
/// Reusable filter button with badge showing active filter count.
Widget _buildFilterButton(
BuildContext context,
List<UnifiedLibraryItem> unifiedItems,
@@ -6495,7 +6477,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
}
// Network URL cover (downloaded items)
if (item.coverUrl != null) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
@@ -6513,7 +6494,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
// Local file cover (from library scan)
if (item.localCoverPath != null && item.localCoverPath!.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
@@ -6530,7 +6510,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
// Placeholder (no cover)
if (size != null) {
return buildPlaceholder();
}
@@ -6540,7 +6519,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
/// Build a unified library item (merged downloaded + local)
Widget _buildUnifiedLibraryItem(
BuildContext context,
UnifiedLibraryItem item,
@@ -6748,7 +6726,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
/// Build unified grid item for grid view mode
Widget _buildUnifiedGridItem(
BuildContext context,
UnifiedLibraryItem item,
@@ -7038,7 +7015,6 @@ class _FilterChip extends StatelessWidget {
}
}
/// Reusable action button for selection mode bottom bar
class _SelectionActionButton extends StatelessWidget {
final IconData icon;
final String label;
@@ -53,10 +53,8 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
if (mounted) {
setState(() {
_androidSdkVersion = sdkVersion;
// SAF doesn't need storage permission on Android 10+
_hasStoragePermission = sdkVersion >= 29 ? true : false;
});
// For older Android, check legacy storage permission
if (sdkVersion < 29) {
final hasPermission = await Permission.storage.isGranted;
if (mounted) {
@@ -65,7 +63,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
}
}
} else if (Platform.isIOS) {
// iOS doesn't need explicit storage permission for app documents
setState(() => _hasStoragePermission = true);
} else {
setState(() => _hasStoragePermission = true);
@@ -74,7 +71,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
Future<bool> _requestStoragePermission() async {
if (!Platform.isAndroid) return true;
// SAF on Android 10+ doesn't need MANAGE_EXTERNAL_STORAGE
if (_androidSdkVersion >= 29) return true;
final status = await Permission.storage.request();
@@ -125,12 +121,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
final granted = await _requestStoragePermission();
if (!granted) return;
}
// Fallback for older devices
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
if (Platform.isIOS) {
// On iOS, create a security-scoped bookmark so we can access
// this folder across app restarts and from the Go backend.
final bookmark = await PlatformBridge.createIosBookmarkFromPath(
result,
);
@@ -139,8 +132,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
.read(settingsProvider.notifier)
.setLocalLibraryPathAndBookmark(result, bookmark);
} else {
// Bookmark creation failed; save path anyway (works for
// app-internal folders like Documents/).
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
}
} else {
@@ -162,13 +153,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
return;
}
// On iOS with a bookmark, try resolving the bookmark first to validate
// access instead of checking the path directly (which may fail outside
// the app sandbox).
if (Platform.isIOS && iosBookmark.isNotEmpty) {
// Bookmark will be resolved inside startScan; skip Directory.exists
// check since security-scoped paths are not accessible without the
// bookmark being activated.
} else if (!libraryPath.startsWith('content://') &&
!await Directory(libraryPath).exists()) {
if (mounted) {
@@ -467,7 +452,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
),
),
// Scan Actions Section
if (settings.localLibraryEnabled) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.libraryActions),
-4
View File
@@ -707,10 +707,6 @@ class _TutorialPage extends StatelessWidget {
final contentGap = (56 * scale) + ((textScale - 1) * 10);
final bottomGap = (32 * scale).clamp(20.0, 32.0);
// Parallax effect logic (simplified for StatelessWidget)
// In a real advanced implementation we'd pass the Controller's listenable
// But for now, let's use entrance animations based on currentIndex == index
final isActive = currentIndex == index;
return SingleChildScrollView(
+3 -9
View File
@@ -21,7 +21,6 @@ class CoverCacheManager {
static CacheManager get instance {
if (!_initialized || _instance == null) {
// Fallback to default cache manager if not initialized
debugPrint('CoverCacheManager: Not initialized, using DefaultCacheManager');
return DefaultCacheManager();
}
@@ -36,13 +35,13 @@ class CoverCacheManager {
try {
final appDir = await getApplicationSupportDirectory();
_cachePath = p.join(appDir.path, 'cover_cache');
await Directory(_cachePath!).create(recursive: true);
debugPrint('CoverCacheManager: Initializing at $_cachePath');
_instance = _createManager(_cachePath!);
_initialized = true;
debugPrint('CoverCacheManager: Initialized successfully');
} catch (e) {
@@ -60,22 +59,18 @@ class CoverCacheManager {
if (instance == null || cachePath == null) return;
// Ask cache manager to clear indexed entries first.
try {
await instance.emptyCache();
} catch (e) {
debugPrint('CoverCacheManager: emptyCache failed, fallback to wipe: $e');
}
// Then wipe the directory to remove orphaned files/metadata leftovers.
await _wipeDirectory(cachePath);
// Clear in-memory image cache so cleared covers are not retained in RAM.
final imageCache = PaintingBinding.instance.imageCache;
imageCache.clear();
imageCache.clearLiveImages();
// Reset manager memory/index state after on-disk wipe.
instance.store.emptyMemoryCache();
_instance = _createManager(cachePath);
_initialized = true;
@@ -124,7 +119,6 @@ class CoverCacheManager {
_cacheKey,
stalePeriod: _maxCacheAge,
maxNrOfCacheObjects: _maxCacheObjects,
// Use path only (not databaseName) to store database in persistent directory
repo: JsonCacheInfoRepository(path: cachePath),
fileSystem: IOFileSystem(cachePath),
fileService: HttpFileService(),
-3
View File
@@ -1350,7 +1350,6 @@ class FFmpegService {
return null;
}
// Lossless targets: dedicated single-pass methods
if (format == 'alac') {
return _convertToAlac(
inputPath: inputPath,
@@ -1369,7 +1368,6 @@ class FFmpegService {
);
}
// Lossy targets: MP3 / Opus
final extension = format == 'opus' ? '.opus' : '.mp3';
final outputPath = _buildOutputPath(inputPath, extension);
@@ -1966,7 +1964,6 @@ class FFmpegService {
}
}
/// Track info for CUE splitting, passed from the CUE parser
class CueSplitTrackInfo {
final int number;
final String title;
-31
View File
@@ -9,10 +9,8 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('HistoryDatabase');
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
/// Cached current iOS container path for path normalization
String? _currentContainerPath;
/// Provides O(1) lookups by spotify_id and isrc with proper indexing
class HistoryDatabase {
static final HistoryDatabase instance = HistoryDatabase._init();
static Database? _database;
@@ -102,21 +100,16 @@ class HistoryDatabase {
}
}
/// Pattern to match iOS container paths
/// Example: /var/mobile/Containers/Data/Application/UUID-HERE/Documents/...
static final _iosContainerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/',
caseSensitive: false,
);
/// Initialize and cache the current iOS container path
Future<void> _initContainerPath() async {
if (!Platform.isIOS || _currentContainerPath != null) return;
try {
final docDir = await getApplicationDocumentsDirectory();
// Extract container path up to and including the UUID folder
// e.g., /var/mobile/Containers/Data/Application/UUID/
final match = _iosContainerPattern.firstMatch(docDir.path);
if (match != null) {
_currentContainerPath = match.group(0);
@@ -127,13 +120,10 @@ class HistoryDatabase {
}
}
/// Normalize iOS file path by replacing old container UUID with current one
/// This fixes the issue where iOS changes container UUID after app updates
String _normalizeIosPath(String? filePath) {
if (filePath == null || filePath.isEmpty) return filePath ?? '';
if (!Platform.isIOS || _currentContainerPath == null) return filePath;
// Check if path contains an iOS container path
if (_iosContainerPattern.hasMatch(filePath)) {
final normalized = filePath.replaceFirst(
_iosContainerPattern,
@@ -148,8 +138,6 @@ class HistoryDatabase {
return filePath;
}
/// Migrate iOS paths in database to use current container UUID
/// This is called once after app update if container changed
Future<bool> migrateIosContainerPaths() async {
if (!Platform.isIOS) return false;
@@ -205,8 +193,6 @@ class HistoryDatabase {
}
}
/// Migrate data from SharedPreferences to SQLite
/// Returns true if migration was performed, false if already migrated
Future<bool> migrateFromSharedPreferences() async {
final prefs = await _prefs;
final migrationKey = 'history_migrated_to_sqlite';
@@ -243,7 +229,6 @@ class HistoryDatabase {
await batch.commit(noResult: true);
// Mark as migrated but keep old data for safety
await prefs.setBool(migrationKey, true);
_log.i('Migration complete: ${jsonList.length} items');
@@ -254,7 +239,6 @@ class HistoryDatabase {
}
}
/// Convert JSON format (camelCase) to DB row (snake_case)
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
return {
'id': json['id'],
@@ -286,8 +270,6 @@ class HistoryDatabase {
};
}
/// Convert DB row (snake_case) to JSON format (camelCase)
/// Also normalizes iOS paths if container UUID changed
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
return {
'id': row['id'],
@@ -342,7 +324,6 @@ class HistoryDatabase {
await batch.commit(noResult: true);
}
/// Get all history items ordered by download date (newest first)
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
final db = await database;
final rows = await db.query(
@@ -366,7 +347,6 @@ class HistoryDatabase {
return _dbRowToJson(rows.first);
}
/// Get item by Spotify ID - O(1) with index
Future<Map<String, dynamic>?> getBySpotifyId(String spotifyId) async {
final db = await database;
final rows = await db.query(
@@ -379,7 +359,6 @@ class HistoryDatabase {
return _dbRowToJson(rows.first);
}
/// Get item by ISRC - O(1) with index
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
final db = await database;
final rows = await db.query(
@@ -392,7 +371,6 @@ class HistoryDatabase {
return _dbRowToJson(rows.first);
}
/// Check if spotify_id exists - O(1) with index
Future<bool> existsBySpotifyId(String spotifyId) async {
final db = await database;
final result = await db.rawQuery(
@@ -402,7 +380,6 @@ class HistoryDatabase {
return result.isNotEmpty;
}
/// Get all spotify_ids as Set for fast in-memory lookup
Future<Set<String>> getAllSpotifyIds() async {
final db = await database;
final rows = await db.rawQuery(
@@ -433,7 +410,6 @@ class HistoryDatabase {
return Sqflite.firstIntValue(result) ?? 0;
}
/// Find existing item by spotify_id or isrc (for deduplication)
Future<Map<String, dynamic>?> findExisting({
String? spotifyId,
String? isrc,
@@ -442,7 +418,6 @@ class HistoryDatabase {
final bySpotify = await getBySpotifyId(spotifyId);
if (bySpotify != null) return bySpotify;
// Check for deezer: prefix matching
if (spotifyId.startsWith('deezer:')) {
final deezerId = spotifyId.substring(7);
final db = await database;
@@ -469,7 +444,6 @@ class HistoryDatabase {
_database = null;
}
/// Update file path for a history entry (e.g. after format conversion)
Future<void> updateFilePath(
String id,
String newFilePath, {
@@ -524,8 +498,6 @@ class HistoryDatabase {
await db.update('history', values, where: 'id = ?', whereArgs: [id]);
}
/// Get all file paths from download history
/// Used to exclude downloaded files from local library scan
Future<Set<String>> getAllFilePaths() async {
final db = await database;
final rows = await db.rawQuery(
@@ -534,8 +506,6 @@ class HistoryDatabase {
return rows.map((r) => r['file_path'] as String).toSet();
}
/// Get all entries with file paths for orphan detection
/// Returns list of (id, file_path, storage_mode, download_tree_uri, saf_relative_dir, saf_file_name)
Future<List<Map<String, dynamic>>> getAllEntriesWithPaths() async {
final db = await database;
final rows = await db.rawQuery('''
@@ -569,7 +539,6 @@ class HistoryDatabase {
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
}
/// Delete multiple entries by IDs
Future<int> deleteByIds(List<String> ids) async {
if (ids.isEmpty) return 0;
-9
View File
@@ -96,7 +96,6 @@ class LocalLibraryItem {
format: json['format'] as String?,
);
/// Create a unique key for matching tracks
String get matchKey =>
'${trackName.toLowerCase()}|${artistName.toLowerCase()}';
String get albumKey =>
@@ -183,13 +182,11 @@ class LibraryDatabase {
}
if (oldVersion < 3) {
// Add file_mod_time column for incremental scanning
await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER');
_log.i('Added file_mod_time column for incremental scanning');
}
if (oldVersion < 4) {
// Add bitrate column for lossy format quality info
await db.execute('ALTER TABLE library ADD COLUMN bitrate INTEGER');
_log.i('Added bitrate column for lossy format quality');
}
@@ -475,8 +472,6 @@ class LibraryDatabase {
_database = null;
}
/// Get all file paths with their modification times for incremental scanning
/// Returns a map of filePath -> fileModTime (unix timestamp in milliseconds)
Future<Map<String, int>> getFileModTimes() async {
final db = await database;
final rows = await db.rawQuery(
@@ -491,8 +486,6 @@ class LibraryDatabase {
return result;
}
/// Export file modification times to a compact line-based snapshot that
/// native code can read without receiving a large method-channel payload.
Future<String> writeFileModTimesSnapshot() async {
final db = await database;
final rows = await db.rawQuery(
@@ -519,7 +512,6 @@ class LibraryDatabase {
return file.path;
}
/// Update file_mod_time for existing rows using file_path as key.
Future<void> updateFileModTimes(Map<String, int> fileModTimes) async {
if (fileModTimes.isEmpty) return;
final db = await database;
@@ -535,7 +527,6 @@ class LibraryDatabase {
await batch.commit(noResult: true);
}
/// Get all file paths in the library (for detecting deleted files)
Future<Set<String>> getAllFilePaths() async {
final db = await database;
final rows = await db.rawQuery('SELECT file_path FROM library');
+15
View File
@@ -422,6 +422,21 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Rewrites ARTIST/ALBUMARTIST Vorbis comments as multiple split entries
/// using the native Go FLAC writer, fixing FFmpeg's tag deduplication.
static Future<Map<String, dynamic>> rewriteSplitArtistTags(
String filePath,
String artist,
String albumArtist,
) async {
final result = await _channel.invokeMethod('rewriteSplitArtistTags', {
'file_path': filePath,
'artist': artist,
'album_artist': albumArtist,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<bool> writeTempToSaf(String tempPath, String safUri) async {
final result = await _channel.invokeMethod('writeTempToSaf', {
'temp_path': tempPath,
-6
View File
@@ -10,7 +10,6 @@ class ShareIntentService {
factory ShareIntentService() => _instance;
ShareIntentService._internal();
// Spotify patterns
static final RegExp _spotifyUriPattern = RegExp(
r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+',
);
@@ -18,7 +17,6 @@ class ShareIntentService {
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
);
// Deezer patterns
static final RegExp _deezerUrlPattern = RegExp(
r'https?://(www\.)?deezer\.com/(track|album|playlist|artist)/\d+(\?[^\s]*)?',
);
@@ -26,17 +24,14 @@ class ShareIntentService {
r'https?://deezer\.page\.link/[a-zA-Z0-9]+',
);
// Tidal patterns
static final RegExp _tidalUrlPattern = RegExp(
r'https?://(listen\.)?tidal\.com/(track|album|playlist|artist)/[a-zA-Z0-9-]+(\?[^\s]*)?',
);
// YouTube Music patterns
static final RegExp _ytMusicUrlPattern = RegExp(
r'https?://music\.youtube\.com/(watch\?v=|playlist\?list=|channel/|browse/)[a-zA-Z0-9_-]+([?&][^\s]*)?',
);
// Standard YouTube patterns (youtu.be short links and www.youtube.com/watch)
static final RegExp _youtubeUrlPattern = RegExp(
r'https?://(youtu\.be/[a-zA-Z0-9_-]+|www\.youtube\.com/watch\?v=[a-zA-Z0-9_-]+)([?&][^\s]*)?',
);
@@ -117,7 +112,6 @@ class ShareIntentService {
final match = pattern.firstMatch(text);
if (match != null) {
final fullUrl = match.group(0)!;
// Keep query params for YouTube URLs (needed for ?v=, ?list=, etc.)
if (pattern == _ytMusicUrlPattern || pattern == _youtubeUrlPattern) {
return fullUrl;
}
-3
View File
@@ -3,7 +3,6 @@ import 'package:flutter/services.dart';
import 'package:spotiflac_android/models/theme_settings.dart';
class AppTheme {
/// Default seed color (Spotify green)
static const Color defaultSeedColor = Color(kDefaultSeedColor);
static ThemeData light({ColorScheme? dynamicScheme, Color? seedColor}) {
@@ -87,12 +86,10 @@ class AppTheme {
fontWeight: FontWeight.w500,
),
systemOverlayStyle: SystemUiOverlayStyle(
// Status bar
statusBarColor: Colors.transparent,
statusBarIconBrightness: scheme.brightness == Brightness.dark
? Brightness.light
: Brightness.dark,
// System navigation bar match the in-app NavigationBar color
systemNavigationBarColor: isAmoled
? Colors.black
: scheme.surfaceContainer,
-21
View File
@@ -11,11 +11,6 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('ClickableMetadata');
/// Navigate to an artist screen by searching Deezer for the artist ID.
///
/// If [artistId] is provided and valid, navigates directly.
/// Otherwise, searches Deezer by [artistName] to resolve the ID first.
/// For extension-based content, pass [extensionId] to use ExtensionArtistScreen.
Future<void> navigateToArtist(
BuildContext context, {
required String artistName,
@@ -95,11 +90,6 @@ Future<void> navigateToArtist(
}
}
/// Navigate to an album screen by searching Deezer for the album ID.
///
/// If [albumId] is provided and valid, navigates directly.
/// Otherwise, searches Deezer by [albumName] (optionally with [artistName]) to resolve the ID.
/// For extension-based content, pass [extensionId] to use ExtensionAlbumScreen.
Future<void> navigateToAlbum(
BuildContext context, {
required String albumName,
@@ -217,9 +207,6 @@ void _pushAlbumScreen(
String? coverUrl,
String? extensionId,
}) {
// Built-in providers (tidal, qobuz, deezer) use AlbumScreen which
// detects the provider from the album ID prefix. Only true JS extensions
// should use ExtensionAlbumScreen.
const builtInProviders = {'tidal', 'qobuz', 'deezer'};
final isExtension =
extensionId != null && !builtInProviders.contains(extensionId);
@@ -297,10 +284,6 @@ void _showUnavailable(BuildContext context, String type) {
).showSnackBar(SnackBar(content: Text('$type information not available')));
}
/// A reusable widget that makes text tappable to navigate to an artist screen.
///
/// Wraps the text in a GestureDetector that, when tapped, looks up the artist
/// via Deezer search and navigates to the ArtistScreen.
class ClickableArtistName extends StatefulWidget {
final String artistName;
final String? artistId;
@@ -526,10 +509,6 @@ bool _canNavigateArtistDirectly({
final RegExp _spotifyArtistIdPattern = RegExp(r'^[A-Za-z0-9]{22}$');
/// A reusable widget that makes text tappable to navigate to an album screen.
///
/// Wraps the text in a GestureDetector that, when tapped, looks up the album
/// via Deezer search and navigates to the AlbumScreen.
class ClickableAlbumName extends StatelessWidget {
final String albumName;
final String? albumId;
-17
View File
@@ -47,26 +47,20 @@ bool isValidIosWritablePath(String path) {
if (path.isEmpty) return false;
if (!path.startsWith('/')) return false;
// Check if it's the container root (without Documents/, tmp/, etc.)
if (_iosContainerRootPattern.hasMatch(path)) {
return false;
}
// Check for iCloud Drive paths
if (path.contains('Mobile Documents') ||
path.contains('CloudDocs') ||
path.contains('com~apple~CloudDocs')) {
return false;
}
// Reject stale paths where an old sandbox container path has been embedded
// inside the current Documents directory.
if (_iosNestedLegacyDocumentsPattern.hasMatch(path)) {
return false;
}
// Ensure path contains a valid subdirectory (Documents, tmp, Library, etc.)
// This handles cases where FilePicker returns container root
final containerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
caseSensitive: false,
@@ -74,7 +68,6 @@ bool isValidIosWritablePath(String path) {
final match = containerPattern.firstMatch(path);
if (match != null) {
final remainingPath = path.substring(match.end);
// Valid paths should have something after the UUID
if (remainingPath.isEmpty || remainingPath == '/') {
return false;
}
@@ -111,13 +104,10 @@ Future<String> validateOrFixIosPath(
candidates.add(trimmed);
}
// Some pickers can return absolute iOS paths without the leading slash.
if (_iosContainerPathWithoutLeadingSlashPattern.hasMatch(trimmed)) {
candidates.add('/$trimmed');
}
// Recover legacy relative iOS path format:
// Data/Application/<UUID>/Documents/<subdir>
final legacyRelativeMatch = _iosLegacyRelativeDocumentsPattern.firstMatch(
trimmed,
);
@@ -127,7 +117,6 @@ Future<String> validateOrFixIosPath(
);
}
// Generic salvage for relative paths containing `Documents/...`.
if (!trimmed.startsWith('/')) {
final documentsMarker = 'Documents/';
final index = trimmed.indexOf(documentsMarker);
@@ -143,7 +132,6 @@ Future<String> validateOrFixIosPath(
}
}
// Fall back to app Documents directory
final musicDir = Directory('${docDir.path}/$subfolder');
if (!await musicDir.exists()) {
await musicDir.create(recursive: true);
@@ -185,7 +173,6 @@ IosPathValidationResult validateIosPath(String path) {
);
}
// Check if it's the container root
if (_iosContainerRootPattern.hasMatch(path)) {
return const IosPathValidationResult(
isValid: false,
@@ -194,7 +181,6 @@ IosPathValidationResult validateIosPath(String path) {
);
}
// Check for iCloud Drive paths
if (path.contains('Mobile Documents') ||
path.contains('CloudDocs') ||
path.contains('com~apple~CloudDocs')) {
@@ -213,7 +199,6 @@ IosPathValidationResult validateIosPath(String path) {
);
}
// Check for container root without subdirectory
final containerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
caseSensitive: false,
@@ -263,7 +248,6 @@ String stripCueTrackSuffix(String path) {
Future<bool> fileExists(String? path) async {
if (path == null || path.isEmpty) return false;
// For CUE virtual paths, check if the base .cue file exists
final realPath = isCueVirtualPath(path) ? stripCueTrackSuffix(path) : path;
if (isContentUri(realPath)) {
return PlatformBridge.safExists(realPath);
@@ -288,7 +272,6 @@ Future<void> deleteFile(String? path) async {
Future<FileAccessStat?> fileStat(String? path) async {
if (path == null || path.isEmpty) return null;
// For CUE virtual paths, stat the base .cue file
final realPath = isCueVirtualPath(path) ? stripCueTrackSuffix(path) : path;
if (isContentUri(realPath)) {
final stat = await PlatformBridge.safStat(realPath);
-5
View File
@@ -25,9 +25,6 @@ const _audioExtensions = <String>[
const _maxPathMatchKeyCacheSize = 6000;
final Map<String, Set<String>> _pathMatchKeyCache = <String, Set<String>>{};
/// Strips a trailing audio extension from [path] if present.
/// Returns the path without extension, or `null` if no known audio extension
/// was found.
String? _stripAudioExtension(String path) {
final lower = path.toLowerCase();
for (final ext in _audioExtensions) {
@@ -115,8 +112,6 @@ Set<String> buildPathMatchKeys(String? filePath) {
addNormalized(cleaned);
// Add extension-stripped variants so that a file converted from one audio
// format to another (e.g. Song.flac Song.opus) still matches.
final extensionStrippedKeys = <String>{};
for (final key in keys) {
final stripped = _stripAudioExtension(key);
-36
View File
@@ -1,9 +1,5 @@
import 'package:flutter/material.dart';
//
// 1. Staggered List Item fade + slide-up entrance with index-based delay
//
/// Wraps a child in a staggered fade-in + slide-up animation.
///
/// [index] controls the stagger delay (each item delayed by [staggerDelay]).
@@ -31,7 +27,6 @@ class StaggeredListItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (!animate || index >= maxAnimatedItems) return child;
// Cap the delay so very long lists don't have absurd wait times.
final cappedIndex = index.clamp(0, maxAnimatedItems - 1);
final delay = staggerDelay * cappedIndex;
final totalDuration = duration + delay;
@@ -42,7 +37,6 @@ class StaggeredListItem extends StatelessWidget {
duration: totalDuration,
curve: Curves.easeOutCubic,
builder: (context, value, child) {
// Compute the effective progress after the stagger delay.
final delayFraction = totalDuration.inMilliseconds > 0
? delay.inMilliseconds / totalDuration.inMilliseconds
: 0.0;
@@ -62,10 +56,6 @@ class StaggeredListItem extends StatelessWidget {
}
}
//
// 2. Animated State Switcher crossfade between loading / content / empty / error
//
/// A convenience wrapper around [AnimatedSwitcher] that crossfades between
/// different widget states (loading, content, empty, error).
///
@@ -94,10 +84,6 @@ class AnimatedStateSwitcher extends StatelessWidget {
}
}
//
// 3. Shared Page Route consistent slide-from-right transition
//
/// Creates a platform-aware material route.
///
/// This intentionally defers route transitions to Flutter's material route and
@@ -107,10 +93,6 @@ Route<T> slidePageRoute<T>({required Widget page}) {
return MaterialPageRoute<T>(builder: (context) => page);
}
//
// 4. Shimmer / Skeleton Loading Widget
//
/// A shimmer effect widget that can wrap skeleton placeholders.
class ShimmerLoading extends StatefulWidget {
final Widget child;
@@ -682,10 +664,6 @@ class HomeSearchSkeleton extends StatelessWidget {
}
}
//
// 5. Animated Selection Checkbox scales in when entering selection mode
//
/// An animated selection indicator that scales in/out and crossfades the
/// checked/unchecked state.
class AnimatedSelectionCheckbox extends StatelessWidget {
@@ -746,10 +724,6 @@ class AnimatedSelectionCheckbox extends StatelessWidget {
}
}
//
// 6. Download Success Animation green flash + checkmark
//
/// A widget that briefly flashes a success color behind its child and shows
/// an animated checkmark when [showSuccess] transitions to true.
class DownloadSuccessOverlay extends StatefulWidget {
@@ -775,8 +749,6 @@ class _DownloadSuccessOverlayState extends State<DownloadSuccessOverlay>
@override
void initState() {
super.initState();
// Initialise from the current widget value so items that are already
// completed when first built do not play the flash animation.
_wasSuccess = widget.showSuccess;
_controller = AnimationController(
vsync: this,
@@ -821,10 +793,6 @@ class _DownloadSuccessOverlayState extends State<DownloadSuccessOverlay>
}
}
//
// 7. Badge Bump Animation scales the badge when count changes
//
/// Wraps a [Badge] child and plays a brief scale-bump whenever [count] changes.
class AnimatedBadge extends StatefulWidget {
final int count;
@@ -877,10 +845,6 @@ class _AnimatedBadgeState extends State<AnimatedBadge>
}
}
//
// 8. Animated Removal Item fade + slide out when removed from a list
//
/// Build a removal animation for [AnimatedList] items.
/// Use as the `builder` callback in [AnimatedListState.removeItem].
Widget buildRemovalAnimation(Widget child, Animation<double> animation) {
-7
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
/// Progress state communicated from caller to dialog via [ValueNotifier].
class _BatchProgress {
final int current;
final String? detail;
@@ -54,11 +53,8 @@ class BatchProgressDialog extends StatefulWidget {
required ValueNotifier<_BatchProgress> progressNotifier,
}) : _progressNotifier = progressNotifier;
// Static bookkeeping
static ValueNotifier<_BatchProgress>? _activeNotifier;
/// Show the dialog. Call [update] to push progress, [dismiss] to close.
static void show({
required BuildContext context,
required String title,
@@ -82,13 +78,10 @@ class BatchProgressDialog extends StatefulWidget {
);
}
/// Update the progress of the currently visible dialog.
/// No [BuildContext] needed communicates via [ValueNotifier].
static void update({required int current, String? detail}) {
_activeNotifier?.value = _BatchProgress(current: current, detail: detail);
}
/// Dismiss the dialog and clean up.
static void dismiss(BuildContext context) {
_activeNotifier = null;
Navigator.of(context, rootNavigator: true).pop();
-4
View File
@@ -1,8 +1,6 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
/// A collapsing header widget
/// Title collapses from large to small when scrolling
class CollapsingHeader extends StatelessWidget {
final String title;
final bool showBackButton;
@@ -100,7 +98,6 @@ class CollapsingHeader extends StatelessWidget {
}
}
/// Section header for settings
class SettingsSection extends StatelessWidget {
final String title;
const SettingsSection({super.key, required this.title});
@@ -120,7 +117,6 @@ class SettingsSection extends StatelessWidget {
}
}
/// Info card widget (like version info)
class InfoCard extends StatelessWidget {
final IconData icon;
final String title;
-8
View File
@@ -1,7 +1,5 @@
import 'package:flutter/material.dart';
/// Custom painted icons for donate platforms
class KofiIcon extends StatelessWidget {
final double size;
final Color color;
@@ -46,7 +44,6 @@ class _KofiPainter extends CustomPainter {
..quadraticBezierTo(s * 0.92, s * 0.68, s * 0.70, s * 0.68);
canvas.drawPath(handlePath, handlePaint);
// Heart on cup
final heartPaint = Paint()
..color = const Color(0xFFFF5E5B)
..style = PaintingStyle.fill;
@@ -62,7 +59,6 @@ class _KofiPainter extends CustomPainter {
..close();
canvas.drawPath(heart, heartPaint);
// Steam lines
final steamPaint = Paint()
..color = color.withValues(alpha: 0.6)
..style = PaintingStyle.stroke
@@ -108,12 +104,9 @@ class _GitHubPainter extends CustomPainter {
..color = color
..style = PaintingStyle.fill;
// GitHub octocat silhouette (simplified mark)
// Based on the GitHub logo path, scaled to fit
final scale = s / 24.0;
final path = Path();
// Outer circle/head shape
path.moveTo(12 * scale, 0.5 * scale);
path.cubicTo(
5.37 * scale, 0.5 * scale,
@@ -135,7 +128,6 @@ class _GitHubPainter extends CustomPainter {
9.01 * scale, 22.01 * scale,
9.01 * scale, 21.01 * scale,
);
// Left arm
path.cubicTo(
5.67 * scale, 21.71 * scale,
4.97 * scale, 19.56 * scale,
-7
View File
@@ -5,7 +5,6 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
/// Built-in service info with quality options
class BuiltInService {
final String id;
final String label;
@@ -22,7 +21,6 @@ class BuiltInService {
});
}
/// Default quality options for each built-in service
const _builtInServices = [
BuiltInService(
id: 'tidal',
@@ -99,7 +97,6 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
ConsumerState<DownloadServicePicker> createState() =>
_DownloadServicePickerState();
/// Show the download service picker as a modal bottom sheet
static void show(
BuildContext context, {
String? trackName,
@@ -135,7 +132,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
@override
void initState() {
super.initState();
// Default to recommended service if available, otherwise use default
final recommended = widget.recommendedService;
if (recommended != null && recommended.isNotEmpty) {
_selectedService = recommended;
@@ -144,7 +140,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
}
}
/// Get quality options for the selected service
List<QualityOption> _getQualityOptions() {
final builtIn = _builtInServices
.where((s) => s.id == _selectedService)
@@ -161,8 +156,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
return ext.qualityOptions;
}
// Extensions without quality options use Tidal's options as default
// since the download will fall back to built-in providers anyway.
return _builtInServices.firstWhere((s) => s.id == 'tidal').qualityOptions;
}
-9
View File
@@ -29,10 +29,6 @@ class ReEnrichFieldSelection {
bool get isAll => fields.length == ReEnrichFields.all.length;
}
/// Shows a bottom sheet that lets the user pick which metadata fields to update
/// during a bulk re-enrich operation.
///
/// Returns `null` when cancelled, or a [ReEnrichFieldSelection] when confirmed.
Future<ReEnrichFieldSelection?> showReEnrichFieldDialog(
BuildContext context, {
required int selectedCount,
@@ -128,7 +124,6 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 4),
child: Text(
@@ -138,7 +133,6 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
),
),
),
// Subtitle
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 4),
child: Text(
@@ -158,7 +152,6 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
),
),
const Divider(height: 1),
// Select All
CheckboxListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
title: Text(
@@ -171,7 +164,6 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
controlAffinity: ListTileControlAffinity.leading,
),
const Divider(height: 1, indent: 16, endIndent: 16),
// Individual fields
for (final field in ReEnrichFields.all)
CheckboxListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
@@ -182,7 +174,6 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
controlAffinity: ListTileControlAffinity.leading,
),
const SizedBox(height: 8),
// Confirm button
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: SizedBox(
+1 -10
View File
@@ -1,7 +1,5 @@
import 'package:flutter/material.dart';
/// A grouped settings card that connects items together like Android Settings
/// Items are connected with no gap between them, only separated when changing groups
class SettingsGroup extends StatelessWidget {
final List<Widget> children;
final EdgeInsetsGeometry? margin;
@@ -17,14 +15,10 @@ class SettingsGroup extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Use a more contrasting color for cards
// In dark mode with dynamic color, surfaceContainerHighest can be too similar to surface
// So we add a slight white overlay to make it more visible
// In light mode with dynamic color, we add a slight black overlay for the same reason
final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
: Color.alphaBlend(Colors.black.withValues(alpha: 0.04), colorScheme.surface);
return Container(
margin: margin ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
@@ -44,7 +38,6 @@ class SettingsGroup extends StatelessWidget {
}
class SettingsItem extends StatelessWidget {
final IconData? icon;
final String title;
final String? subtitle;
@@ -126,7 +119,6 @@ class SettingsItem extends StatelessWidget {
}
class SettingsSwitchItem extends StatelessWidget {
final IconData? icon;
final String title;
final String? subtitle;
@@ -214,7 +206,6 @@ class SettingsSwitchItem extends StatelessWidget {
}
class SettingsSectionHeader extends StatelessWidget {
final String title;
const SettingsSectionHeader({super.key, required this.title});
@@ -74,7 +74,6 @@ class _TrackOptionsSheet extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header with drag handle + track info (matches _TrackInfoHeader)
Column(
children: [
const SizedBox(height: 8),
@@ -163,7 +162,6 @@ class _TrackOptionsSheet extends ConsumerWidget {
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
// Action items (matches _QualityOption style)
_OptionTile(
icon: isLoved ? Icons.favorite : Icons.favorite_border,
iconColor: isLoved ? colorScheme.error : null,
@@ -234,7 +232,6 @@ class _TrackOptionsSheet extends ConsumerWidget {
}
}
/// Styled like _QualityOption in download_service_picker.dart
class _OptionTile extends StatelessWidget {
final IconData icon;
final Color? iconColor;