mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-13 03:07:20 +02:00
refactor(screens): migrate remaining screens to shared header and selection components
- album/playlist/library-folder/track-metadata adopt CollapsingHeaderScrollMixin; artist/library-folder adopt SelectionModeMixin - HeaderCircleButton and HeaderMetaRow replace per-screen copies in album and playlist headers; dead _tallHeader flag removed - SelectionBottomBar gains optional subtitle labels and backs the playlist selection bar in queue tab
This commit is contained in:
1 parent
44de61a06a
commit
62015a4e2d
8 files changed
+259
-559
No files matched your search
+30
-132
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
|
|||||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||||
import 'package:spotiflac_android/utils/cover_art_utils.dart';
|
import 'package:spotiflac_android/utils/cover_art_utils.dart';
|
||||||
|
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
|
||||||
import 'package:spotiflac_android/widgets/error_card.dart';
|
import 'package:spotiflac_android/widgets/error_card.dart';
|
||||||
import 'package:spotiflac_android/widgets/album_detail_header.dart';
|
import 'package:spotiflac_android/widgets/album_detail_header.dart';
|
||||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||||
@@ -64,20 +65,17 @@ class AlbumScreen extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<AlbumScreen> createState() => _AlbumScreenState();
|
ConsumerState<AlbumScreen> createState() => _AlbumScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
class _AlbumScreenState extends ConsumerState<AlbumScreen>
|
||||||
|
with CollapsingHeaderScrollMixin<AlbumScreen> {
|
||||||
List<Track>? _tracks;
|
List<Track>? _tracks;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
bool _showTitleInAppBar = false;
|
|
||||||
String? _artistId;
|
String? _artistId;
|
||||||
String? _albumType;
|
String? _albumType;
|
||||||
int? _albumTotalTracks;
|
int? _albumTotalTracks;
|
||||||
String? _headerVideoUrl;
|
String? _headerVideoUrl;
|
||||||
String? _headerImageUrl;
|
String? _headerImageUrl;
|
||||||
List<String> _audioTraits = const [];
|
List<String> _audioTraits = const [];
|
||||||
bool _tallHeader = false;
|
|
||||||
final ScrollController _scrollController = ScrollController();
|
|
||||||
|
|
||||||
|
|
||||||
String _effectiveMetadataProviderIdFromAlbumId() {
|
String _effectiveMetadataProviderIdFromAlbumId() {
|
||||||
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
||||||
@@ -89,13 +87,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
_scrollController.addListener(_onScroll);
|
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final providerId = _effectiveMetadataProviderIdFromAlbumId();
|
final providerId = _effectiveMetadataProviderIdFromAlbumId();
|
||||||
ref
|
ref
|
||||||
@@ -129,30 +124,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_scrollController.removeListener(_onScroll);
|
|
||||||
_scrollController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onScroll() {
|
|
||||||
final expandedHeight = _calculateExpandedHeight(context, tall: _tallHeader);
|
|
||||||
final shouldShow =
|
|
||||||
_scrollController.offset > (expandedHeight - kToolbarHeight - 20);
|
|
||||||
if (shouldShow != _showTitleInAppBar) {
|
|
||||||
setState(() => _showTitleInAppBar = shouldShow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
double _calculateExpandedHeight(BuildContext context, {bool tall = false}) {
|
|
||||||
final mediaSize = MediaQuery.sizeOf(context);
|
|
||||||
if (tall) {
|
|
||||||
return (mediaSize.height * 0.68).clamp(440.0, 660.0);
|
|
||||||
}
|
|
||||||
return (mediaSize.height * 0.6).clamp(400.0, 580.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _fetchTracks() async {
|
Future<void> _fetchTracks() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
@@ -258,25 +229,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
return stripPrefixedResourceId(widget.albumId);
|
return stripPrefixedResourceId(widget.albumId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _metaInlineItem(IconData? icon, String label) {
|
|
||||||
const textStyle = TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
);
|
|
||||||
if (icon == null) {
|
|
||||||
return Text(label, style: textStyle);
|
|
||||||
}
|
|
||||||
return Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: 15, color: Colors.white),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(label, style: textStyle),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Widget> _audioTraitInline() {
|
List<Widget> _audioTraitInline() {
|
||||||
final traits = _audioTraits
|
final traits = _audioTraits
|
||||||
.map((t) => t.toLowerCase().trim())
|
.map((t) => t.toLowerCase().trim())
|
||||||
@@ -288,15 +240,15 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
|
|
||||||
final items = <Widget>[];
|
final items = <Widget>[];
|
||||||
if (has(['atmos', 'dolby_atmos', 'dolby-atmos'])) {
|
if (has(['atmos', 'dolby_atmos', 'dolby-atmos'])) {
|
||||||
items.add(_metaInlineItem(Icons.surround_sound, 'Dolby Atmos'));
|
items.add(HeaderMetaItem('Dolby Atmos', icon: Icons.surround_sound));
|
||||||
} else if (has(['spatial'])) {
|
} else if (has(['spatial'])) {
|
||||||
items.add(_metaInlineItem(Icons.surround_sound, 'Spatial Audio'));
|
items.add(HeaderMetaItem('Spatial Audio', icon: Icons.surround_sound));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (has(['hi-res-lossless', 'hi_res_lossless', 'hires-lossless'])) {
|
if (has(['hi-res-lossless', 'hi_res_lossless', 'hires-lossless'])) {
|
||||||
items.add(_metaInlineItem(Icons.graphic_eq, 'Hi-Res Lossless'));
|
items.add(HeaderMetaItem('Hi-Res Lossless', icon: Icons.graphic_eq));
|
||||||
} else if (has(['lossless'])) {
|
} else if (has(['lossless'])) {
|
||||||
items.add(_metaInlineItem(Icons.graphic_eq, 'Lossless'));
|
items.add(HeaderMetaItem('Lossless', icon: Icons.graphic_eq));
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
@@ -305,38 +257,13 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
Widget _buildHeaderMeta(BuildContext context, String? releaseDate) {
|
Widget _buildHeaderMeta(BuildContext context, String? releaseDate) {
|
||||||
final items = <Widget>[];
|
final items = <Widget>[];
|
||||||
|
|
||||||
void add(Widget widget) {
|
|
||||||
if (items.isNotEmpty) {
|
|
||||||
items.add(
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
|
||||||
child: Text(
|
|
||||||
'•',
|
|
||||||
style: TextStyle(color: Colors.white70, fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
items.add(widget);
|
|
||||||
}
|
|
||||||
|
|
||||||
final year = _releaseYear(releaseDate);
|
final year = _releaseYear(releaseDate);
|
||||||
if (year != null) {
|
if (year != null) items.add(HeaderMetaItem(year));
|
||||||
add(_metaInlineItem(null, year));
|
items.addAll(_audioTraitInline());
|
||||||
}
|
|
||||||
for (final trait in _audioTraitInline()) {
|
|
||||||
add(trait);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ConstrainedBox(
|
return ConstrainedBox(
|
||||||
constraints: const BoxConstraints(minHeight: 20),
|
constraints: const BoxConstraints(minHeight: 20),
|
||||||
child: Wrap(
|
child: HeaderMetaRow(items: items),
|
||||||
alignment: WrapAlignment.center,
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
spacing: 0,
|
|
||||||
runSpacing: 4,
|
|
||||||
children: items,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +324,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: pageBackgroundColor,
|
backgroundColor: pageBackgroundColor,
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
_buildAppBar(context, colorScheme, pageBackgroundColor),
|
_buildAppBar(context, colorScheme, pageBackgroundColor),
|
||||||
if (_isLoading)
|
if (_isLoading)
|
||||||
@@ -444,8 +371,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
Uri.tryParse(motionUrl)?.hasAuthority == true;
|
Uri.tryParse(motionUrl)?.hasAuthority == true;
|
||||||
final coverThumbUrl = widget.coverUrl ?? _headerImageUrl;
|
final coverThumbUrl = widget.coverUrl ?? _headerImageUrl;
|
||||||
final showSquareCover = !hasMotion;
|
final showSquareCover = !hasMotion;
|
||||||
_tallHeader = false;
|
final expandedHeight = calculateExpandedHeight(context);
|
||||||
final expandedHeight = _calculateExpandedHeight(context);
|
|
||||||
final cacheWidth = coverCacheWidthForViewport(context);
|
final cacheWidth = coverCacheWidthForViewport(context);
|
||||||
final headerBgUrl =
|
final headerBgUrl =
|
||||||
_headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl;
|
_headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl;
|
||||||
@@ -470,7 +396,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
return AlbumDetailHeader(
|
return AlbumDetailHeader(
|
||||||
title: widget.albumName,
|
title: widget.albumName,
|
||||||
expandedHeight: expandedHeight,
|
expandedHeight: expandedHeight,
|
||||||
showTitleInAppBar: _showTitleInAppBar,
|
showTitleInAppBar: showTitleInAppBar,
|
||||||
backgroundColor: pageBackgroundColor,
|
backgroundColor: pageBackgroundColor,
|
||||||
background: hasMotion
|
background: hasMotion
|
||||||
? MotionHeaderBanner(videoUrl: motionUrl, fallback: headerBgImage)
|
? MotionHeaderBanner(videoUrl: motionUrl, fallback: headerBgImage)
|
||||||
@@ -656,54 +582,25 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
tracks.isNotEmpty &&
|
tracks.isNotEmpty &&
|
||||||
tracks.every((t) => collectionsState.isLoved(t));
|
tracks.every((t) => collectionsState.isLoved(t));
|
||||||
|
|
||||||
return Container(
|
return HeaderCircleButton(
|
||||||
width: 48,
|
icon: allLoved ? Icons.favorite : Icons.favorite_border,
|
||||||
height: 48,
|
iconColor: allLoved ? Colors.redAccent : Colors.white,
|
||||||
decoration: BoxDecoration(
|
tooltip: allLoved
|
||||||
shape: BoxShape.circle,
|
? context.l10n.trackOptionRemoveFromLoved
|
||||||
color: Colors.white.withValues(alpha: 0.15),
|
: context.l10n.tooltipLoveAll,
|
||||||
border: Border.all(
|
onPressed: tracks == null || tracks.isEmpty
|
||||||
color: Colors.white.withValues(alpha: 0.3),
|
? null
|
||||||
width: 1,
|
: () => _loveAll(tracks),
|
||||||
),
|
|
||||||
),
|
|
||||||
child: IconButton(
|
|
||||||
onPressed: tracks == null || tracks.isEmpty
|
|
||||||
? null
|
|
||||||
: () => _loveAll(tracks),
|
|
||||||
icon: Icon(
|
|
||||||
allLoved ? Icons.favorite : Icons.favorite_border,
|
|
||||||
size: 22,
|
|
||||||
color: allLoved ? Colors.redAccent : Colors.white,
|
|
||||||
),
|
|
||||||
tooltip: allLoved
|
|
||||||
? context.l10n.trackOptionRemoveFromLoved
|
|
||||||
: context.l10n.tooltipLoveAll,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAddToPlaylistButton(BuildContext context) {
|
Widget _buildAddToPlaylistButton(BuildContext context) {
|
||||||
return Container(
|
return HeaderCircleButton(
|
||||||
width: 48,
|
icon: Icons.add,
|
||||||
height: 48,
|
tooltip: context.l10n.tooltipAddToPlaylist,
|
||||||
decoration: BoxDecoration(
|
onPressed: _tracks == null || _tracks!.isEmpty
|
||||||
shape: BoxShape.circle,
|
? null
|
||||||
color: Colors.white.withValues(alpha: 0.15),
|
: () => showAddTracksToPlaylistSheet(context, ref, _tracks!),
|
||||||
border: Border.all(
|
|
||||||
color: Colors.white.withValues(alpha: 0.3),
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: IconButton(
|
|
||||||
onPressed: _tracks == null || _tracks!.isEmpty
|
|
||||||
? null
|
|
||||||
: () => showAddTracksToPlaylistSheet(context, ref, _tracks!),
|
|
||||||
icon: const Icon(Icons.add, size: 22, color: Colors.white),
|
|
||||||
tooltip: context.l10n.tooltipAddToPlaylist,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,5 +625,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loveAll(List<Track> tracks) => loveAllTracks(context, ref, tracks);
|
Future<void> _loveAll(List<Track> tracks) =>
|
||||||
|
loveAllTracks(context, ref, tracks);
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,7 @@ import 'package:spotiflac_android/widgets/error_card.dart';
|
|||||||
import 'package:spotiflac_android/widgets/in_library_badge.dart';
|
import 'package:spotiflac_android/widgets/in_library_badge.dart';
|
||||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
|
import 'package:spotiflac_android/screens/selection_mode_mixin.dart';
|
||||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||||
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
|
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
|
||||||
@@ -104,7 +105,8 @@ class ArtistScreen extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<ArtistScreen> createState() => _ArtistScreenState();
|
ConsumerState<ArtistScreen> createState() => _ArtistScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
class _ArtistScreenState extends ConsumerState<ArtistScreen>
|
||||||
|
with SelectionModeMixin<ArtistScreen> {
|
||||||
bool _isLoadingDiscography = false;
|
bool _isLoadingDiscography = false;
|
||||||
List<ArtistAlbum>? _albums;
|
List<ArtistAlbum>? _albums;
|
||||||
List<ArtistAlbum>? _releases;
|
List<ArtistAlbum>? _releases;
|
||||||
@@ -119,8 +121,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
final PageController _popularPageController = PageController();
|
final PageController _popularPageController = PageController();
|
||||||
int _popularCurrentPage = 0;
|
int _popularCurrentPage = 0;
|
||||||
|
|
||||||
bool _isSelectionMode = false;
|
|
||||||
final Set<String> _selectedAlbumIds = {};
|
|
||||||
bool _isFetchingDiscography = false;
|
bool _isFetchingDiscography = false;
|
||||||
List<ArtistAlbum>? _albumBucketSource;
|
List<ArtistAlbum>? _albumBucketSource;
|
||||||
List<ArtistAlbum> _albumsOnlyBucket = const [];
|
List<ArtistAlbum> _albumsOnlyBucket = const [];
|
||||||
@@ -162,7 +162,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
return _directMetadataProviderId();
|
return _directMetadataProviderId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
String _effectiveMetadataProviderIdFromArtistId() {
|
String _effectiveMetadataProviderIdFromArtistId() {
|
||||||
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
||||||
return widget.extensionId!;
|
return widget.extensionId!;
|
||||||
@@ -173,7 +172,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
String? _directMetadataProviderId() {
|
String? _directMetadataProviderId() {
|
||||||
final providerId = _effectiveMetadataProviderIdFromArtistId();
|
final providerId = _effectiveMetadataProviderIdFromArtistId();
|
||||||
return providerId.isEmpty ? null : providerId;
|
return providerId.isEmpty ? null : providerId;
|
||||||
@@ -464,10 +462,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
!_isLoadingDiscography && _error == null && albums.isNotEmpty;
|
!_isLoadingDiscography && _error == null && albums.isNotEmpty;
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: !_isSelectionMode,
|
canPop: !isSelectionMode,
|
||||||
onPopInvokedWithResult: (didPop, result) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
if (!didPop && _isSelectionMode) {
|
if (!didPop && isSelectionMode) {
|
||||||
_exitSelectionMode();
|
exitSelectionMode();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
@@ -500,7 +498,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: ErrorCard(error: _error!, colorScheme: colorScheme),
|
child: ErrorCard(
|
||||||
|
error: _error!,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!_isLoadingDiscography && _error == null) ...[
|
if (!_isLoadingDiscography && _error == null) ...[
|
||||||
@@ -543,12 +544,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SizedBox(height: _isSelectionMode ? 120 : 32),
|
child: SizedBox(height: isSelectionMode ? 120 : 32),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
|
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (_isSelectionMode)
|
if (isSelectionMode)
|
||||||
_buildSelectionBar(context, colorScheme, albums),
|
_buildSelectionBar(context, colorScheme, albums),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -556,45 +557,21 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _exitSelectionMode() {
|
@override
|
||||||
|
void exitSelectionMode() {
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
setState(() {
|
super.exitSelectionMode();
|
||||||
_isSelectionMode = false;
|
|
||||||
_selectedAlbumIds.clear();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _enterSelectionMode(String albumId) {
|
@override
|
||||||
HapticFeedback.mediumImpact();
|
void toggleSelection(String itemId) {
|
||||||
setState(() {
|
|
||||||
_isSelectionMode = true;
|
|
||||||
_selectedAlbumIds.add(albumId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _toggleAlbumSelection(String albumId) {
|
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
setState(() {
|
super.toggleSelection(itemId);
|
||||||
if (_selectedAlbumIds.contains(albumId)) {
|
|
||||||
_selectedAlbumIds.remove(albumId);
|
|
||||||
if (_selectedAlbumIds.isEmpty) {
|
|
||||||
_isSelectionMode = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_selectedAlbumIds.add(albumId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _selectAll(List<ArtistAlbum> albums) {
|
|
||||||
setState(() {
|
|
||||||
_selectedAlbumIds.addAll(albums.map((a) => a.id));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _deselectAll() {
|
void _deselectAll() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedAlbumIds.clear();
|
selectedIds.clear();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,10 +580,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
List<ArtistAlbum> allAlbums,
|
List<ArtistAlbum> allAlbums,
|
||||||
) {
|
) {
|
||||||
final allSelected = _selectedAlbumIds.length == allAlbums.length;
|
final allSelected = selectedIds.length == allAlbums.length;
|
||||||
final selectedCount = _selectedAlbumIds.length;
|
final selectedCount = selectedIds.length;
|
||||||
final selectedAlbums = allAlbums
|
final selectedAlbums = allAlbums
|
||||||
.where((a) => _selectedAlbumIds.contains(a.id))
|
.where((a) => selectedIds.contains(a.id))
|
||||||
.toList();
|
.toList();
|
||||||
final totalTracks = selectedAlbums.fold<int>(
|
final totalTracks = selectedAlbums.fold<int>(
|
||||||
0,
|
0,
|
||||||
@@ -643,7 +620,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: _exitSelectionMode,
|
onPressed: exitSelectionMode,
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
tooltip: context.l10n.dialogCancel,
|
tooltip: context.l10n.dialogCancel,
|
||||||
),
|
),
|
||||||
@@ -684,7 +661,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
onPressed: allSelected
|
onPressed: allSelected
|
||||||
? _deselectAll
|
? _deselectAll
|
||||||
: () => _selectAll(allAlbums),
|
: () => selectAll(allAlbums.map((a) => a.id)),
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
fit: BoxFit.scaleDown,
|
fit: BoxFit.scaleDown,
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -719,7 +696,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
: Row(
|
: Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: _exitSelectionMode,
|
onPressed: exitSelectionMode,
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
tooltip: context.l10n.dialogCancel,
|
tooltip: context.l10n.dialogCancel,
|
||||||
),
|
),
|
||||||
@@ -754,7 +731,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
TextButton(
|
TextButton(
|
||||||
onPressed: allSelected
|
onPressed: allSelected
|
||||||
? _deselectAll
|
? _deselectAll
|
||||||
: () => _selectAll(allAlbums),
|
: () => selectAll(allAlbums.map((a) => a.id)),
|
||||||
child: Text(
|
child: Text(
|
||||||
allSelected
|
allSelected
|
||||||
? context.l10n.actionDeselect
|
? context.l10n.actionDeselect
|
||||||
@@ -880,7 +857,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
subtitle: context.l10n.discographySelectAlbumsSubtitle,
|
subtitle: context.l10n.discographySelectAlbumsSubtitle,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_enterSelectionMode(albums.first.id);
|
enterSelectionMode(albums.first.id);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -913,7 +890,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
List<ArtistAlbum> albums,
|
List<ArtistAlbum> albums,
|
||||||
) async {
|
) async {
|
||||||
_exitSelectionMode();
|
exitSelectionMode();
|
||||||
await _downloadAlbums(context, albums);
|
await _downloadAlbums(context, albums);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1308,7 +1285,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!_isSelectionMode) ...[
|
if (!isSelectionMode) ...[
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Container(
|
Container(
|
||||||
width: 52,
|
width: 52,
|
||||||
@@ -1334,7 +1311,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (hasDiscography && !_isSelectionMode) ...[
|
if (hasDiscography && !isSelectionMode) ...[
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Container(
|
Container(
|
||||||
width: 52,
|
width: 52,
|
||||||
@@ -1745,25 +1722,25 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
required double sectionHeight,
|
required double sectionHeight,
|
||||||
bool showTypeBadge = false,
|
bool showTypeBadge = false,
|
||||||
}) {
|
}) {
|
||||||
final isSelected = _selectedAlbumIds.contains(album.id);
|
final isSelected = selectedIds.contains(album.id);
|
||||||
|
|
||||||
return Semantics(
|
return Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
selected: _isSelectionMode && isSelected,
|
selected: isSelectionMode && isSelected,
|
||||||
label: _isSelectionMode
|
label: isSelectionMode
|
||||||
? context.l10n.a11ySelectAlbum(album.name)
|
? context.l10n.a11ySelectAlbum(album.name)
|
||||||
: context.l10n.a11yOpenAlbum(album.name),
|
: context.l10n.a11yOpenAlbum(album.name),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (_isSelectionMode) {
|
if (isSelectionMode) {
|
||||||
_toggleAlbumSelection(album.id);
|
toggleSelection(album.id);
|
||||||
} else {
|
} else {
|
||||||
_navigateToAlbum(album);
|
_navigateToAlbum(album);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
if (!_isSelectionMode) {
|
if (!isSelectionMode) {
|
||||||
_enterSelectionMode(album.id);
|
enterSelectionMode(album.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -1809,7 +1786,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_isSelectionMode)
|
if (isSelectionMode)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
@@ -1827,7 +1804,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_isSelectionMode)
|
if (isSelectionMode)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
@@ -1938,7 +1915,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DiscographyOptionTile extends StatelessWidget {
|
class _DiscographyOptionTile extends StatelessWidget {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import 'package:file_picker/file_picker.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:spotiflac_android/widgets/album_detail_header.dart';
|
import 'package:spotiflac_android/widgets/album_detail_header.dart';
|
||||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
@@ -16,6 +15,8 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
|
|||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||||
import 'package:spotiflac_android/utils/cover_art_utils.dart';
|
import 'package:spotiflac_android/utils/cover_art_utils.dart';
|
||||||
|
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
|
||||||
|
import 'package:spotiflac_android/screens/selection_mode_mixin.dart';
|
||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
import 'package:spotiflac_android/widgets/selection_action_button.dart';
|
import 'package:spotiflac_android/widgets/selection_action_button.dart';
|
||||||
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
|
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
|
||||||
@@ -50,41 +51,12 @@ class LibraryTracksFolderScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LibraryTracksFolderScreenState
|
class _LibraryTracksFolderScreenState
|
||||||
extends ConsumerState<LibraryTracksFolderScreen> {
|
extends ConsumerState<LibraryTracksFolderScreen>
|
||||||
bool _showTitleInAppBar = false;
|
with
|
||||||
final ScrollController _scrollController = ScrollController();
|
SelectionModeMixin<LibraryTracksFolderScreen>,
|
||||||
|
CollapsingHeaderScrollMixin<LibraryTracksFolderScreen> {
|
||||||
bool _isSelectionMode = false;
|
|
||||||
final Set<String> _selectedKeys = {};
|
|
||||||
UserPlaylistCollection? playlist;
|
UserPlaylistCollection? playlist;
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_scrollController.addListener(_onScroll);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_scrollController.removeListener(_onScroll);
|
|
||||||
_scrollController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onScroll() {
|
|
||||||
final expandedHeight = _calculateExpandedHeight(context);
|
|
||||||
final shouldShow =
|
|
||||||
_scrollController.offset > (expandedHeight - kToolbarHeight - 20);
|
|
||||||
if (shouldShow != _showTitleInAppBar) {
|
|
||||||
setState(() => _showTitleInAppBar = shouldShow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
double _calculateExpandedHeight(BuildContext context) {
|
|
||||||
final mediaSize = MediaQuery.sizeOf(context);
|
|
||||||
return (mediaSize.height * 0.6).clamp(400.0, 580.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData _modeIcon() {
|
IconData _modeIcon() {
|
||||||
return switch (widget.mode) {
|
return switch (widget.mode) {
|
||||||
LibraryTracksFolderMode.wishlist => Icons.bookmark,
|
LibraryTracksFolderMode.wishlist => Icons.bookmark,
|
||||||
@@ -104,42 +76,8 @@ class _LibraryTracksFolderScreenState
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _enterSelectionMode(String key) {
|
|
||||||
HapticFeedback.mediumImpact();
|
|
||||||
setState(() {
|
|
||||||
_isSelectionMode = true;
|
|
||||||
_selectedKeys.add(key);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _exitSelectionMode() {
|
|
||||||
setState(() {
|
|
||||||
_isSelectionMode = false;
|
|
||||||
_selectedKeys.clear();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _toggleSelection(String key) {
|
|
||||||
setState(() {
|
|
||||||
if (_selectedKeys.contains(key)) {
|
|
||||||
_selectedKeys.remove(key);
|
|
||||||
if (_selectedKeys.isEmpty) {
|
|
||||||
_isSelectionMode = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_selectedKeys.add(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _selectAll(List<CollectionTrackEntry> entries) {
|
|
||||||
setState(() {
|
|
||||||
_selectedKeys.addAll(entries.map((e) => e.key));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _removeSelected(List<CollectionTrackEntry> entries) async {
|
Future<void> _removeSelected(List<CollectionTrackEntry> entries) async {
|
||||||
final keysToRemove = _selectedKeys.toSet();
|
final keysToRemove = selectedIds.toSet();
|
||||||
if (keysToRemove.isEmpty) return;
|
if (keysToRemove.isEmpty) return;
|
||||||
|
|
||||||
final count = keysToRemove.length;
|
final count = keysToRemove.length;
|
||||||
@@ -161,7 +99,7 @@ class _LibraryTracksFolderScreenState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_exitSelectionMode();
|
exitSelectionMode();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -202,12 +140,12 @@ class _LibraryTracksFolderScreenState
|
|||||||
var count = 0;
|
var count = 0;
|
||||||
|
|
||||||
for (final entry in entries) {
|
for (final entry in entries) {
|
||||||
if (!_selectedKeys.contains(entry.key)) continue;
|
if (!selectedIds.contains(entry.key)) continue;
|
||||||
queueNotifier.addToQueue(entry.track, service);
|
queueNotifier.addToQueue(entry.track, service);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
_exitSelectionMode();
|
exitSelectionMode();
|
||||||
|
|
||||||
if (!mounted || count == 0) return;
|
if (!mounted || count == 0) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -217,7 +155,7 @@ class _LibraryTracksFolderScreenState
|
|||||||
|
|
||||||
void _addSelectedToPlaylist(List<CollectionTrackEntry> entries) {
|
void _addSelectedToPlaylist(List<CollectionTrackEntry> entries) {
|
||||||
final selectedTracks = entries
|
final selectedTracks = entries
|
||||||
.where((e) => _selectedKeys.contains(e.key))
|
.where((e) => selectedIds.contains(e.key))
|
||||||
.map((e) => e.track)
|
.map((e) => e.track)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
if (selectedTracks.isEmpty) return;
|
if (selectedTracks.isEmpty) return;
|
||||||
@@ -257,15 +195,7 @@ class _LibraryTracksFolderScreenState
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_isSelectionMode) {
|
pruneSelection(entries.map((e) => e.key).toSet());
|
||||||
final validKeys = entries.map((e) => e.key).toSet();
|
|
||||||
_selectedKeys.removeWhere((key) => !validKeys.contains(key));
|
|
||||||
if (_selectedKeys.isEmpty && _isSelectionMode) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) setState(() => _isSelectionMode = false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final title = switch (widget.mode) {
|
final title = switch (widget.mode) {
|
||||||
LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist,
|
LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist,
|
||||||
@@ -308,17 +238,17 @@ class _LibraryTracksFolderScreenState
|
|||||||
final bottomInset = context.navBarBottomInset;
|
final bottomInset = context.navBarBottomInset;
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: !_isSelectionMode,
|
canPop: !isSelectionMode,
|
||||||
onPopInvokedWithResult: (didPop, result) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
if (!didPop && _isSelectionMode) {
|
if (!didPop && isSelectionMode) {
|
||||||
_exitSelectionMode();
|
exitSelectionMode();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
CustomScrollView(
|
CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
_buildAppBar(context, colorScheme, title, entries, playlist),
|
_buildAppBar(context, colorScheme, title, entries, playlist),
|
||||||
if (entries.isEmpty)
|
if (entries.isEmpty)
|
||||||
@@ -333,7 +263,7 @@ class _LibraryTracksFolderScreenState
|
|||||||
SliverList(
|
SliverList(
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
final entry = entries[index];
|
final entry = entries[index];
|
||||||
final isSelected = _selectedKeys.contains(entry.key);
|
final isSelected = selectedIds.contains(entry.key);
|
||||||
final isInHistory = existingHistoryKeys.contains(
|
final isInHistory = existingHistoryKeys.contains(
|
||||||
historyLookups[index].lookupKey,
|
historyLookups[index].lookupKey,
|
||||||
);
|
);
|
||||||
@@ -347,21 +277,21 @@ class _LibraryTracksFolderScreenState
|
|||||||
playlistId: widget.playlistId,
|
playlistId: widget.playlistId,
|
||||||
folderTracks: folderTracks,
|
folderTracks: folderTracks,
|
||||||
isInHistory: isInHistory,
|
isInHistory: isInHistory,
|
||||||
isSelectionMode: _isSelectionMode,
|
isSelectionMode: isSelectionMode,
|
||||||
isSelected: isSelected,
|
isSelected: isSelected,
|
||||||
onTap: _isSelectionMode
|
onTap: isSelectionMode
|
||||||
? () => _toggleSelection(entry.key)
|
? () => toggleSelection(entry.key)
|
||||||
: null,
|
: null,
|
||||||
onLongPress: _isSelectionMode
|
onLongPress: isSelectionMode
|
||||||
? null
|
? null
|
||||||
: () => _enterSelectionMode(entry.key),
|
: () => enterSelectionMode(entry.key),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, childCount: entries.length),
|
}, childCount: entries.length),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SizedBox(height: _isSelectionMode ? 200 : 32),
|
child: SizedBox(height: isSelectionMode ? 200 : 32),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
|
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
|
||||||
],
|
],
|
||||||
@@ -372,7 +302,7 @@ class _LibraryTracksFolderScreenState
|
|||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: _isSelectionMode ? 0 : -(280 + bottomPadding),
|
bottom: isSelectionMode ? 0 : -(280 + bottomPadding),
|
||||||
child: _buildSelectionBottomBar(
|
child: _buildSelectionBottomBar(
|
||||||
context,
|
context,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
@@ -392,19 +322,19 @@ class _LibraryTracksFolderScreenState
|
|||||||
List<CollectionTrackEntry> entries,
|
List<CollectionTrackEntry> entries,
|
||||||
double bottomPadding,
|
double bottomPadding,
|
||||||
) {
|
) {
|
||||||
final selectedCount = _selectedKeys.length;
|
final selectedCount = selectedIds.length;
|
||||||
final allSelected = selectedCount == entries.length && entries.isNotEmpty;
|
final allSelected = selectedCount == entries.length && entries.isNotEmpty;
|
||||||
final isWishlist = widget.mode == LibraryTracksFolderMode.wishlist;
|
final isWishlist = widget.mode == LibraryTracksFolderMode.wishlist;
|
||||||
|
|
||||||
return SelectionBottomBar(
|
return SelectionBottomBar(
|
||||||
selectedCount: selectedCount,
|
selectedCount: selectedCount,
|
||||||
allSelected: allSelected,
|
allSelected: allSelected,
|
||||||
onClose: _exitSelectionMode,
|
onClose: exitSelectionMode,
|
||||||
onToggleSelectAll: () {
|
onToggleSelectAll: () {
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
_exitSelectionMode();
|
exitSelectionMode();
|
||||||
} else {
|
} else {
|
||||||
_selectAll(entries);
|
selectAll(entries.map((e) => e.key));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
bottomPadding: bottomPadding,
|
bottomPadding: bottomPadding,
|
||||||
@@ -502,7 +432,7 @@ class _LibraryTracksFolderScreenState
|
|||||||
List<CollectionTrackEntry> entries,
|
List<CollectionTrackEntry> entries,
|
||||||
UserPlaylistCollection? playlist,
|
UserPlaylistCollection? playlist,
|
||||||
) {
|
) {
|
||||||
final expandedHeight = _calculateExpandedHeight(context);
|
final expandedHeight = calculateExpandedHeight(context);
|
||||||
final customCoverPath = playlist?.coverImagePath;
|
final customCoverPath = playlist?.coverImagePath;
|
||||||
final isLovedMode = widget.mode == LibraryTracksFolderMode.loved;
|
final isLovedMode = widget.mode == LibraryTracksFolderMode.loved;
|
||||||
final isPlaylistMode = widget.mode == LibraryTracksFolderMode.playlist;
|
final isPlaylistMode = widget.mode == LibraryTracksFolderMode.playlist;
|
||||||
@@ -563,11 +493,11 @@ class _LibraryTracksFolderScreenState
|
|||||||
|
|
||||||
return AlbumDetailHeader(
|
return AlbumDetailHeader(
|
||||||
title: title,
|
title: title,
|
||||||
appBarTitle: _isSelectionMode
|
appBarTitle: isSelectionMode
|
||||||
? context.l10n.selectionSelected(_selectedKeys.length)
|
? context.l10n.selectionSelected(selectedIds.length)
|
||||||
: title,
|
: title,
|
||||||
expandedHeight: expandedHeight,
|
expandedHeight: expandedHeight,
|
||||||
showTitleInAppBar: _showTitleInAppBar,
|
showTitleInAppBar: showTitleInAppBar,
|
||||||
background: background,
|
background: background,
|
||||||
blurAndScrimBackground: hasCustomCover || hasCoverUrl,
|
blurAndScrimBackground: hasCustomCover || hasCoverUrl,
|
||||||
coverBuilder: (context, coverSize) {
|
coverBuilder: (context, coverSize) {
|
||||||
@@ -633,7 +563,7 @@ class _LibraryTracksFolderScreenState
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
appBarActions: [
|
appBarActions: [
|
||||||
if (isPlaylistMode && !_isSelectionMode) ...[
|
if (isPlaylistMode && !isSelectionMode) ...[
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: context.l10n.collectionRenamePlaylist,
|
tooltip: context.l10n.collectionRenamePlaylist,
|
||||||
icon: Container(
|
icon: Container(
|
||||||
@@ -672,7 +602,7 @@ class _LibraryTracksFolderScreenState
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
tooltip: _isSelectionMode
|
tooltip: isSelectionMode
|
||||||
? MaterialLocalizations.of(context).closeButtonTooltip
|
? MaterialLocalizations.of(context).closeButtonTooltip
|
||||||
: MaterialLocalizations.of(context).backButtonTooltip,
|
: MaterialLocalizations.of(context).backButtonTooltip,
|
||||||
icon: Container(
|
icon: Container(
|
||||||
@@ -682,12 +612,12 @@ class _LibraryTracksFolderScreenState
|
|||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
_isSelectionMode ? Icons.close : Icons.arrow_back,
|
isSelectionMode ? Icons.close : Icons.arrow_back,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: _isSelectionMode
|
onPressed: isSelectionMode
|
||||||
? _exitSelectionMode
|
? exitSelectionMode
|
||||||
: () => Navigator.pop(context),
|
: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -712,7 +642,11 @@ class _LibraryTracksFolderScreenState
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _confirmDownloadAll(List<Track> tracks) {
|
void _confirmDownloadAll(List<Track> tracks) {
|
||||||
confirmDownloadAllDialog(context, tracks.length, () => _downloadAll(tracks));
|
confirmDownloadAllDialog(
|
||||||
|
context,
|
||||||
|
tracks.length,
|
||||||
|
() => _downloadAll(tracks),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _downloadAll(List<Track> tracks) async {
|
Future<void> _downloadAll(List<Track> tracks) async {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
|||||||
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
|
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
|
||||||
import 'package:spotiflac_android/widgets/track_list_tile.dart';
|
import 'package:spotiflac_android/widgets/track_list_tile.dart';
|
||||||
import 'package:spotiflac_android/widgets/track_detail_actions.dart';
|
import 'package:spotiflac_android/widgets/track_detail_actions.dart';
|
||||||
|
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
|
||||||
import 'package:spotiflac_android/widgets/error_card.dart';
|
import 'package:spotiflac_android/widgets/error_card.dart';
|
||||||
|
|
||||||
class PlaylistScreen extends ConsumerStatefulWidget {
|
class PlaylistScreen extends ConsumerStatefulWidget {
|
||||||
@@ -41,9 +42,8 @@ class PlaylistScreen extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<PlaylistScreen> createState() => _PlaylistScreenState();
|
ConsumerState<PlaylistScreen> createState() => _PlaylistScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
|
||||||
bool _showTitleInAppBar = false;
|
with CollapsingHeaderScrollMixin<PlaylistScreen> {
|
||||||
final ScrollController _scrollController = ScrollController();
|
|
||||||
List<Track>? _fetchedTracks;
|
List<Track>? _fetchedTracks;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
@@ -57,8 +57,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
String? get _headerVideoUrl =>
|
String? get _headerVideoUrl =>
|
||||||
_resolvedHeaderVideoUrl ?? widget.headerVideoUrl;
|
_resolvedHeaderVideoUrl ?? widget.headerVideoUrl;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
String? _metadataProviderId(String playlistId) {
|
String? _metadataProviderId(String playlistId) {
|
||||||
final providerId = legacyProviderIdFromResourceId(playlistId);
|
final providerId = legacyProviderIdFromResourceId(playlistId);
|
||||||
if (providerId == null) return null;
|
if (providerId == null) return null;
|
||||||
@@ -109,17 +107,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_scrollController.addListener(_onScroll);
|
|
||||||
_fetchTracksIfNeeded();
|
_fetchTracksIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_scrollController.removeListener(_onScroll);
|
|
||||||
_scrollController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _fetchTracksIfNeeded() async {
|
Future<void> _fetchTracksIfNeeded() async {
|
||||||
if (widget.tracks.isNotEmpty || widget.playlistId == null) return;
|
if (widget.tracks.isNotEmpty || widget.playlistId == null) return;
|
||||||
|
|
||||||
@@ -178,27 +168,13 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onScroll() {
|
|
||||||
final expandedHeight = _calculateExpandedHeight(context);
|
|
||||||
final shouldShow =
|
|
||||||
_scrollController.offset > (expandedHeight - kToolbarHeight - 20);
|
|
||||||
if (shouldShow != _showTitleInAppBar) {
|
|
||||||
setState(() => _showTitleInAppBar = shouldShow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
double _calculateExpandedHeight(BuildContext context) {
|
|
||||||
final mediaSize = MediaQuery.of(context).size;
|
|
||||||
return (mediaSize.height * 0.6).clamp(400.0, 580.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
_buildAppBar(context, colorScheme),
|
_buildAppBar(context, colorScheme),
|
||||||
_buildTrackList(context, colorScheme),
|
_buildTrackList(context, colorScheme),
|
||||||
@@ -212,7 +188,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||||
final expandedHeight = _calculateExpandedHeight(context);
|
final expandedHeight = calculateExpandedHeight(context);
|
||||||
final cacheWidth = coverCacheWidthForViewport(context);
|
final cacheWidth = coverCacheWidthForViewport(context);
|
||||||
final motionUrl = _headerVideoUrl;
|
final motionUrl = _headerVideoUrl;
|
||||||
final hasMotion =
|
final hasMotion =
|
||||||
@@ -243,7 +219,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
return AlbumDetailHeader(
|
return AlbumDetailHeader(
|
||||||
title: _playlistName,
|
title: _playlistName,
|
||||||
expandedHeight: expandedHeight,
|
expandedHeight: expandedHeight,
|
||||||
showTitleInAppBar: _showTitleInAppBar,
|
showTitleInAppBar: showTitleInAppBar,
|
||||||
background: hasMotion
|
background: hasMotion
|
||||||
? MotionHeaderBanner(videoUrl: motionUrl, fallback: coverImage)
|
? MotionHeaderBanner(videoUrl: motionUrl, fallback: coverImage)
|
||||||
: coverImage,
|
: coverImage,
|
||||||
@@ -403,59 +379,18 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCircleButton({
|
|
||||||
required IconData icon,
|
|
||||||
required String tooltip,
|
|
||||||
required VoidCallback? onPressed,
|
|
||||||
}) {
|
|
||||||
return Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: Colors.white.withValues(alpha: 0.15),
|
|
||||||
border: Border.all(
|
|
||||||
color: Colors.white.withValues(alpha: 0.3),
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: IconButton(
|
|
||||||
onPressed: onPressed,
|
|
||||||
icon: Icon(icon, size: 22, color: Colors.white),
|
|
||||||
tooltip: tooltip,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildLoveAllButton() {
|
Widget _buildLoveAllButton() {
|
||||||
final collectionsState = ref.watch(libraryCollectionsProvider);
|
final collectionsState = ref.watch(libraryCollectionsProvider);
|
||||||
final allLoved =
|
final allLoved =
|
||||||
_tracks.isNotEmpty && _tracks.every((t) => collectionsState.isLoved(t));
|
_tracks.isNotEmpty && _tracks.every((t) => collectionsState.isLoved(t));
|
||||||
|
|
||||||
return Container(
|
return HeaderCircleButton(
|
||||||
width: 48,
|
icon: allLoved ? Icons.favorite : Icons.favorite_border,
|
||||||
height: 48,
|
iconColor: allLoved ? Colors.redAccent : Colors.white,
|
||||||
decoration: BoxDecoration(
|
tooltip: allLoved
|
||||||
shape: BoxShape.circle,
|
? context.l10n.trackOptionRemoveFromLoved
|
||||||
color: Colors.white.withValues(alpha: 0.15),
|
: context.l10n.tooltipLoveAll,
|
||||||
border: Border.all(
|
onPressed: _tracks.isEmpty ? null : () => _loveAll(_tracks),
|
||||||
color: Colors.white.withValues(alpha: 0.3),
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: IconButton(
|
|
||||||
onPressed: _tracks.isEmpty ? null : () => _loveAll(_tracks),
|
|
||||||
icon: Icon(
|
|
||||||
allLoved ? Icons.favorite : Icons.favorite_border,
|
|
||||||
size: 22,
|
|
||||||
color: allLoved ? Colors.redAccent : Colors.white,
|
|
||||||
),
|
|
||||||
tooltip: allLoved
|
|
||||||
? context.l10n.trackOptionRemoveFromLoved
|
|
||||||
: context.l10n.tooltipLoveAll,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,7 +413,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAddToPlaylistButton(BuildContext context) {
|
Widget _buildAddToPlaylistButton(BuildContext context) {
|
||||||
return _buildCircleButton(
|
return HeaderCircleButton(
|
||||||
icon: Icons.playlist_add,
|
icon: Icons.playlist_add,
|
||||||
tooltip: context.l10n.tooltipAddToPlaylist,
|
tooltip: context.l10n.tooltipAddToPlaylist,
|
||||||
onPressed: _tracks.isEmpty
|
onPressed: _tracks.isEmpty
|
||||||
@@ -500,7 +435,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loveAll(List<Track> tracks) => loveAllTracks(context, ref, tracks);
|
Future<void> _loveAll(List<Track> tracks) =>
|
||||||
|
loveAllTracks(context, ref, tracks);
|
||||||
|
|
||||||
void _downloadAll(BuildContext context) {
|
void _downloadAll(BuildContext context) {
|
||||||
_downloadTracks(context, _tracks);
|
_downloadTracks(context, _tracks);
|
||||||
|
|||||||
@@ -336,158 +336,77 @@ extension _QueueTabSelectionActions on _QueueTabState {
|
|||||||
final allSelected =
|
final allSelected =
|
||||||
selectedCount == playlists.length && playlists.isNotEmpty;
|
selectedCount == playlists.length && playlists.isNotEmpty;
|
||||||
|
|
||||||
return Container(
|
return SelectionBottomBar(
|
||||||
decoration: BoxDecoration(
|
selectedCount: selectedCount,
|
||||||
color: colorScheme.surfaceContainerHigh,
|
allSelected: allSelected,
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
|
onClose: _exitPlaylistSelectionMode,
|
||||||
boxShadow: [
|
onToggleSelectAll: () {
|
||||||
BoxShadow(
|
if (allSelected) {
|
||||||
color: Colors.black.withValues(alpha: 0.15),
|
_exitPlaylistSelectionMode();
|
||||||
blurRadius: 12,
|
} else {
|
||||||
offset: const Offset(0, -4),
|
_selectAllPlaylists(playlists);
|
||||||
),
|
}
|
||||||
],
|
},
|
||||||
),
|
bottomPadding: bottomPadding,
|
||||||
child: SafeArea(
|
allSelectedLabel: context.l10n.selectionAllPlaylistsSelected,
|
||||||
top: false,
|
tapToSelectLabel: context.l10n.selectionTapPlaylistsToSelect,
|
||||||
child: Padding(
|
children: [
|
||||||
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16),
|
SizedBox(
|
||||||
child: Column(
|
width: double.infinity,
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: FilledButton.icon(
|
||||||
children: [
|
onPressed: selectedCount > 0
|
||||||
Container(
|
? () => _downloadAllSelectedPlaylists(context)
|
||||||
width: 32,
|
: null,
|
||||||
height: 4,
|
icon: const Icon(Icons.download_rounded),
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
label: Text(
|
||||||
decoration: BoxDecoration(
|
selectedCount > 0
|
||||||
color: colorScheme.outlineVariant,
|
? context.l10n.bulkDownloadPlaylistsButton(selectedCount)
|
||||||
borderRadius: BorderRadius.circular(2),
|
: context.l10n.bulkDownloadSelectPlaylists,
|
||||||
),
|
),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: selectedCount > 0
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
|
foregroundColor: selectedCount > 0
|
||||||
|
? colorScheme.onPrimary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
IconButton.filledTonal(
|
|
||||||
onPressed: _exitPlaylistSelectionMode,
|
|
||||||
tooltip: MaterialLocalizations.of(
|
|
||||||
context,
|
|
||||||
).closeButtonTooltip,
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
style: IconButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
context.l10n.selectionSelected(selectedCount),
|
|
||||||
style: Theme.of(context).textTheme.titleMedium
|
|
||||||
?.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
allSelected
|
|
||||||
? context.l10n.selectionAllPlaylistsSelected
|
|
||||||
: context.l10n.selectionTapPlaylistsToSelect,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall
|
|
||||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
if (allSelected) {
|
|
||||||
_exitPlaylistSelectionMode();
|
|
||||||
} else {
|
|
||||||
_selectAllPlaylists(playlists);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: Icon(
|
|
||||||
allSelected ? Icons.deselect : Icons.select_all,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
label: Text(
|
|
||||||
allSelected
|
|
||||||
? context.l10n.actionDeselect
|
|
||||||
: context.l10n.actionSelectAll,
|
|
||||||
),
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor: colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: FilledButton.icon(
|
|
||||||
onPressed: selectedCount > 0
|
|
||||||
? () => _downloadAllSelectedPlaylists(context)
|
|
||||||
: null,
|
|
||||||
icon: const Icon(Icons.download_rounded),
|
|
||||||
label: Text(
|
|
||||||
selectedCount > 0
|
|
||||||
? context.l10n.bulkDownloadPlaylistsButton(
|
|
||||||
selectedCount,
|
|
||||||
)
|
|
||||||
: context.l10n.bulkDownloadSelectPlaylists,
|
|
||||||
),
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: selectedCount > 0
|
|
||||||
? colorScheme.primary
|
|
||||||
: colorScheme.surfaceContainerHighest,
|
|
||||||
foregroundColor: selectedCount > 0
|
|
||||||
? colorScheme.onPrimary
|
|
||||||
: colorScheme.onSurfaceVariant,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: FilledButton.icon(
|
|
||||||
onPressed: selectedCount > 0
|
|
||||||
? () => _deleteSelectedPlaylists(context)
|
|
||||||
: null,
|
|
||||||
icon: const Icon(Icons.delete_outline),
|
|
||||||
label: Text(
|
|
||||||
selectedCount > 0
|
|
||||||
? context.l10n.selectionDeletePlaylistsCount(
|
|
||||||
selectedCount,
|
|
||||||
)
|
|
||||||
: context.l10n.selectionSelectPlaylistsToDelete,
|
|
||||||
),
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: selectedCount > 0
|
|
||||||
? colorScheme.error
|
|
||||||
: colorScheme.surfaceContainerHighest,
|
|
||||||
foregroundColor: selectedCount > 0
|
|
||||||
? colorScheme.onError
|
|
||||||
: colorScheme.onSurfaceVariant,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: selectedCount > 0
|
||||||
|
? () => _deleteSelectedPlaylists(context)
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
label: Text(
|
||||||
|
selectedCount > 0
|
||||||
|
? context.l10n.selectionDeletePlaylistsCount(selectedCount)
|
||||||
|
: context.l10n.selectionSelectPlaylistsToDelete,
|
||||||
|
),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: selectedCount > 0
|
||||||
|
? colorScheme.error
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
|
foregroundColor: selectedCount > 0
|
||||||
|
? colorScheme.onError
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,5 +504,4 @@ extension _QueueTabSelectionActions on _QueueTabState {
|
|||||||
Future<int?> _readFileModTimeMillis(String? filePath) async {
|
Future<int?> _readFileModTimeMillis(String? filePath) async {
|
||||||
return DownloadedEmbeddedCoverResolver.readFileModTimeMillis(filePath);
|
return DownloadedEmbeddedCoverResolver.readFileModTimeMillis(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,7 @@ import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
|
|||||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
import 'package:spotiflac_android/constants/music_services.dart';
|
import 'package:spotiflac_android/constants/music_services.dart';
|
||||||
|
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
|
||||||
|
|
||||||
part 'track_metadata_edit_sheet.dart';
|
part 'track_metadata_edit_sheet.dart';
|
||||||
part 'track_metadata_cards.dart';
|
part 'track_metadata_cards.dart';
|
||||||
@@ -92,7 +93,8 @@ class TrackMetadataScreen extends ConsumerStatefulWidget {
|
|||||||
_TrackMetadataScreenState();
|
_TrackMetadataScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
|
||||||
|
with CollapsingHeaderScrollMixin<TrackMetadataScreen> {
|
||||||
static const int _maxCoverPreviewCacheEntries = 96;
|
static const int _maxCoverPreviewCacheEntries = 96;
|
||||||
static final Map<String, _EmbeddedCoverPreviewCacheEntry>
|
static final Map<String, _EmbeddedCoverPreviewCacheEntry>
|
||||||
_embeddedCoverPreviewCache = {};
|
_embeddedCoverPreviewCache = {};
|
||||||
@@ -105,7 +107,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
bool _lyricsLoading = false;
|
bool _lyricsLoading = false;
|
||||||
String? _lyricsError;
|
String? _lyricsError;
|
||||||
String? _lyricsSource;
|
String? _lyricsSource;
|
||||||
bool _showTitleInAppBar = false;
|
|
||||||
bool _lyricsEmbedded = false;
|
bool _lyricsEmbedded = false;
|
||||||
bool _isEmbedding = false;
|
bool _isEmbedding = false;
|
||||||
bool _isInstrumental = false;
|
bool _isInstrumental = false;
|
||||||
@@ -121,7 +122,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
late int? _currentNavigationIndex;
|
late int? _currentNavigationIndex;
|
||||||
Map<String, dynamic>? _editedMetadata;
|
Map<String, dynamic>? _editedMetadata;
|
||||||
String? _embeddedCoverPreviewPath;
|
String? _embeddedCoverPreviewPath;
|
||||||
final ScrollController _scrollController = ScrollController();
|
|
||||||
static final RegExp _lrcTimestampPattern = RegExp(
|
static final RegExp _lrcTimestampPattern = RegExp(
|
||||||
r'^\[\d{2}:\d{2}\.\d{2,3}\]',
|
r'^\[\d{2}:\d{2}\.\d{2,3}\]',
|
||||||
);
|
);
|
||||||
@@ -247,15 +247,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
_currentDownloadItem = widget.item;
|
_currentDownloadItem = widget.item;
|
||||||
_currentLocalLibraryItem = widget.localItem;
|
_currentLocalLibraryItem = widget.localItem;
|
||||||
_currentNavigationIndex = widget.navigationIndex;
|
_currentNavigationIndex = widget.navigationIndex;
|
||||||
_scrollController.addListener(_onScroll);
|
|
||||||
_checkFile();
|
_checkFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
unawaited(_cleanupTempFileAndParentIfNotCached(_embeddedCoverPreviewPath));
|
unawaited(_cleanupTempFileAndParentIfNotCached(_embeddedCoverPreviewPath));
|
||||||
_scrollController.removeListener(_onScroll);
|
|
||||||
_scrollController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,16 +260,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
/// through this forwarder.
|
/// through this forwarder.
|
||||||
void _setState(VoidCallback fn) => setState(fn);
|
void _setState(VoidCallback fn) => setState(fn);
|
||||||
|
|
||||||
void _onScroll() {
|
@override
|
||||||
final expandedHeight = _calculateExpandedHeight(context);
|
double calculateExpandedHeight(BuildContext context) {
|
||||||
final shouldShow =
|
|
||||||
_scrollController.offset > (expandedHeight - kToolbarHeight - 20);
|
|
||||||
if (shouldShow != _showTitleInAppBar) {
|
|
||||||
setState(() => _showTitleInAppBar = shouldShow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
double _calculateExpandedHeight(BuildContext context) {
|
|
||||||
final mediaSize = MediaQuery.sizeOf(context);
|
final mediaSize = MediaQuery.sizeOf(context);
|
||||||
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
||||||
}
|
}
|
||||||
@@ -775,7 +764,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
String? get _localCoverPath =>
|
String? get _localCoverPath =>
|
||||||
_isLocalItem ? _localLibraryItem!.coverPath : null;
|
_isLocalItem ? _localLibraryItem!.coverPath : null;
|
||||||
String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId;
|
String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId;
|
||||||
String get _service => _isLocalItem ? MusicServices.local : _downloadItem!.service;
|
String get _service =>
|
||||||
|
_isLocalItem ? MusicServices.local : _downloadItem!.service;
|
||||||
DateTime get _addedAt {
|
DateTime get _addedAt {
|
||||||
if (_isLocalItem) {
|
if (_isLocalItem) {
|
||||||
final modTime = _localLibraryItem!.fileModTime;
|
final modTime = _localLibraryItem!.fileModTime;
|
||||||
@@ -1083,7 +1073,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
_lyricsLoading = false;
|
_lyricsLoading = false;
|
||||||
_lyricsError = null;
|
_lyricsError = null;
|
||||||
_lyricsSource = null;
|
_lyricsSource = null;
|
||||||
_showTitleInAppBar = false;
|
showTitleInAppBar = false;
|
||||||
_lyricsEmbedded = false;
|
_lyricsEmbedded = false;
|
||||||
_isInstrumental = false;
|
_isInstrumental = false;
|
||||||
_embeddedLyricsChecked = false;
|
_embeddedLyricsChecked = false;
|
||||||
@@ -1092,8 +1082,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
_embeddedCoverPreviewPath = null;
|
_embeddedCoverPreviewPath = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (_scrollController.hasClients) {
|
if (scrollController.hasClients) {
|
||||||
_scrollController.jumpTo(0);
|
scrollController.jumpTo(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oldPreviewPath != null) {
|
if (oldPreviewPath != null) {
|
||||||
@@ -1110,7 +1100,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final expandedHeight = _calculateExpandedHeight(context);
|
final expandedHeight = calculateExpandedHeight(context);
|
||||||
final bottomInset = context.navBarBottomInset;
|
final bottomInset = context.navBarBottomInset;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
@@ -1118,7 +1108,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
onHorizontalDragEnd: _handleHorizontalDragEnd,
|
onHorizontalDragEnd: _handleHorizontalDragEnd,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: expandedHeight,
|
expandedHeight: expandedHeight,
|
||||||
@@ -1128,7 +1118,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
title: AnimatedOpacity(
|
title: AnimatedOpacity(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
opacity: showTitleInAppBar ? 1.0 : 0.0,
|
||||||
child: Text(
|
child: Text(
|
||||||
trackName,
|
trackName,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -1500,7 +1490,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
|
|
||||||
return placeholder();
|
return placeholder();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MetadataOption {
|
class _MetadataOption {
|
||||||
|
|||||||
@@ -283,6 +283,45 @@ class AlbumPlayActions extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 48x48 translucent-white circle icon button used in album/playlist headers
|
||||||
|
/// (add-to-playlist, love-all, ...).
|
||||||
|
class HeaderCircleButton extends StatelessWidget {
|
||||||
|
const HeaderCircleButton({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.onPressed,
|
||||||
|
this.iconColor = Colors.white,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String tooltip;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final Color iconColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.3),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: onPressed,
|
||||||
|
icon: Icon(icon, size: 22, color: iconColor),
|
||||||
|
tooltip: tooltip,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// "•"-joined row of white [HeaderMetaItem]s shown under the header subtitle
|
/// "•"-joined row of white [HeaderMetaItem]s shown under the header subtitle
|
||||||
/// (track count, duration, quality, ...).
|
/// (track count, duration, quality, ...).
|
||||||
class HeaderMetaRow extends StatelessWidget {
|
class HeaderMetaRow extends StatelessWidget {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class SelectionBottomBar extends StatelessWidget {
|
|||||||
required this.onToggleSelectAll,
|
required this.onToggleSelectAll,
|
||||||
required this.bottomPadding,
|
required this.bottomPadding,
|
||||||
required this.children,
|
required this.children,
|
||||||
|
this.allSelectedLabel,
|
||||||
|
this.tapToSelectLabel,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int selectedCount;
|
final int selectedCount;
|
||||||
@@ -22,6 +24,12 @@ class SelectionBottomBar extends StatelessWidget {
|
|||||||
final double bottomPadding;
|
final double bottomPadding;
|
||||||
final List<Widget> children;
|
final List<Widget> children;
|
||||||
|
|
||||||
|
/// Overrides the default "All tracks selected" subtitle.
|
||||||
|
final String? allSelectedLabel;
|
||||||
|
|
||||||
|
/// Overrides the default "Tap tracks to select" subtitle.
|
||||||
|
final String? tapToSelectLabel;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
@@ -80,8 +88,10 @@ class SelectionBottomBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
allSelected
|
allSelected
|
||||||
? context.l10n.selectionAllSelected
|
? allSelectedLabel ??
|
||||||
: context.l10n.downloadedAlbumTapToSelect,
|
context.l10n.selectionAllSelected
|
||||||
|
: tapToSelectLabel ??
|
||||||
|
context.l10n.downloadedAlbumTapToSelect,
|
||||||
style: Theme.of(context).textTheme.bodySmall
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in new issue
Block a user