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:
zarzet committed 2026-07-12 19:38:50 +07:00
1 parent 44de61a06a
commit 62015a4e2d
8 files changed
+259 -559

No files matched your search

+30 -132
View File
@@ -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);
} }
+39 -63
View File
@@ -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 {
+42 -108
View File
@@ -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 {
+16 -80
View File
@@ -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);
+68 -150
View File
@@ -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);
} }
} }
+13 -24
View File
@@ -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 {
+39
View File
@@ -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 {
+12 -2
View File
@@ -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),
), ),