chore: rebuild dev history without streaming-era commits

This commit is contained in:
zarzet
2026-02-27 13:48:44 +07:00
parent c89600591c
commit ab26d84632
111 changed files with 18282 additions and 3459 deletions
+2 -1
View File
@@ -22,7 +22,7 @@ class BuiltInService {
});
}
/// Default quality options for built-in services (Tidal, Qobuz, Amazon, YouTube)
/// Default quality options for built-in services
/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads
const _builtInServices = [
BuiltInService(
@@ -129,6 +129,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
File diff suppressed because it is too large Load Diff
+170 -213
View File
@@ -13,134 +13,9 @@ Future<void> showAddTrackToPlaylistSheet(
WidgetRef ref,
Track track,
) async {
final notifier = ref.read(libraryCollectionsProvider.notifier);
final state = ref.read(libraryCollectionsProvider);
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('${track.name}${track.artistName}'),
),
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 added = await notifier.addTrackToPlaylist(
playlistId,
track,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
added
? 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];
final alreadyInPlaylist = playlist.containsTrack(track);
return ListTile(
leading: _PlaylistPickerThumbnail(
playlist: playlist,
isSelected: alreadyInPlaylist,
),
title: Text(playlist.name),
subtitle: Text(
context.l10n.collectionPlaylistTracks(
playlist.tracks.length,
),
),
enabled: !alreadyInPlaylist,
onTap: !alreadyInPlaylist
? () async {
final added = await notifier.addTrackToPlaylist(
playlist.id,
track,
);
if (!context.mounted) return;
Navigator.of(sheetContext).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
added
? context.l10n
.collectionAddedToPlaylist(
playlist.name,
)
: context.l10n
.collectionAlreadyInPlaylist(
playlist.name,
),
),
),
);
}
: null,
);
},
),
),
const SizedBox(height: 8),
],
),
);
},
);
if (!context.mounted) return;
final afterState = ref.read(libraryCollectionsProvider);
if (afterState.playlists.length != state.playlists.length) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.collectionPlaylistCreated)),
);
}
return showAddTracksToPlaylistSheet(context, ref, [track]);
}
/// Batch version: add multiple tracks to a chosen playlist.
Future<void> showAddTracksToPlaylistSheet(
BuildContext context,
WidgetRef ref,
@@ -148,79 +23,157 @@ Future<void> showAddTracksToPlaylistSheet(
) async {
if (tracks.isEmpty) return;
final notifier = ref.read(libraryCollectionsProvider.notifier);
if (!context.mounted) return;
await showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
showDragHandle: true,
isScrollControlled: true,
builder: (sheetContext) {
final playlists = ref.watch(
libraryCollectionsProvider.select((state) => state.playlists),
return _PlaylistPickerSheetContent(tracks: tracks);
},
);
}
class _PlaylistPickerSheetContent extends ConsumerStatefulWidget {
final List<Track> tracks;
const _PlaylistPickerSheetContent({required this.tracks});
@override
ConsumerState<_PlaylistPickerSheetContent> createState() =>
_PlaylistPickerSheetContentState();
}
class _PlaylistPickerSheetContentState
extends ConsumerState<_PlaylistPickerSheetContent> {
final Set<String> _selectedPlaylistIds = {};
final Set<String> _initialDisabledIds = {};
bool _initialized = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialized) {
final playlists = ref.read(libraryCollectionsProvider).playlists;
for (final playlist in playlists) {
final alreadyInPlaylist =
widget.tracks.every((t) => playlist.containsTrack(t));
if (alreadyInPlaylist) {
_initialDisabledIds.add(playlist.id);
_selectedPlaylistIds.add(playlist.id);
}
}
_initialized = true;
}
}
void _handleDone() async {
final notifier = ref.read(libraryCollectionsProvider.notifier);
final idsToAdd = _selectedPlaylistIds.difference(_initialDisabledIds);
final addedNames = <String>[];
for (final playlistId in idsToAdd) {
final playlist =
ref.read(libraryCollectionsProvider).playlistById(playlistId);
if (playlist != null) {
addedNames.add(playlist.name);
}
await notifier.addTracksToPlaylist(playlistId, widget.tracks);
}
if (!mounted) return;
Navigator.of(context).pop();
if (addedNames.isNotEmpty) {
final name =
addedNames.length == 1 ? addedNames.first : addedNames.join(', ');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.collectionAddedToPlaylist(name)),
),
);
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,
),
}
}
@override
Widget build(BuildContext context) {
final playlists = ref.watch(
libraryCollectionsProvider.select((state) => state.playlists),
);
final notifier = ref.read(libraryCollectionsProvider.notifier);
final String subtitle;
if (widget.tracks.length == 1) {
final track = widget.tracks.first;
subtitle = '${track.name}${track.artistName}';
} else {
subtitle =
'${widget.tracks.length} ${widget.tracks.length == 1 ? 'track' : 'tracks'}';
}
final idsToAdd = _selectedPlaylistIds.difference(_initialDisabledIds);
final hasNewSelections = idsToAdd.isNotEmpty;
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.playlist_add),
title: Text(context.l10n.collectionAddToPlaylist),
subtitle: Text(subtitle),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.add_circle_outline),
title: Text(context.l10n.collectionCreatePlaylist),
onTap: () async {
final name = await _promptPlaylistName(context);
if (name == null || name.trim().isEmpty || !context.mounted) {
return;
}
final playlistId = await notifier.createPlaylist(name.trim());
await notifier.addTracksToPlaylist(playlistId, widget.tracks);
setState(() {
_initialDisabledIds.add(playlistId);
_selectedPlaylistIds.add(playlistId);
});
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.collectionAddedToPlaylist(name.trim())),
),
)
else
ConstrainedBox(
);
},
),
if (playlists.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Text(
context.l10n.collectionNoPlaylistsYet,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
)
else
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 320),
child: ListView.builder(
shrinkWrap: true,
itemCount: playlists.length,
itemBuilder: (context, index) {
final playlist = playlists[index];
final isAlreadyIn = _initialDisabledIds.contains(playlist.id);
final isSelected = _selectedPlaylistIds.contains(playlist.id);
return ListTile(
leading: _PlaylistPickerThumbnail(
playlist: playlist,
isSelected: false,
isSelected: isSelected,
),
title: Text(playlist.name),
subtitle: Text(
@@ -228,37 +181,44 @@ Future<void> showAddTracksToPlaylistSheet(
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,
),
),
),
);
},
enabled: !isAlreadyIn,
onTap: !isAlreadyIn
? () {
setState(() {
if (isSelected) {
_selectedPlaylistIds.remove(playlist.id);
} else {
_selectedPlaylistIds.add(playlist.id);
}
});
}
: null,
);
},
),
),
const SizedBox(height: 8),
],
),
);
},
);
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () {
if (hasNewSelections) {
_handleDone();
} else {
Navigator.of(context).pop();
}
},
child: Text(context.l10n.dialogDone),
),
),
),
],
),
);
}
}
Future<String?> _promptPlaylistName(BuildContext context) async {
@@ -352,10 +312,7 @@ class _PlaylistPickerThumbnail extends StatelessWidget {
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
border: Border.all(
color: colorScheme.primary,
width: 1.5,
),
border: Border.all(color: colorScheme.primary, width: 1.5),
),
child: Icon(
Icons.check,
+226 -158
View File
@@ -3,17 +3,36 @@ 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/download_queue_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
class TrackCollectionQuickActions extends ConsumerWidget {
final Track track;
const TrackCollectionQuickActions({
super.key,
required this.track,
});
const TrackCollectionQuickActions({super.key, required this.track});
static void showTrackOptionsSheet(
BuildContext context,
WidgetRef ref,
Track track,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (sheetContext) => _TrackOptionsSheet(track: track),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -25,24 +44,11 @@ class TrackCollectionQuickActions extends ConsumerWidget {
color: colorScheme.onSurfaceVariant,
size: 20,
),
onPressed: () => _showTrackOptionsSheet(context, ref),
onPressed: () => showTrackOptionsSheet(context, ref, track),
padding: const EdgeInsets.only(left: 12),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
);
}
void _showTrackOptionsSheet(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (sheetContext) => _TrackOptionsSheet(track: track),
);
}
}
class _TrackOptionsSheet extends ConsumerWidget {
@@ -53,6 +59,9 @@ class _TrackOptionsSheet extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.watch(settingsProvider);
final rootContext = Navigator.of(context, rootNavigator: true).context;
final container = ProviderScope.containerOf(rootContext, listen: false);
final isLoved = ref.watch(
libraryCollectionsProvider.select((state) => state.isLoved(track)),
@@ -62,155 +71,214 @@ class _TrackOptionsSheet extends ConsumerWidget {
);
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header with drag handle + track info (matches _TrackInfoHeader)
Column(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.82,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: track.coverUrl != null && track.coverUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
memCacheWidth: 112,
cacheManager: CoverCacheManager.instance,
errorWidget: (context, url, error) => Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
)
: Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
// Header with drag handle + track info (matches _TrackInfoHeader)
Column(
children: [
const SizedBox(height: 8),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(2),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
track.name,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
track.artistName,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child:
track.coverUrl != null &&
track.coverUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
memCacheWidth: 112,
cacheManager: CoverCacheManager.instance,
errorWidget: (context, url, error) =>
Container(
width: 56,
height: 56,
color:
colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
)
: Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
track.name,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
ClickableArtistName(
artistName: track.artistName,
artistId: track.artistId,
coverUrl: track.coverUrl,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
],
),
],
),
),
],
),
Divider(
height: 1,
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
// Action items (matches _QualityOption style)
_OptionTile(
icon: Icons.download_rounded,
title: context.l10n.downloadTitle,
onTap: () async {
Navigator.pop(context);
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
rootContext,
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
onSelect: (quality, service) {
container
.read(downloadQueueProvider.notifier)
.addToQueue(
track,
service,
qualityOverride: quality,
);
ScaffoldMessenger.of(rootContext).showSnackBar(
SnackBar(
content: Text(
rootContext.l10n.snackbarAddedToQueue(track.name),
),
),
);
},
);
} else {
container
.read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService);
if (!rootContext.mounted) {
return;
}
ScaffoldMessenger.of(rootContext).showSnackBar(
SnackBar(
content: Text(
rootContext.l10n.snackbarAddedToQueue(track.name),
),
),
);
}
},
),
_OptionTile(
icon: isLoved ? Icons.favorite : Icons.favorite_border,
iconColor: isLoved ? colorScheme.error : null,
title: isLoved
? context.l10n.trackOptionRemoveFromLoved
: context.l10n.trackOptionAddToLoved,
onTap: () async {
Navigator.pop(context);
final added = await ref
.read(libraryCollectionsProvider.notifier)
.toggleLoved(track);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
added
? context.l10n.collectionAddedToLoved(track.name)
: context.l10n.collectionRemovedFromLoved(
track.name,
),
),
),
],
),
);
},
),
_OptionTile(
icon: isInWishlist
? Icons.playlist_add_check_circle
: Icons.add_circle_outline,
iconColor: isInWishlist ? colorScheme.primary : null,
title: isInWishlist
? context.l10n.trackOptionRemoveFromWishlist
: context.l10n.trackOptionAddToWishlist,
onTap: () async {
Navigator.pop(context);
final added = await ref
.read(libraryCollectionsProvider.notifier)
.toggleWishlist(track);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
added
? context.l10n.collectionAddedToWishlist(track.name)
: context.l10n.collectionRemovedFromWishlist(
track.name,
),
),
),
);
},
),
_OptionTile(
icon: Icons.playlist_add,
title: context.l10n.collectionAddToPlaylist,
onTap: () {
Navigator.pop(context);
showAddTrackToPlaylistSheet(context, ref, track);
},
),
const SizedBox(height: 16),
],
),
Divider(
height: 1,
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,
title: isLoved
? context.l10n.trackOptionRemoveFromLoved
: context.l10n.trackOptionAddToLoved,
onTap: () async {
Navigator.pop(context);
final added = await ref
.read(libraryCollectionsProvider.notifier)
.toggleLoved(track);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
added
? context.l10n.collectionAddedToLoved(track.name)
: context.l10n.collectionRemovedFromLoved(track.name),
),
),
);
},
),
_OptionTile(
icon: isInWishlist
? Icons.playlist_add_check_circle
: Icons.add_circle_outline,
iconColor: isInWishlist ? colorScheme.primary : null,
title: isInWishlist
? context.l10n.trackOptionRemoveFromWishlist
: context.l10n.trackOptionAddToWishlist,
onTap: () async {
Navigator.pop(context);
final added = await ref
.read(libraryCollectionsProvider.notifier)
.toggleWishlist(track);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
added
? context.l10n.collectionAddedToWishlist(track.name)
: context.l10n.collectionRemovedFromWishlist(
track.name),
),
),
);
},
),
_OptionTile(
icon: Icons.playlist_add,
title: context.l10n.collectionAddToPlaylist,
onTap: () {
Navigator.pop(context);
showAddTrackToPlaylistSheet(context, ref, track);
},
),
const SizedBox(height: 16),
],
),
),
);
}