mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 07:18:49 +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:
+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/string_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/album_detail_header.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
@@ -64,20 +65,17 @@ class AlbumScreen extends ConsumerStatefulWidget {
|
||||
ConsumerState<AlbumScreen> createState() => _AlbumScreenState();
|
||||
}
|
||||
|
||||
class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
class _AlbumScreenState extends ConsumerState<AlbumScreen>
|
||||
with CollapsingHeaderScrollMixin<AlbumScreen> {
|
||||
List<Track>? _tracks;
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
bool _showTitleInAppBar = false;
|
||||
String? _artistId;
|
||||
String? _albumType;
|
||||
int? _albumTotalTracks;
|
||||
String? _headerVideoUrl;
|
||||
String? _headerImageUrl;
|
||||
List<String> _audioTraits = const [];
|
||||
bool _tallHeader = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
|
||||
String _effectiveMetadataProviderIdFromAlbumId() {
|
||||
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
||||
@@ -89,13 +87,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final providerId = _effectiveMetadataProviderIdFromAlbumId();
|
||||
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 {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
@@ -258,25 +229,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
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() {
|
||||
final traits = _audioTraits
|
||||
.map((t) => t.toLowerCase().trim())
|
||||
@@ -288,15 +240,15 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
|
||||
final items = <Widget>[];
|
||||
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'])) {
|
||||
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'])) {
|
||||
items.add(_metaInlineItem(Icons.graphic_eq, 'Hi-Res Lossless'));
|
||||
items.add(HeaderMetaItem('Hi-Res Lossless', icon: Icons.graphic_eq));
|
||||
} else if (has(['lossless'])) {
|
||||
items.add(_metaInlineItem(Icons.graphic_eq, 'Lossless'));
|
||||
items.add(HeaderMetaItem('Lossless', icon: Icons.graphic_eq));
|
||||
}
|
||||
|
||||
return items;
|
||||
@@ -305,38 +257,13 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
Widget _buildHeaderMeta(BuildContext context, String? releaseDate) {
|
||||
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);
|
||||
if (year != null) {
|
||||
add(_metaInlineItem(null, year));
|
||||
}
|
||||
for (final trait in _audioTraitInline()) {
|
||||
add(trait);
|
||||
}
|
||||
if (year != null) items.add(HeaderMetaItem(year));
|
||||
items.addAll(_audioTraitInline());
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 20),
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 0,
|
||||
runSpacing: 4,
|
||||
children: items,
|
||||
),
|
||||
child: HeaderMetaRow(items: items),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -397,7 +324,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
return Scaffold(
|
||||
backgroundColor: pageBackgroundColor,
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
_buildAppBar(context, colorScheme, pageBackgroundColor),
|
||||
if (_isLoading)
|
||||
@@ -444,8 +371,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
Uri.tryParse(motionUrl)?.hasAuthority == true;
|
||||
final coverThumbUrl = widget.coverUrl ?? _headerImageUrl;
|
||||
final showSquareCover = !hasMotion;
|
||||
_tallHeader = false;
|
||||
final expandedHeight = _calculateExpandedHeight(context);
|
||||
final expandedHeight = calculateExpandedHeight(context);
|
||||
final cacheWidth = coverCacheWidthForViewport(context);
|
||||
final headerBgUrl =
|
||||
_headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl;
|
||||
@@ -470,7 +396,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
return AlbumDetailHeader(
|
||||
title: widget.albumName,
|
||||
expandedHeight: expandedHeight,
|
||||
showTitleInAppBar: _showTitleInAppBar,
|
||||
showTitleInAppBar: showTitleInAppBar,
|
||||
backgroundColor: pageBackgroundColor,
|
||||
background: hasMotion
|
||||
? MotionHeaderBanner(videoUrl: motionUrl, fallback: headerBgImage)
|
||||
@@ -656,54 +582,25 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
tracks.isNotEmpty &&
|
||||
tracks.every((t) => collectionsState.isLoved(t));
|
||||
|
||||
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: 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,
|
||||
),
|
||||
return HeaderCircleButton(
|
||||
icon: allLoved ? Icons.favorite : Icons.favorite_border,
|
||||
iconColor: allLoved ? Colors.redAccent : Colors.white,
|
||||
tooltip: allLoved
|
||||
? context.l10n.trackOptionRemoveFromLoved
|
||||
: context.l10n.tooltipLoveAll,
|
||||
onPressed: tracks == null || tracks.isEmpty
|
||||
? null
|
||||
: () => _loveAll(tracks),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAddToPlaylistButton(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: _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,
|
||||
),
|
||||
return HeaderCircleButton(
|
||||
icon: Icons.add,
|
||||
tooltip: context.l10n.tooltipAddToPlaylist,
|
||||
onPressed: _tracks == null || _tracks!.isEmpty
|
||||
? null
|
||||
: () => showAddTracksToPlaylistSheet(context, ref, _tracks!),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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/track_collection_quick_actions.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/widgets/cached_cover_image.dart';
|
||||
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
|
||||
@@ -104,7 +105,8 @@ class ArtistScreen extends ConsumerStatefulWidget {
|
||||
ConsumerState<ArtistScreen> createState() => _ArtistScreenState();
|
||||
}
|
||||
|
||||
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
class _ArtistScreenState extends ConsumerState<ArtistScreen>
|
||||
with SelectionModeMixin<ArtistScreen> {
|
||||
bool _isLoadingDiscography = false;
|
||||
List<ArtistAlbum>? _albums;
|
||||
List<ArtistAlbum>? _releases;
|
||||
@@ -119,8 +121,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
final PageController _popularPageController = PageController();
|
||||
int _popularCurrentPage = 0;
|
||||
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedAlbumIds = {};
|
||||
bool _isFetchingDiscography = false;
|
||||
List<ArtistAlbum>? _albumBucketSource;
|
||||
List<ArtistAlbum> _albumsOnlyBucket = const [];
|
||||
@@ -162,7 +162,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
return _directMetadataProviderId();
|
||||
}
|
||||
|
||||
|
||||
String _effectiveMetadataProviderIdFromArtistId() {
|
||||
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
||||
return widget.extensionId!;
|
||||
@@ -173,7 +172,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
String? _directMetadataProviderId() {
|
||||
final providerId = _effectiveMetadataProviderIdFromArtistId();
|
||||
return providerId.isEmpty ? null : providerId;
|
||||
@@ -464,10 +462,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
!_isLoadingDiscography && _error == null && albums.isNotEmpty;
|
||||
|
||||
return PopScope(
|
||||
canPop: !_isSelectionMode,
|
||||
canPop: !isSelectionMode,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop && _isSelectionMode) {
|
||||
_exitSelectionMode();
|
||||
if (!didPop && isSelectionMode) {
|
||||
exitSelectionMode();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
@@ -500,7 +498,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ErrorCard(error: _error!, colorScheme: colorScheme),
|
||||
child: ErrorCard(
|
||||
error: _error!,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_isLoadingDiscography && _error == null) ...[
|
||||
@@ -543,12 +544,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
),
|
||||
],
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: _isSelectionMode ? 120 : 32),
|
||||
child: SizedBox(height: isSelectionMode ? 120 : 32),
|
||||
),
|
||||
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
|
||||
],
|
||||
),
|
||||
if (_isSelectionMode)
|
||||
if (isSelectionMode)
|
||||
_buildSelectionBar(context, colorScheme, albums),
|
||||
],
|
||||
),
|
||||
@@ -556,45 +557,21 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _exitSelectionMode() {
|
||||
@override
|
||||
void exitSelectionMode() {
|
||||
HapticFeedback.lightImpact();
|
||||
setState(() {
|
||||
_isSelectionMode = false;
|
||||
_selectedAlbumIds.clear();
|
||||
});
|
||||
super.exitSelectionMode();
|
||||
}
|
||||
|
||||
void _enterSelectionMode(String albumId) {
|
||||
HapticFeedback.mediumImpact();
|
||||
setState(() {
|
||||
_isSelectionMode = true;
|
||||
_selectedAlbumIds.add(albumId);
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleAlbumSelection(String albumId) {
|
||||
@override
|
||||
void toggleSelection(String itemId) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() {
|
||||
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));
|
||||
});
|
||||
super.toggleSelection(itemId);
|
||||
}
|
||||
|
||||
void _deselectAll() {
|
||||
setState(() {
|
||||
_selectedAlbumIds.clear();
|
||||
selectedIds.clear();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -603,10 +580,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
ColorScheme colorScheme,
|
||||
List<ArtistAlbum> allAlbums,
|
||||
) {
|
||||
final allSelected = _selectedAlbumIds.length == allAlbums.length;
|
||||
final selectedCount = _selectedAlbumIds.length;
|
||||
final allSelected = selectedIds.length == allAlbums.length;
|
||||
final selectedCount = selectedIds.length;
|
||||
final selectedAlbums = allAlbums
|
||||
.where((a) => _selectedAlbumIds.contains(a.id))
|
||||
.where((a) => selectedIds.contains(a.id))
|
||||
.toList();
|
||||
final totalTracks = selectedAlbums.fold<int>(
|
||||
0,
|
||||
@@ -643,7 +620,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _exitSelectionMode,
|
||||
onPressed: exitSelectionMode,
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: context.l10n.dialogCancel,
|
||||
),
|
||||
@@ -684,7 +661,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
child: OutlinedButton(
|
||||
onPressed: allSelected
|
||||
? _deselectAll
|
||||
: () => _selectAll(allAlbums),
|
||||
: () => selectAll(allAlbums.map((a) => a.id)),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
@@ -719,7 +696,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _exitSelectionMode,
|
||||
onPressed: exitSelectionMode,
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: context.l10n.dialogCancel,
|
||||
),
|
||||
@@ -754,7 +731,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
TextButton(
|
||||
onPressed: allSelected
|
||||
? _deselectAll
|
||||
: () => _selectAll(allAlbums),
|
||||
: () => selectAll(allAlbums.map((a) => a.id)),
|
||||
child: Text(
|
||||
allSelected
|
||||
? context.l10n.actionDeselect
|
||||
@@ -880,7 +857,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
subtitle: context.l10n.discographySelectAlbumsSubtitle,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_enterSelectionMode(albums.first.id);
|
||||
enterSelectionMode(albums.first.id);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -913,7 +890,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
BuildContext context,
|
||||
List<ArtistAlbum> albums,
|
||||
) async {
|
||||
_exitSelectionMode();
|
||||
exitSelectionMode();
|
||||
await _downloadAlbums(context, albums);
|
||||
}
|
||||
|
||||
@@ -1308,7 +1285,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!_isSelectionMode) ...[
|
||||
if (!isSelectionMode) ...[
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
width: 52,
|
||||
@@ -1334,7 +1311,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (hasDiscography && !_isSelectionMode) ...[
|
||||
if (hasDiscography && !isSelectionMode) ...[
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
width: 52,
|
||||
@@ -1745,25 +1722,25 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
required double sectionHeight,
|
||||
bool showTypeBadge = false,
|
||||
}) {
|
||||
final isSelected = _selectedAlbumIds.contains(album.id);
|
||||
final isSelected = selectedIds.contains(album.id);
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: _isSelectionMode && isSelected,
|
||||
label: _isSelectionMode
|
||||
selected: isSelectionMode && isSelected,
|
||||
label: isSelectionMode
|
||||
? context.l10n.a11ySelectAlbum(album.name)
|
||||
: context.l10n.a11yOpenAlbum(album.name),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (_isSelectionMode) {
|
||||
_toggleAlbumSelection(album.id);
|
||||
if (isSelectionMode) {
|
||||
toggleSelection(album.id);
|
||||
} else {
|
||||
_navigateToAlbum(album);
|
||||
}
|
||||
},
|
||||
onLongPress: () {
|
||||
if (!_isSelectionMode) {
|
||||
_enterSelectionMode(album.id);
|
||||
if (!isSelectionMode) {
|
||||
enterSelectionMode(album.id);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
@@ -1809,7 +1786,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isSelectionMode)
|
||||
if (isSelectionMode)
|
||||
Positioned.fill(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
@@ -1827,7 +1804,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isSelectionMode)
|
||||
if (isSelectionMode)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
@@ -1938,7 +1915,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class _DiscographyOptionTile extends StatelessWidget {
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/widgets/album_detail_header.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.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/utils/nav_bar_inset.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/widgets/selection_action_button.dart';
|
||||
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
|
||||
@@ -50,41 +51,12 @@ class LibraryTracksFolderScreen extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _LibraryTracksFolderScreenState
|
||||
extends ConsumerState<LibraryTracksFolderScreen> {
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedKeys = {};
|
||||
extends ConsumerState<LibraryTracksFolderScreen>
|
||||
with
|
||||
SelectionModeMixin<LibraryTracksFolderScreen>,
|
||||
CollapsingHeaderScrollMixin<LibraryTracksFolderScreen> {
|
||||
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() {
|
||||
return switch (widget.mode) {
|
||||
LibraryTracksFolderMode.wishlist => Icons.bookmark,
|
||||
@@ -104,42 +76,8 @@ class _LibraryTracksFolderScreenState
|
||||
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 {
|
||||
final keysToRemove = _selectedKeys.toSet();
|
||||
final keysToRemove = selectedIds.toSet();
|
||||
if (keysToRemove.isEmpty) return;
|
||||
|
||||
final count = keysToRemove.length;
|
||||
@@ -161,7 +99,7 @@ class _LibraryTracksFolderScreenState
|
||||
}
|
||||
}
|
||||
|
||||
_exitSelectionMode();
|
||||
exitSelectionMode();
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -202,12 +140,12 @@ class _LibraryTracksFolderScreenState
|
||||
var count = 0;
|
||||
|
||||
for (final entry in entries) {
|
||||
if (!_selectedKeys.contains(entry.key)) continue;
|
||||
if (!selectedIds.contains(entry.key)) continue;
|
||||
queueNotifier.addToQueue(entry.track, service);
|
||||
count++;
|
||||
}
|
||||
|
||||
_exitSelectionMode();
|
||||
exitSelectionMode();
|
||||
|
||||
if (!mounted || count == 0) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -217,7 +155,7 @@ class _LibraryTracksFolderScreenState
|
||||
|
||||
void _addSelectedToPlaylist(List<CollectionTrackEntry> entries) {
|
||||
final selectedTracks = entries
|
||||
.where((e) => _selectedKeys.contains(e.key))
|
||||
.where((e) => selectedIds.contains(e.key))
|
||||
.map((e) => e.track)
|
||||
.toList(growable: false);
|
||||
if (selectedTracks.isEmpty) return;
|
||||
@@ -257,15 +195,7 @@ class _LibraryTracksFolderScreenState
|
||||
break;
|
||||
}
|
||||
|
||||
if (_isSelectionMode) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
pruneSelection(entries.map((e) => e.key).toSet());
|
||||
|
||||
final title = switch (widget.mode) {
|
||||
LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist,
|
||||
@@ -308,17 +238,17 @@ class _LibraryTracksFolderScreenState
|
||||
final bottomInset = context.navBarBottomInset;
|
||||
|
||||
return PopScope(
|
||||
canPop: !_isSelectionMode,
|
||||
canPop: !isSelectionMode,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop && _isSelectionMode) {
|
||||
_exitSelectionMode();
|
||||
if (!didPop && isSelectionMode) {
|
||||
exitSelectionMode();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
controller: _scrollController,
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
_buildAppBar(context, colorScheme, title, entries, playlist),
|
||||
if (entries.isEmpty)
|
||||
@@ -333,7 +263,7 @@ class _LibraryTracksFolderScreenState
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final entry = entries[index];
|
||||
final isSelected = _selectedKeys.contains(entry.key);
|
||||
final isSelected = selectedIds.contains(entry.key);
|
||||
final isInHistory = existingHistoryKeys.contains(
|
||||
historyLookups[index].lookupKey,
|
||||
);
|
||||
@@ -347,21 +277,21 @@ class _LibraryTracksFolderScreenState
|
||||
playlistId: widget.playlistId,
|
||||
folderTracks: folderTracks,
|
||||
isInHistory: isInHistory,
|
||||
isSelectionMode: _isSelectionMode,
|
||||
isSelectionMode: isSelectionMode,
|
||||
isSelected: isSelected,
|
||||
onTap: _isSelectionMode
|
||||
? () => _toggleSelection(entry.key)
|
||||
onTap: isSelectionMode
|
||||
? () => toggleSelection(entry.key)
|
||||
: null,
|
||||
onLongPress: _isSelectionMode
|
||||
onLongPress: isSelectionMode
|
||||
? null
|
||||
: () => _enterSelectionMode(entry.key),
|
||||
: () => enterSelectionMode(entry.key),
|
||||
),
|
||||
),
|
||||
);
|
||||
}, childCount: entries.length),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: _isSelectionMode ? 200 : 32),
|
||||
child: SizedBox(height: isSelectionMode ? 200 : 32),
|
||||
),
|
||||
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
|
||||
],
|
||||
@@ -372,7 +302,7 @@ class _LibraryTracksFolderScreenState
|
||||
curve: Curves.easeOutCubic,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: _isSelectionMode ? 0 : -(280 + bottomPadding),
|
||||
bottom: isSelectionMode ? 0 : -(280 + bottomPadding),
|
||||
child: _buildSelectionBottomBar(
|
||||
context,
|
||||
colorScheme,
|
||||
@@ -392,19 +322,19 @@ class _LibraryTracksFolderScreenState
|
||||
List<CollectionTrackEntry> entries,
|
||||
double bottomPadding,
|
||||
) {
|
||||
final selectedCount = _selectedKeys.length;
|
||||
final selectedCount = selectedIds.length;
|
||||
final allSelected = selectedCount == entries.length && entries.isNotEmpty;
|
||||
final isWishlist = widget.mode == LibraryTracksFolderMode.wishlist;
|
||||
|
||||
return SelectionBottomBar(
|
||||
selectedCount: selectedCount,
|
||||
allSelected: allSelected,
|
||||
onClose: _exitSelectionMode,
|
||||
onClose: exitSelectionMode,
|
||||
onToggleSelectAll: () {
|
||||
if (allSelected) {
|
||||
_exitSelectionMode();
|
||||
exitSelectionMode();
|
||||
} else {
|
||||
_selectAll(entries);
|
||||
selectAll(entries.map((e) => e.key));
|
||||
}
|
||||
},
|
||||
bottomPadding: bottomPadding,
|
||||
@@ -502,7 +432,7 @@ class _LibraryTracksFolderScreenState
|
||||
List<CollectionTrackEntry> entries,
|
||||
UserPlaylistCollection? playlist,
|
||||
) {
|
||||
final expandedHeight = _calculateExpandedHeight(context);
|
||||
final expandedHeight = calculateExpandedHeight(context);
|
||||
final customCoverPath = playlist?.coverImagePath;
|
||||
final isLovedMode = widget.mode == LibraryTracksFolderMode.loved;
|
||||
final isPlaylistMode = widget.mode == LibraryTracksFolderMode.playlist;
|
||||
@@ -563,11 +493,11 @@ class _LibraryTracksFolderScreenState
|
||||
|
||||
return AlbumDetailHeader(
|
||||
title: title,
|
||||
appBarTitle: _isSelectionMode
|
||||
? context.l10n.selectionSelected(_selectedKeys.length)
|
||||
appBarTitle: isSelectionMode
|
||||
? context.l10n.selectionSelected(selectedIds.length)
|
||||
: title,
|
||||
expandedHeight: expandedHeight,
|
||||
showTitleInAppBar: _showTitleInAppBar,
|
||||
showTitleInAppBar: showTitleInAppBar,
|
||||
background: background,
|
||||
blurAndScrimBackground: hasCustomCover || hasCoverUrl,
|
||||
coverBuilder: (context, coverSize) {
|
||||
@@ -633,7 +563,7 @@ class _LibraryTracksFolderScreenState
|
||||
)
|
||||
: null,
|
||||
appBarActions: [
|
||||
if (isPlaylistMode && !_isSelectionMode) ...[
|
||||
if (isPlaylistMode && !isSelectionMode) ...[
|
||||
IconButton(
|
||||
tooltip: context.l10n.collectionRenamePlaylist,
|
||||
icon: Container(
|
||||
@@ -672,7 +602,7 @@ class _LibraryTracksFolderScreenState
|
||||
],
|
||||
],
|
||||
leading: IconButton(
|
||||
tooltip: _isSelectionMode
|
||||
tooltip: isSelectionMode
|
||||
? MaterialLocalizations.of(context).closeButtonTooltip
|
||||
: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
icon: Container(
|
||||
@@ -682,12 +612,12 @@ class _LibraryTracksFolderScreenState
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_isSelectionMode ? Icons.close : Icons.arrow_back,
|
||||
isSelectionMode ? Icons.close : Icons.arrow_back,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
onPressed: _isSelectionMode
|
||||
? _exitSelectionMode
|
||||
onPressed: isSelectionMode
|
||||
? exitSelectionMode
|
||||
: () => Navigator.pop(context),
|
||||
),
|
||||
);
|
||||
@@ -712,7 +642,11 @@ class _LibraryTracksFolderScreenState
|
||||
}
|
||||
|
||||
void _confirmDownloadAll(List<Track> tracks) {
|
||||
confirmDownloadAllDialog(context, tracks.length, () => _downloadAll(tracks));
|
||||
confirmDownloadAllDialog(
|
||||
context,
|
||||
tracks.length,
|
||||
() => _downloadAll(tracks),
|
||||
);
|
||||
}
|
||||
|
||||
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/track_list_tile.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';
|
||||
|
||||
class PlaylistScreen extends ConsumerStatefulWidget {
|
||||
@@ -41,9 +42,8 @@ class PlaylistScreen extends ConsumerStatefulWidget {
|
||||
ConsumerState<PlaylistScreen> createState() => _PlaylistScreenState();
|
||||
}
|
||||
|
||||
class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
|
||||
with CollapsingHeaderScrollMixin<PlaylistScreen> {
|
||||
List<Track>? _fetchedTracks;
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
@@ -57,8 +57,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
String? get _headerVideoUrl =>
|
||||
_resolvedHeaderVideoUrl ?? widget.headerVideoUrl;
|
||||
|
||||
|
||||
|
||||
String? _metadataProviderId(String playlistId) {
|
||||
final providerId = legacyProviderIdFromResourceId(playlistId);
|
||||
if (providerId == null) return null;
|
||||
@@ -109,17 +107,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_fetchTracksIfNeeded();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _fetchTracksIfNeeded() async {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
_buildAppBar(context, colorScheme),
|
||||
_buildTrackList(context, colorScheme),
|
||||
@@ -212,7 +188,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
final expandedHeight = _calculateExpandedHeight(context);
|
||||
final expandedHeight = calculateExpandedHeight(context);
|
||||
final cacheWidth = coverCacheWidthForViewport(context);
|
||||
final motionUrl = _headerVideoUrl;
|
||||
final hasMotion =
|
||||
@@ -243,7 +219,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
return AlbumDetailHeader(
|
||||
title: _playlistName,
|
||||
expandedHeight: expandedHeight,
|
||||
showTitleInAppBar: _showTitleInAppBar,
|
||||
showTitleInAppBar: showTitleInAppBar,
|
||||
background: hasMotion
|
||||
? MotionHeaderBanner(videoUrl: motionUrl, fallback: 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() {
|
||||
final collectionsState = ref.watch(libraryCollectionsProvider);
|
||||
final allLoved =
|
||||
_tracks.isNotEmpty && _tracks.every((t) => collectionsState.isLoved(t));
|
||||
|
||||
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: _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,
|
||||
),
|
||||
return HeaderCircleButton(
|
||||
icon: allLoved ? Icons.favorite : Icons.favorite_border,
|
||||
iconColor: allLoved ? Colors.redAccent : Colors.white,
|
||||
tooltip: allLoved
|
||||
? context.l10n.trackOptionRemoveFromLoved
|
||||
: context.l10n.tooltipLoveAll,
|
||||
onPressed: _tracks.isEmpty ? null : () => _loveAll(_tracks),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -478,7 +413,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
}
|
||||
|
||||
Widget _buildAddToPlaylistButton(BuildContext context) {
|
||||
return _buildCircleButton(
|
||||
return HeaderCircleButton(
|
||||
icon: Icons.playlist_add,
|
||||
tooltip: context.l10n.tooltipAddToPlaylist,
|
||||
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) {
|
||||
_downloadTracks(context, _tracks);
|
||||
|
||||
@@ -336,158 +336,77 @@ extension _QueueTabSelectionActions on _QueueTabState {
|
||||
final allSelected =
|
||||
selectedCount == playlists.length && playlists.isNotEmpty;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, -4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
return SelectionBottomBar(
|
||||
selectedCount: selectedCount,
|
||||
allSelected: allSelected,
|
||||
onClose: _exitPlaylistSelectionMode,
|
||||
onToggleSelectAll: () {
|
||||
if (allSelected) {
|
||||
_exitPlaylistSelectionMode();
|
||||
} else {
|
||||
_selectAllPlaylists(playlists);
|
||||
}
|
||||
},
|
||||
bottomPadding: bottomPadding,
|
||||
allSelectedLabel: context.l10n.selectionAllPlaylistsSelected,
|
||||
tapToSelectLabel: context.l10n.selectionTapPlaylistsToSelect,
|
||||
children: [
|
||||
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),
|
||||
),
|
||||
|
||||
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 {
|
||||
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/settings_group.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_cards.dart';
|
||||
@@ -92,7 +93,8 @@ class TrackMetadataScreen extends ConsumerStatefulWidget {
|
||||
_TrackMetadataScreenState();
|
||||
}
|
||||
|
||||
class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
|
||||
with CollapsingHeaderScrollMixin<TrackMetadataScreen> {
|
||||
static const int _maxCoverPreviewCacheEntries = 96;
|
||||
static final Map<String, _EmbeddedCoverPreviewCacheEntry>
|
||||
_embeddedCoverPreviewCache = {};
|
||||
@@ -105,7 +107,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
bool _lyricsLoading = false;
|
||||
String? _lyricsError;
|
||||
String? _lyricsSource;
|
||||
bool _showTitleInAppBar = false;
|
||||
bool _lyricsEmbedded = false;
|
||||
bool _isEmbedding = false;
|
||||
bool _isInstrumental = false;
|
||||
@@ -121,7 +122,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
late int? _currentNavigationIndex;
|
||||
Map<String, dynamic>? _editedMetadata;
|
||||
String? _embeddedCoverPreviewPath;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
static final RegExp _lrcTimestampPattern = RegExp(
|
||||
r'^\[\d{2}:\d{2}\.\d{2,3}\]',
|
||||
);
|
||||
@@ -247,15 +247,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_currentDownloadItem = widget.item;
|
||||
_currentLocalLibraryItem = widget.localItem;
|
||||
_currentNavigationIndex = widget.navigationIndex;
|
||||
_scrollController.addListener(_onScroll);
|
||||
_checkFile();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_cleanupTempFileAndParentIfNotCached(_embeddedCoverPreviewPath));
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -263,16 +260,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
/// through this forwarder.
|
||||
void _setState(VoidCallback fn) => setState(fn);
|
||||
|
||||
void _onScroll() {
|
||||
final expandedHeight = _calculateExpandedHeight(context);
|
||||
final shouldShow =
|
||||
_scrollController.offset > (expandedHeight - kToolbarHeight - 20);
|
||||
if (shouldShow != _showTitleInAppBar) {
|
||||
setState(() => _showTitleInAppBar = shouldShow);
|
||||
}
|
||||
}
|
||||
|
||||
double _calculateExpandedHeight(BuildContext context) {
|
||||
@override
|
||||
double calculateExpandedHeight(BuildContext context) {
|
||||
final mediaSize = MediaQuery.sizeOf(context);
|
||||
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
||||
}
|
||||
@@ -775,7 +764,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
String? get _localCoverPath =>
|
||||
_isLocalItem ? _localLibraryItem!.coverPath : null;
|
||||
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 {
|
||||
if (_isLocalItem) {
|
||||
final modTime = _localLibraryItem!.fileModTime;
|
||||
@@ -1083,7 +1073,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_lyricsLoading = false;
|
||||
_lyricsError = null;
|
||||
_lyricsSource = null;
|
||||
_showTitleInAppBar = false;
|
||||
showTitleInAppBar = false;
|
||||
_lyricsEmbedded = false;
|
||||
_isInstrumental = false;
|
||||
_embeddedLyricsChecked = false;
|
||||
@@ -1092,8 +1082,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_embeddedCoverPreviewPath = null;
|
||||
});
|
||||
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(0);
|
||||
if (scrollController.hasClients) {
|
||||
scrollController.jumpTo(0);
|
||||
}
|
||||
|
||||
if (oldPreviewPath != null) {
|
||||
@@ -1110,7 +1100,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final expandedHeight = _calculateExpandedHeight(context);
|
||||
final expandedHeight = calculateExpandedHeight(context);
|
||||
final bottomInset = context.navBarBottomInset;
|
||||
|
||||
return GestureDetector(
|
||||
@@ -1118,7 +1108,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
onHorizontalDragEnd: _handleHorizontalDragEnd,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: expandedHeight,
|
||||
@@ -1128,7 +1118,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
||||
opacity: showTitleInAppBar ? 1.0 : 0.0,
|
||||
child: Text(
|
||||
trackName,
|
||||
style: TextStyle(
|
||||
@@ -1500,7 +1490,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
return placeholder();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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
|
||||
/// (track count, duration, quality, ...).
|
||||
class HeaderMetaRow extends StatelessWidget {
|
||||
|
||||
@@ -13,6 +13,8 @@ class SelectionBottomBar extends StatelessWidget {
|
||||
required this.onToggleSelectAll,
|
||||
required this.bottomPadding,
|
||||
required this.children,
|
||||
this.allSelectedLabel,
|
||||
this.tapToSelectLabel,
|
||||
});
|
||||
|
||||
final int selectedCount;
|
||||
@@ -22,6 +24,12 @@ class SelectionBottomBar extends StatelessWidget {
|
||||
final double bottomPadding;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -80,8 +88,10 @@ class SelectionBottomBar extends StatelessWidget {
|
||||
),
|
||||
Text(
|
||||
allSelected
|
||||
? context.l10n.selectionAllSelected
|
||||
: context.l10n.downloadedAlbumTapToSelect,
|
||||
? allSelectedLabel ??
|
||||
context.l10n.selectionAllSelected
|
||||
: tapToSelectLabel ??
|
||||
context.l10n.downloadedAlbumTapToSelect,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user