fix: unify search bar, filter chips, tab navigation, and clean up comments

This commit is contained in:
zarzet
2026-03-25 22:27:22 +07:00
parent 49c2501fbc
commit 66d714d368
12 changed files with 394 additions and 373 deletions
+1 -2
View File
@@ -1181,7 +1181,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
if attempt > 0 {
delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
delay := deezerRetryDelay * time.Duration(1<<(attempt-1))
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
time.Sleep(delay)
}
@@ -1194,7 +1194,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
lastErr = err
errStr := err.Error()
// Check if error is retryable
isRetryable := strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "connection refused") ||
-2
View File
@@ -48,7 +48,6 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
}
// SetSongLinkNetworkOptions is kept for backward compatibility.
// It now applies global network compatibility options for all backend API requests.
func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) {
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
}
@@ -910,7 +909,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
// MP3/Opus: return metadata for Dart-side FFmpeg embedding
resp := map[string]any{
"success": true,
"method": "ffmpeg",
-5
View File
@@ -300,14 +300,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
continue
}
// Check for ISP blocking via HTTP status codes
// Some ISPs return 403 or 451 when blocking content
if resp.StatusCode == 403 || resp.StatusCode == 451 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
bodyStr := strings.ToLower(string(body))
// Check if response looks like ISP blocking page
ispBlockingIndicators := []string{
"blocked", "forbidden", "access denied", "not available in your",
"restricted", "censored", "unavailable for legal", "blocked by",
@@ -518,7 +515,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
return nil
}
// Returns true if ISP blocking was detected
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
ispErr := IsISPBlocking(err, requestURL)
if ispErr != nil {
@@ -553,7 +549,6 @@ func extractDomain(rawURL string) string {
return "unknown"
}
// If ISP blocking is detected, returns a more descriptive error
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
if err == nil {
return nil
-6
View File
@@ -83,7 +83,6 @@ func SetLyricsProviderOrder(providers []string) {
return
}
// Validate provider names
validNames := map[string]bool{
LyricsProviderSpotifyAPI: true,
LyricsProviderLRCLIB: true,
@@ -105,7 +104,6 @@ func SetLyricsProviderOrder(providers []string) {
GoLog("[Lyrics] Provider order set to: %v\n", valid)
}
// GetLyricsProviderOrder returns the current lyrics provider order.
func GetLyricsProviderOrder() []string {
lyricsProvidersMu.RLock()
defer lyricsProvidersMu.RUnlock()
@@ -119,7 +117,6 @@ func GetLyricsProviderOrder() []string {
return result
}
// GetAvailableLyricsProviders returns metadata about all available providers.
func GetAvailableLyricsProviders() []map[string]interface{} {
return []map[string]interface{}{
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
@@ -140,7 +137,6 @@ func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions {
return opts
}
// SetLyricsFetchOptions sets provider-specific lyric fetch behavior.
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
normalized := normalizeLyricsFetchOptions(opts)
@@ -156,7 +152,6 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
)
}
// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior.
func GetLyricsFetchOptions() LyricsFetchOptions {
lyricsFetchOptionsMu.RLock()
defer lyricsFetchOptionsMu.RUnlock()
@@ -667,7 +662,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
// Cascade through all configured built-in providers
for _, providerName := range providerOrder {
GoLog("[Lyrics] Trying provider: %s\n", providerName)
-5
View File
@@ -542,7 +542,6 @@ func searchYouTubeMusicViaExtension(artistName, trackName string) string {
extManager := GetExtensionManager()
searchProviders := extManager.GetSearchProviders()
// Find the ytmusic-spotiflac extension
var ytProvider *ExtensionProviderWrapper
for _, p := range searchProviders {
if p.extension.ID == "ytmusic-spotiflac" {
@@ -569,7 +568,6 @@ func searchYouTubeMusicViaExtension(artistName, trackName string) string {
return ""
}
// Find the first track result (item_type == "track" with a valid video ID)
for _, track := range results {
if track.ItemType != "" && track.ItemType != "track" {
continue
@@ -610,7 +608,6 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
}
}
// Fallback: Try Spotify ID via SongLink
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
songlink := NewSongLinkClient()
@@ -622,7 +619,6 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
}
}
// Fallback: Try Deezer ID via SongLink
if youtubeURL == "" && req.DeezerID != "" {
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
songlink := NewSongLinkClient()
@@ -634,7 +630,6 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
}
}
// Fallback: Try ISRC via SongLink
if youtubeURL == "" && req.ISRC != "" {
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
songlink := NewSongLinkClient()
+1 -1
View File
@@ -10,7 +10,7 @@ class AppInfo {
/// Shows "Internal" in debug builds, actual version in release.
static String get displayVersion => kDebugMode ? 'Internal' : version;
static const String appName = 'SpotiFLAC';
static const String appName = 'SpotiFLAC Mobile';
static const String copyright = '© 2026 SpotiFLAC';
static const String mobileAuthor = 'zarzet';
-3
View File
@@ -192,11 +192,9 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
if (settings.localLibraryPath.isEmpty) return;
if (settings.localLibraryAutoScan == 'off') return;
// Don't start a scan if one is already running.
final libraryState = ref.read(localLibraryProvider);
if (libraryState.isScanning) return;
// Determine cooldown based on auto-scan mode.
final now = DateTime.now();
final prefs = await SharedPreferences.getInstance();
final lastScanned = readLocalLibraryLastScannedAt(prefs);
@@ -220,7 +218,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
}
}
// All checks passed -- start an incremental scan.
final iosBookmark = settings.localLibraryBookmark;
ref
.read(localLibraryProvider.notifier)
+3 -3
View File
@@ -874,8 +874,9 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
await _db.upsert(updated.toJson());
}
/// Remove history entries where the file no longer exists on disk
/// Returns the number of orphaned entries removed
/// Remove history entries where the file no longer exists on disk.
/// Returns the number of orphaned entries removed.
/// Audio file extensions that the app commonly produces or converts between.
static const _audioExtensions = [
'.flac',
@@ -891,7 +892,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
/// different audio extension exists (e.g. the user converted .flac → .opus).
/// Returns the path of the first match found, or `null` if none exist.
Future<String?> _findConvertedSibling(String originalPath) async {
// Strip the current extension to get the base path.
final dotIndex = originalPath.lastIndexOf('.');
if (dotIndex < 0) return null;
final basePath = originalPath.substring(0, dotIndex);
+6 -33
View File
@@ -3124,16 +3124,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
_triggerSearchWithFilter(null);
},
showCheckmark: false,
selectedColor: colorScheme.primaryContainer,
backgroundColor: colorScheme.surfaceContainerHighest,
labelStyle: TextStyle(
color: selectedFilter == null
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: selectedFilter == null
? FontWeight.w600
: FontWeight.normal,
),
),
),
...filters.map((filter) {
@@ -3148,24 +3138,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
_triggerSearchWithFilter(filter.id);
},
showCheckmark: false,
selectedColor: colorScheme.primaryContainer,
backgroundColor: colorScheme.surfaceContainerHighest,
labelStyle: TextStyle(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),
avatar: filter.icon != null
? Icon(
_getFilterIcon(filter.icon!),
size: 18,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
)
? Icon(_getFilterIcon(filter.icon!), size: 18)
: null,
),
);
@@ -3220,15 +3194,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: colorScheme.outline.withValues(alpha: 0.5),
),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: colorScheme.outline.withValues(alpha: 0.5),
),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
@@ -3276,6 +3246,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
onSubmitted: (_) => _onSearchSubmitted(),
onTapOutside: (_) {
FocusScope.of(context).unfocus();
},
);
}
+44 -14
View File
@@ -31,9 +31,11 @@ class MainShell extends ConsumerStatefulWidget {
ConsumerState<MainShell> createState() => _MainShellState();
}
class _MainShellState extends ConsumerState<MainShell> {
class _MainShellState extends ConsumerState<MainShell>
with SingleTickerProviderStateMixin {
int _currentIndex = 0;
late final PageController _pageController;
late final AnimationController _tabJumpTransitionController;
bool _hasCheckedUpdate = false;
StreamSubscription<String>? _shareSubscription;
DateTime? _lastBackPress;
@@ -48,6 +50,11 @@ class _MainShellState extends ConsumerState<MainShell> {
void initState() {
super.initState();
_pageController = PageController(initialPage: _currentIndex);
_tabJumpTransitionController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 180),
value: 1,
);
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showStoreTab: false,
@@ -229,6 +236,7 @@ class _MainShellState extends ConsumerState<MainShell> {
void dispose() {
_shareSubscription?.cancel();
_pageController.dispose();
_tabJumpTransitionController.dispose();
super.dispose();
}
@@ -251,6 +259,8 @@ class _MainShellState extends ConsumerState<MainShell> {
}
if (_currentIndex != index) {
final previousIndex = _currentIndex;
final isNonAdjacentJump = (previousIndex - index).abs() > 1;
final shouldResetHome = index == 0;
HapticFeedback.selectionClick();
setState(() => _currentIndex = index);
@@ -265,11 +275,19 @@ class _MainShellState extends ConsumerState<MainShell> {
if (shouldResetHome) {
_resetHomeToMain();
}
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
);
// Jump directly when skipping intermediate tabs to avoid
// sliding through them. For those jumps, keep a short fade-in
// so the transition still feels intentional.
if (isNonAdjacentJump) {
_pageController.jumpToPage(index);
_tabJumpTransitionController.forward(from: 0);
} else {
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
);
}
}
}
@@ -504,15 +522,27 @@ class _MainShellState extends ConsumerState<MainShell> {
return true;
},
child: Scaffold(
body: PageView.builder(
controller: _pageController,
itemCount: tabs.length,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) => _KeepAliveTabPage(
key: ValueKey('page-$index'),
child: tabs[index],
body: AnimatedBuilder(
animation: _tabJumpTransitionController,
child: PageView.builder(
controller: _pageController,
itemCount: tabs.length,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) => _KeepAliveTabPage(
key: ValueKey('page-$index'),
child: tabs[index],
),
),
builder: (context, child) {
final t = Curves.easeOutCubic.transform(
_tabJumpTransitionController.value,
);
return Opacity(
opacity: t,
child: Transform.scale(scale: 0.985 + (0.015 * t), child: child),
);
},
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex.clamp(0, maxIndex),
+297 -280
View File
@@ -2898,6 +2898,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
tooltip: 'Clear',
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
@@ -2912,26 +2913,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: colorScheme.outlineVariant,
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: colorScheme.outlineVariant,
width: 1.5,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: colorScheme.primary,
width: 2.5,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
vertical: 16,
),
),
onChanged: _onSearchChanged,
@@ -3355,238 +3354,91 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
/// Build a collection item at [index] for the unified "All" tab grid view.
/// Index 0 = Wishlist, 1 = Loved, 2+ = individual playlists.
/// Returns the visible collection entries, hiding Wishlist/Loved when empty.
List<_CollectionEntry> _getVisibleCollectionEntries(
LibraryCollectionsState collectionState,
) {
final entries = <_CollectionEntry>[];
if (collectionState.wishlistCount > 0) {
entries.add(_CollectionEntry.wishlist);
}
if (collectionState.lovedCount > 0) {
entries.add(_CollectionEntry.loved);
}
for (var i = 0; i < collectionState.playlists.length; i++) {
entries.add(_CollectionEntry.playlist(i));
}
return entries;
}
/// Build a collection item for the unified "All" tab grid view.
Widget _buildAllTabGridCollectionItem({
required BuildContext context,
required ColorScheme colorScheme,
required int index,
required _CollectionEntry entry,
required LibraryCollectionsState collectionState,
List<UnifiedLibraryItem> filteredUnifiedItems = const [],
}) {
if (index == 0) {
return _buildCollectionGridItem(
context: context,
colorScheme: colorScheme,
icon: Icons.add_circle_outline,
iconColor: Colors.white,
iconBgColor: const Color(0xFF1DB954),
title: context.l10n.collectionWishlist,
count: collectionState.wishlistCount,
onTap: _openWishlistFolder,
);
} else if (index == 1) {
return _buildCollectionGridItem(
context: context,
colorScheme: colorScheme,
icon: Icons.favorite,
iconColor: Colors.white,
iconBgColor: const Color(0xFF8C67AC),
title: context.l10n.collectionLoved,
count: collectionState.lovedCount,
onTap: _openLovedFolder,
);
} else {
final playlist = collectionState.playlists[index - 2];
final isSelected = _selectedPlaylistIds.contains(playlist.id);
return DragTarget<UnifiedLibraryItem>(
onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode,
onAcceptWithDetails: (details) {
_onTrackDroppedOnPlaylist(
context,
details.data,
playlist.id,
playlist.name,
allItems: filteredUnifiedItems,
);
},
builder: (context, candidateData, rejectedData) {
final isHovering = candidateData.isNotEmpty;
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: isHovering
? BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colorScheme.primary, width: 2),
color: colorScheme.primary.withValues(alpha: 0.1),
)
: null,
child: Stack(
children: [
_buildCollectionGridItem(
context: context,
colorScheme: colorScheme,
coverWidget: _buildPlaylistCover(
context,
playlist,
colorScheme,
),
title: playlist.name,
count: playlist.tracks.length,
onTap: _isPlaylistSelectionMode
? () => _togglePlaylistSelection(playlist.id)
: () => _openPlaylistById(playlist.id),
onLongPress: _isPlaylistSelectionMode
? () => _togglePlaylistSelection(playlist.id)
: () => _enterPlaylistSelectionMode(playlist.id),
),
if (_isPlaylistSelectionMode)
Positioned(
left: 0,
top: 0,
right: 0,
child: IgnorePointer(
child: AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary.withValues(alpha: 0.3)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
),
),
),
),
if (_isPlaylistSelectionMode)
Positioned(
top: 4,
right: 4,
child: IgnorePointer(
child: Container(
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: colorScheme.surface.withValues(alpha: 0.85),
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(
Icons.check,
size: 16,
color: colorScheme.onPrimary,
)
: const SizedBox(width: 16, height: 16),
),
),
),
],
),
);
},
);
}
}
/// Build a collection item at [index] for the unified "All" tab list view.
/// Index 0 = Wishlist, 1 = Loved, 2+ = individual playlists.
Widget _buildAllTabListCollectionItem({
required BuildContext context,
required ColorScheme colorScheme,
required int index,
required LibraryCollectionsState collectionState,
List<UnifiedLibraryItem> filteredUnifiedItems = const [],
}) {
if (index == 0) {
return _buildCollectionListItem(
context: context,
colorScheme: colorScheme,
icon: Icons.add_circle_outline,
iconColor: Colors.white,
iconBgColor: const Color(0xFF1DB954),
title: context.l10n.collectionWishlist,
subtitle:
'${context.l10n.collectionFoldersTitle}${collectionState.wishlistCount} ${collectionState.wishlistCount == 1 ? 'track' : 'tracks'}',
onTap: _openWishlistFolder,
);
} else if (index == 1) {
return _buildCollectionListItem(
context: context,
colorScheme: colorScheme,
icon: Icons.favorite,
iconColor: Colors.white,
iconBgColor: const Color(0xFF8C67AC),
title: context.l10n.collectionLoved,
subtitle:
'${context.l10n.collectionFoldersTitle}${collectionState.lovedCount} ${collectionState.lovedCount == 1 ? 'track' : 'tracks'}',
onTap: _openLovedFolder,
);
} else {
final playlist = collectionState.playlists[index - 2];
final isSelected = _selectedPlaylistIds.contains(playlist.id);
return DragTarget<UnifiedLibraryItem>(
onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode,
onAcceptWithDetails: (details) {
_onTrackDroppedOnPlaylist(
context,
details.data,
playlist.id,
playlist.name,
allItems: filteredUnifiedItems,
);
},
builder: (context, candidateData, rejectedData) {
final isHovering = candidateData.isNotEmpty;
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: isHovering
? BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colorScheme.primary, width: 2),
color: colorScheme.primary.withValues(alpha: 0.1),
)
: null,
child: Row(
children: [
if (_isPlaylistSelectionMode)
GestureDetector(
onTap: () => _togglePlaylistSelection(playlist.id),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: Container(
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(
Icons.check,
size: 18,
color: colorScheme.onPrimary,
)
: const SizedBox(width: 18, height: 18),
),
),
),
Expanded(
child: _buildCollectionListItem(
switch (entry.type) {
case _CollectionEntryType.wishlist:
return _buildCollectionGridItem(
context: context,
colorScheme: colorScheme,
icon: Icons.add_circle_outline,
iconColor: Colors.white,
iconBgColor: const Color(0xFF1DB954),
title: context.l10n.collectionWishlist,
count: collectionState.wishlistCount,
onTap: _openWishlistFolder,
);
case _CollectionEntryType.loved:
return _buildCollectionGridItem(
context: context,
colorScheme: colorScheme,
icon: Icons.favorite,
iconColor: Colors.white,
iconBgColor: const Color(0xFF8C67AC),
title: context.l10n.collectionLoved,
count: collectionState.lovedCount,
onTap: _openLovedFolder,
);
case _CollectionEntryType.playlist:
final playlist = collectionState.playlists[entry.playlistIndex];
final isSelected = _selectedPlaylistIds.contains(playlist.id);
return DragTarget<UnifiedLibraryItem>(
onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode,
onAcceptWithDetails: (details) {
_onTrackDroppedOnPlaylist(
context,
details.data,
playlist.id,
playlist.name,
allItems: filteredUnifiedItems,
);
},
builder: (context, candidateData, rejectedData) {
final isHovering = candidateData.isNotEmpty;
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: isHovering
? BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colorScheme.primary, width: 2),
color: colorScheme.primary.withValues(alpha: 0.1),
)
: null,
child: Stack(
children: [
_buildCollectionGridItem(
context: context,
colorScheme: colorScheme,
coverWidget: _buildPlaylistCover(
context,
playlist,
colorScheme,
56,
),
title: playlist.name,
subtitle:
'${playlist.tracks.length} ${playlist.tracks.length == 1 ? 'track' : 'tracks'}',
count: playlist.tracks.length,
onTap: _isPlaylistSelectionMode
? () => _togglePlaylistSelection(playlist.id)
: () => _openPlaylistById(playlist.id),
@@ -3594,12 +3446,176 @@ class _QueueTabState extends ConsumerState<QueueTab> {
? () => _togglePlaylistSelection(playlist.id)
: () => _enterPlaylistSelectionMode(playlist.id),
),
),
],
),
);
},
);
if (_isPlaylistSelectionMode)
Positioned(
left: 0,
top: 0,
right: 0,
child: IgnorePointer(
child: AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary.withValues(alpha: 0.3)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
),
),
),
),
if (_isPlaylistSelectionMode)
Positioned(
top: 4,
right: 4,
child: IgnorePointer(
child: Container(
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: colorScheme.surface.withValues(alpha: 0.85),
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(
Icons.check,
size: 16,
color: colorScheme.onPrimary,
)
: const SizedBox(width: 16, height: 16),
),
),
),
],
),
);
},
);
}
}
/// Build a collection item for the unified "All" tab list view.
Widget _buildAllTabListCollectionItem({
required BuildContext context,
required ColorScheme colorScheme,
required _CollectionEntry entry,
required LibraryCollectionsState collectionState,
List<UnifiedLibraryItem> filteredUnifiedItems = const [],
}) {
switch (entry.type) {
case _CollectionEntryType.wishlist:
return _buildCollectionListItem(
context: context,
colorScheme: colorScheme,
icon: Icons.add_circle_outline,
iconColor: Colors.white,
iconBgColor: const Color(0xFF1DB954),
title: context.l10n.collectionWishlist,
subtitle:
'${context.l10n.collectionFoldersTitle}${collectionState.wishlistCount} ${collectionState.wishlistCount == 1 ? 'track' : 'tracks'}',
onTap: _openWishlistFolder,
);
case _CollectionEntryType.loved:
return _buildCollectionListItem(
context: context,
colorScheme: colorScheme,
icon: Icons.favorite,
iconColor: Colors.white,
iconBgColor: const Color(0xFF8C67AC),
title: context.l10n.collectionLoved,
subtitle:
'${context.l10n.collectionFoldersTitle}${collectionState.lovedCount} ${collectionState.lovedCount == 1 ? 'track' : 'tracks'}',
onTap: _openLovedFolder,
);
case _CollectionEntryType.playlist:
final playlist = collectionState.playlists[entry.playlistIndex];
final isSelected = _selectedPlaylistIds.contains(playlist.id);
return DragTarget<UnifiedLibraryItem>(
onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode,
onAcceptWithDetails: (details) {
_onTrackDroppedOnPlaylist(
context,
details.data,
playlist.id,
playlist.name,
allItems: filteredUnifiedItems,
);
},
builder: (context, candidateData, rejectedData) {
final isHovering = candidateData.isNotEmpty;
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: isHovering
? BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colorScheme.primary, width: 2),
color: colorScheme.primary.withValues(alpha: 0.1),
)
: null,
child: Row(
children: [
if (_isPlaylistSelectionMode)
GestureDetector(
onTap: () => _togglePlaylistSelection(playlist.id),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: Container(
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(
Icons.check,
size: 18,
color: colorScheme.onPrimary,
)
: const SizedBox(width: 18, height: 18),
),
),
),
Expanded(
child: _buildCollectionListItem(
context: context,
colorScheme: colorScheme,
coverWidget: _buildPlaylistCover(
context,
playlist,
colorScheme,
56,
),
title: playlist.name,
subtitle:
'${playlist.tracks.length} ${playlist.tracks.length == 1 ? 'track' : 'tracks'}',
onTap: _isPlaylistSelectionMode
? () => _togglePlaylistSelection(playlist.id)
: () => _openPlaylistById(playlist.id),
onLongPress: _isPlaylistSelectionMode
? () => _togglePlaylistSelection(playlist.id)
: () => _enterPlaylistSelectionMode(playlist.id),
),
),
],
),
);
},
);
}
}
@@ -3789,13 +3805,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final collectionCount =
2 + collectionState.playlists.length;
final collectionEntries = _getVisibleCollectionEntries(
collectionState,
);
final collectionCount = collectionEntries.length;
if (index < collectionCount) {
return _buildAllTabGridCollectionItem(
context: context,
colorScheme: colorScheme,
index: index,
entry: collectionEntries[index],
collectionState: collectionState,
filteredUnifiedItems: filteredUnifiedItems,
);
@@ -3831,8 +3849,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return const SizedBox.shrink();
},
childCount:
2 +
collectionState.playlists.length +
_getVisibleCollectionEntries(collectionState).length +
filteredUnifiedItems.length,
),
),
@@ -3841,12 +3858,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final collectionCount = 2 + collectionState.playlists.length;
final collectionEntries = _getVisibleCollectionEntries(
collectionState,
);
final collectionCount = collectionEntries.length;
if (index < collectionCount) {
return _buildAllTabListCollectionItem(
context: context,
colorScheme: colorScheme,
index: index,
entry: collectionEntries[index],
collectionState: collectionState,
filteredUnifiedItems: filteredUnifiedItems,
);
@@ -3882,8 +3902,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return const SizedBox.shrink();
},
childCount:
2 +
collectionState.playlists.length +
_getVisibleCollectionEntries(collectionState).length +
filteredUnifiedItems.length,
),
),
@@ -6372,6 +6391,20 @@ class _QueueItemSliverRow extends ConsumerWidget {
}
}
enum _CollectionEntryType { wishlist, loved, playlist }
class _CollectionEntry {
final _CollectionEntryType type;
final int playlistIndex;
const _CollectionEntry._(this.type, [this.playlistIndex = -1]);
static const wishlist = _CollectionEntry._(_CollectionEntryType.wishlist);
static const loved = _CollectionEntry._(_CollectionEntryType.loved);
static _CollectionEntry playlist(int index) =>
_CollectionEntry._(_CollectionEntryType.playlist, index);
}
class _FilterChip extends StatelessWidget {
final String label;
final int count;
@@ -6389,52 +6422,36 @@ class _FilterChip extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
return FilterChip(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary.withValues(alpha: 0.2)
: colorScheme.outline.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
),
child: Text(
count.toString(),
style: TextStyle(
fontSize: 11,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary.withValues(alpha: 0.2)
: colorScheme.outline.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
),
child: Text(
count.toString(),
style: TextStyle(
fontSize: 11,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
],
),
selected: isSelected,
onSelected: (_) => onTap(),
showCheckmark: false,
);
}
}
+42 -19
View File
@@ -58,7 +58,9 @@ class _StoreTabState extends ConsumerState<StoreTab> {
final downloadingId = ref.watch(
storeProvider.select((s) => s.downloadingId),
);
final hasRegistryUrl = ref.watch(storeProvider.select((s) => s.hasRegistryUrl));
final hasRegistryUrl = ref.watch(
storeProvider.select((s) => s.hasRegistryUrl),
);
final registryUrl = ref.watch(storeProvider.select((s) => s.registryUrl));
final filteredExtensions = StoreState(
extensions: extensions,
@@ -139,7 +141,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
prefixIcon: const Icon(Icons.search),
suffixIcon: value.text.isNotEmpty
? IconButton(
tooltip: 'Clear search',
tooltip: 'Clear',
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
@@ -151,23 +153,37 @@ class _StoreTabState extends ConsumerState<StoreTab> {
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide.none,
borderSide: BorderSide(
color: colorScheme.outlineVariant,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: colorScheme.outlineVariant,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
filled: true,
fillColor:
Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest,
fillColor: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
horizontal: 20,
vertical: 16,
),
),
onChanged: (value) {
ref.read(storeProvider.notifier).setSearchQuery(value);
ref
.read(storeProvider.notifier)
.setSearchQuery(value);
},
onTapOutside: (_) {
FocusScope.of(context).unfocus();
},
);
},
@@ -231,7 +247,8 @@ class _StoreTabState extends ConsumerState<StoreTab> {
_CategoryChip(
label: context.l10n.storeFilterIntegration,
icon: Icons.link,
isSelected: selectedCategory == StoreCategory.integration,
isSelected:
selectedCategory == StoreCategory.integration,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.integration),
@@ -309,9 +326,9 @@ class _StoreTabState extends ConsumerState<StoreTab> {
const SizedBox(height: 24),
Text(
context.l10n.storeAddRepoTitle,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
@@ -347,7 +364,11 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
child: Row(
children: [
Icon(Icons.error_outline, size: 20, color: colorScheme.onErrorContainer),
Icon(
Icons.error_outline,
size: 20,
color: colorScheme.onErrorContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
@@ -503,7 +524,9 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
const SizedBox(height: 16),
Text(
hasFilters ? context.l10n.storeEmptyNoResults : context.l10n.storeEmptyNoExtensions,
hasFilters
? context.l10n.storeEmptyNoResults
: context.l10n.storeEmptyNoExtensions,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),