mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-25 13:30:55 +02:00
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:
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
Reference in New Issue
Block a user