mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-28 12:22:12 +02:00
feat: add multi-select to library folders, batch playlist picker, and Go backend FD safety
- Add multi-select support to library_tracks_folder_screen (wishlist, loved, playlist) with long-press to enter selection mode, animated bottom bar with batch remove/download/add-to-playlist actions, and PopScope exit handling - Create batch showAddTracksToPlaylistSheet in playlist_picker_sheet with playlist thumbnail widget and cover image support - Add playlist grid selection tint overlay in queue_tab - Optimize collection lookups with pre-built _allPlaylistTrackKeys index and isTrackInAnyPlaylist/hasPlaylistTracks accessors - Eagerly initialize localLibraryProvider and libraryCollectionsProvider - Enable SQLite WAL mode and PRAGMA synchronous=NORMAL across all databases - Go backend: duplicate SAF output FDs before provider attempts to prevent fdsan abort on fallback retries; close detached FDs after download completes - Go backend: rewrite compatibilityTransport to try HTTPS first and only fallback to HTTP on transport-level failures, preventing redirect loops - Go backend: enforce HTTPS-only for extension sandbox HTTP clients
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
|
||||
Future<void> showAddTrackToPlaylistSheet(
|
||||
BuildContext context,
|
||||
@@ -79,10 +83,9 @@ Future<void> showAddTrackToPlaylistSheet(
|
||||
final playlist = playlists[index];
|
||||
final alreadyInPlaylist = playlist.containsTrack(track);
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
alreadyInPlaylist
|
||||
? Icons.playlist_add_check
|
||||
: Icons.queue_music,
|
||||
leading: _PlaylistPickerThumbnail(
|
||||
playlist: playlist,
|
||||
isSelected: alreadyInPlaylist,
|
||||
),
|
||||
title: Text(playlist.name),
|
||||
subtitle: Text(
|
||||
@@ -137,6 +140,127 @@ Future<void> showAddTrackToPlaylistSheet(
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch version: add multiple tracks to a chosen playlist.
|
||||
Future<void> showAddTracksToPlaylistSheet(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
List<Track> tracks,
|
||||
) async {
|
||||
if (tracks.isEmpty) return;
|
||||
|
||||
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) {
|
||||
final playlists = ref.watch(
|
||||
libraryCollectionsProvider.select((state) => state.playlists),
|
||||
);
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.playlist_add),
|
||||
title: Text(sheetContext.l10n.collectionAddToPlaylist),
|
||||
subtitle: Text(
|
||||
'${tracks.length} ${tracks.length == 1 ? 'track' : 'tracks'}',
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_circle_outline),
|
||||
title: Text(sheetContext.l10n.collectionCreatePlaylist),
|
||||
onTap: () async {
|
||||
Navigator.of(sheetContext).pop();
|
||||
final name = await _promptPlaylistName(context);
|
||||
if (name == null || name.trim().isEmpty || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
final playlistId = await notifier.createPlaylist(name.trim());
|
||||
final result = await notifier.addTracksToPlaylist(
|
||||
playlistId,
|
||||
tracks,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
result.addedCount > 0
|
||||
? context.l10n.collectionAddedToPlaylist(name.trim())
|
||||
: context.l10n.collectionAlreadyInPlaylist(
|
||||
name.trim(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (playlists.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: Text(
|
||||
sheetContext.l10n.collectionNoPlaylistsYet,
|
||||
style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(sheetContext).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 320),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: playlists.length,
|
||||
itemBuilder: (context, index) {
|
||||
final playlist = playlists[index];
|
||||
return ListTile(
|
||||
leading: _PlaylistPickerThumbnail(
|
||||
playlist: playlist,
|
||||
isSelected: false,
|
||||
),
|
||||
title: Text(playlist.name),
|
||||
subtitle: Text(
|
||||
context.l10n.collectionPlaylistTracks(
|
||||
playlist.tracks.length,
|
||||
),
|
||||
),
|
||||
onTap: () async {
|
||||
final result = await notifier.addTracksToPlaylist(
|
||||
playlist.id,
|
||||
tracks,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(sheetContext).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
result.addedCount > 0
|
||||
? context.l10n.collectionAddedToPlaylist(
|
||||
playlist.name,
|
||||
)
|
||||
: context.l10n.collectionAlreadyInPlaylist(
|
||||
playlist.name,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _promptPlaylistName(BuildContext context) async {
|
||||
final controller = TextEditingController();
|
||||
final formKey = GlobalKey<FormState>();
|
||||
@@ -187,3 +311,125 @@ Future<String?> _promptPlaylistName(BuildContext context) async {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
class _PlaylistPickerThumbnail extends StatelessWidget {
|
||||
final UserPlaylistCollection playlist;
|
||||
final bool isSelected;
|
||||
|
||||
const _PlaylistPickerThumbnail({
|
||||
required this.playlist,
|
||||
required this.isSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
const double size = 48;
|
||||
final borderRadius = BorderRadius.circular(8);
|
||||
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: _buildCoverImage(colorScheme, size),
|
||||
),
|
||||
if (isSelected) ...[
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary.withValues(alpha: 0.3),
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 2,
|
||||
top: 2,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: colorScheme.primary,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
color: colorScheme.onPrimary,
|
||||
size: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCoverImage(ColorScheme colorScheme, double size) {
|
||||
final customCoverPath = playlist.coverImagePath;
|
||||
if (customCoverPath != null && customCoverPath.isNotEmpty) {
|
||||
return Image.file(
|
||||
File(customCoverPath),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _iconFallback(colorScheme, size),
|
||||
);
|
||||
}
|
||||
|
||||
String? firstCoverUrl;
|
||||
for (final entry in playlist.tracks) {
|
||||
final coverUrl = entry.track.coverUrl;
|
||||
if (coverUrl != null && coverUrl.isNotEmpty) {
|
||||
firstCoverUrl = coverUrl;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstCoverUrl != null) {
|
||||
final isLocalPath =
|
||||
!firstCoverUrl.startsWith('http://') &&
|
||||
!firstCoverUrl.startsWith('https://');
|
||||
|
||||
if (isLocalPath) {
|
||||
return Image.file(
|
||||
File(firstCoverUrl),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _iconFallback(colorScheme, size),
|
||||
);
|
||||
}
|
||||
|
||||
return CachedNetworkImage(
|
||||
imageUrl: firstCoverUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (size * 2).toInt(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) => _iconFallback(colorScheme, size),
|
||||
errorWidget: (_, _, _) => _iconFallback(colorScheme, size),
|
||||
);
|
||||
}
|
||||
|
||||
return _iconFallback(colorScheme, size);
|
||||
}
|
||||
|
||||
Widget _iconFallback(ColorScheme colorScheme, double size) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.queue_music, color: colorScheme.onSurfaceVariant),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user