Compare commits

...
16 Commits
Author SHA1 Message Date
Your Name 29758eb9bb chore: bump version to 2.7.2-beta 2025-12-29 18:28:52 +00:00
Your NameandClaude Opus 4.5 332de9e00c perf: search screen audit fixes
- Only rebuild when clear button visibility changes (not every keystroke)
- Use context.select for connection status to avoid unnecessary rebuilds
- Cache available filters list to avoid recalculating per build
- Use theme-aware colors for filter chips (light/dark mode support)
- Remove dead code (_buildListItems method - 88 lines)
- Use safe .whereType<T>() instead of unsafe .cast<T>()

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 18:05:49 +00:00
Your Name 6f503c8540 fix: add gap between search bar and filter bar, optimize scroll to avoid animation jank 2025-12-29 17:41:45 +00:00
Your Name 9950ff2063 fix: remove container wrapper and extra padding from filter bar 2025-12-29 17:25:49 +00:00
Your Name 4c777a93e1 fix: add solid background to filter bar container 2025-12-29 17:15:54 +00:00
Your Name a96578bbf0 fix: remove gap between filter bar and results 2025-12-29 16:54:16 +00:00
Your Name 8fa95705e1 fix: keep filter bar visible when scrolling 2025-12-29 16:34:17 +00:00
Your NameandClaude Opus 4.5 9dcca2ce54 fix: improve search filter bar scroll and layout behavior
- Filter bar only scrolls when active filter would be off-screen
- Changed from Stack overlay to Column layout
- Results no longer scroll behind filter bar
- Filter bar hides on scroll along with search bar

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 16:17:02 +00:00
Your NameandClaude Opus 4.5 4152c3d67f perf(#23): use ListView.builder for player list with many players
- Only builds visible player cards (lazy loading)
- Uses itemExtent for optimal scroll performance
- Reduces animation work from O(n) to O(visible) per frame
- Should fix choppy animation reported by shpongledsummer

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 14:42:10 +00:00
Your NameandClaude Opus 4.5 d84fb58a5c fix: only allow swipe down for dismiss, ignore swipe up
Swipe up was incorrectly moving cards down because delta negation
was inverting the direction. Now:
- Swipe up (finger up): ignored when not scrollable
- Swipe down (finger down): triggers dismiss gesture

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 13:50:57 +00:00
Your NameandClaude Opus 4.5 128d417014 feat(#23): smart scroll for player list overflow
- Add scroll when player list exceeds 80% of available screen height
- Swipe down dismisses only when scrolled to top
- When scrolled down, swipe gestures scroll the list instead
- No behavior change for small player counts (5-6 players)

This matches the common pattern used by Instagram, Apple Maps, etc.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 13:35:49 +00:00
Your NameandClaude Opus 4.5 176fa5c8b9 fix: revert player list scrollability changes
The scrollability changes were interfering with the drag-to-dismiss
gesture. For normal player counts (5-6), scrolling isn't needed.
Issue #23 (many players overflow) needs a different approach.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 13:04:38 +00:00
Your NameandClaude Opus 4.5 ccaf764f52 Add missing player_picker_sheet.dart from feature branch
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 12:45:37 +00:00
Your NameandClaude Opus 4.5 ccda231fb2 Fix GitHub issues #27, #23, add hero animations to search
## Issue Fixes

### #27 - DLNA external source detection
- Add app_id check as primary indicator for external sources
- When app_id != 'music_assistant', mark as external source
- Keeps existing URI-based detection as fallback

### #23 - Speaker selection scrollability
- Add top constraint to player overlay for max height
- Wrap player list in SingleChildScrollView with Flexible
- Enable scrolling when more than 5 players (BouncingScrollPhysics)
- Prevents player cards from overflowing on devices with many speakers

### #21 - Auto-selection debugging
- Add detailed logging when Priority 2 (local player) fails
- Log whether local player is missing from list or not available
- Log when builtinPlayerId is null

## Enhancements

### Hero animations for search results
- Add hero animations to album, artist, and audiobook tiles
- Use FadeSlidePageRoute for smooth transitions
- Use 'search' suffix for hero tags to avoid conflicts
- Update adaptive colors on navigation tap

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 12:34:13 +00:00
Your NameandClaude Opus 4.5 0f82829172 Fix action button consistency on artist details screen
- Radio button now shows player selection popup (consistent with search/album)
- Add to Queue button now shows player selection popup (was broken - did nothing without selected player)
- Removed redundant "Play On" button (Radio now provides the same functionality)
- Renamed _showPlayOnMenu to _showRadioMenu for clarity
- Removed unused _startArtistRadio method

Artist details header now has: [Radio] [Add to Queue] [Favorite]
All buttons work consistently with popup selection, matching search/album behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 11:53:16 +00:00
Your NameandClaude Opus 4.5 0ccde33c85 Standardize bottom sheet implementations across all screens
- All sheets now use consistent pattern: transparent background with
  Container decoration, mainAxisSize.min, Flexible with shrinkWrap
- All sheets hide mini player before showing and restore on dismiss
- All sheets include safe area bottom padding
- Fixed album_details_screen _showPlayOnSheet (was using fixed 45% height)
- Fixed artist_details_screen _showPlayOnMenu (was missing hidePlayer/showPlayer)
- Fixed audiobook_detail_screen _showPlayOnMenu (was using fixed 45% height)
- Added safe area padding to search_screen sheets

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 11:40:49 +00:00
9 changed files with 1137 additions and 541 deletions
+14 -2
View File
@@ -197,9 +197,21 @@ class Player {
final activeSource = json['active_source'] as String?;
// Detect external source (optical, Spotify, AirPlay, etc.)
// External sources have non-MA URIs and often media_type='unknown'
// Primary indicator: app_id - if not 'music_assistant', it's an external source
// Secondary indicators: URI patterns and media_type
bool isExternalSource = false;
if (json.containsKey('current_media')) {
// Check app_id first - this is the most reliable indicator for DLNA players
// When MA is playing: app_id = 'music_assistant'
// When external source: app_id = 'http', 'spotify', etc.
final appId = json['app_id'] as String?;
if (appId != null && appId.isNotEmpty && appId != 'music_assistant') {
// app_id is set but not 'music_assistant' - external source is active
isExternalSource = true;
}
// Also check current_media for additional external source indicators
if (!isExternalSource && json.containsKey('current_media')) {
final currentMedia = json['current_media'] as Map<String, dynamic>?;
if (currentMedia != null) {
final uri = currentMedia['uri'] as String?;
+13 -1
View File
@@ -2415,7 +2415,19 @@ class MusicAssistantProvider with ChangeNotifier {
(p) => p.playerId == builtinPlayerId && p.available,
);
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
} catch (e) {}
} catch (e) {
// Local player not found or not available - check why
final localPlayer = _availablePlayers
.where((p) => p.playerId == builtinPlayerId)
.toList();
if (localPlayer.isEmpty) {
_logger.log('⚠️ Priority 2 skipped: local player ($builtinPlayerId) not in available players list');
} else {
_logger.log('⚠️ Priority 2 skipped: local player found but available=${localPlayer.first.available}');
}
}
} else if (playerToSelect == null && builtinPlayerId == null) {
_logger.log('⚠️ Priority 2 skipped: builtinPlayerId is null');
}
}
+11 -13
View File
@@ -558,7 +558,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
},
),
),
const SizedBox(height: 16),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
@@ -1132,7 +1132,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
);
}
/// Shared "Play on..." bottom sheet with fixed height and scrollable list
/// Shared "Play on..." bottom sheet that sizes to content
void _showPlayOnSheet(
BuildContext context,
List players, {
@@ -1141,21 +1141,16 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
final screenHeight = MediaQuery.of(context).size.height;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final sheetHeight = (screenHeight * 0.45) + bottomPadding; // Fixed 45% + safe area
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => Container(
height: sheetHeight,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Text(
@@ -1163,13 +1158,15 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Expanded(
child: players.isEmpty
? Center(
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
: ListView.builder(
padding: EdgeInsets.only(bottom: bottomPadding + 16),
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
@@ -1187,6 +1184,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
},
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
+75 -75
View File
@@ -243,57 +243,25 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
}
Future<void> _startArtistRadio() async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
try {
await maProvider.playArtistRadio(selectedPlayer.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.startingRadio(widget.artist.name)),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error starting artist radio: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
}
void _showPlayOnMenu(BuildContext context) {
void _showRadioMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
final colorScheme = Theme.of(context).colorScheme;
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
Text(
S.of(context)!.startRadioOn(widget.artist.name),
style: Theme.of(context).textTheme.titleLarge,
@@ -314,7 +282,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
return ListTile(
leading: Icon(
Icons.speaker,
color: colorScheme.onSurface,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
@@ -345,35 +313,71 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
},
),
),
const SizedBox(height: 16),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
);
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
}
Future<void> _addArtistRadioToQueue() async {
void _showAddToQueueMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
final players = maProvider.availablePlayers;
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
final api = maProvider.api;
if (api == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.notConnected)),
);
}
return;
}
try {
// For adding to queue with radio mode, we use radio_mode but with 'add' option
await api.playArtistRadioToQueue(selectedPlayer.playerId, widget.artist);
await api.playArtistRadioToQueue(player.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -393,6 +397,19 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
);
}
}
},
);
},
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
}
Future<void> _loadArtistImage() async {
@@ -635,7 +652,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
onPressed: _startArtistRadio,
onPressed: () => _showRadioMenu(context),
icon: const Icon(Icons.radio),
label: Text(S.of(context)!.radio),
style: ElevatedButton.styleFrom(
@@ -648,23 +665,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
),
const SizedBox(width: 12),
// "Play on..." Button (Square)
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: () => _showPlayOnMenu(context),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
),
),
const SizedBox(width: 12),
@@ -673,7 +673,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _addArtistRadioToQueue,
onPressed: () => _showAddToQueueMenu(context),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
+13 -11
View File
@@ -211,23 +211,19 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
final screenHeight = MediaQuery.of(context).size.height;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final sheetHeight = (screenHeight * 0.45) + bottomPadding;
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => Container(
height: sheetHeight,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Text(
@@ -235,11 +231,15 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Expanded(
child: players.isEmpty
? Center(child: Text(S.of(context)!.noPlayersAvailable))
: ListView.builder(
padding: EdgeInsets.only(bottom: bottomPadding + 16),
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
@@ -262,10 +262,12 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
},
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
}
+426 -201
View File
@@ -8,9 +8,13 @@ import '../models/media_item.dart';
import '../services/debug_logger.dart';
import '../services/database_service.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../widgets/common/empty_state.dart';
import '../widgets/common/disconnected_state.dart';
import '../widgets/artist_avatar.dart';
import '../constants/hero_tags.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import 'album_details_screen.dart';
import 'artist_details_screen.dart';
import 'playlist_details_screen.dart';
@@ -76,6 +80,8 @@ class SearchScreenState extends State<SearchScreen> {
final _logger = DebugLogger();
final TextEditingController _searchController = TextEditingController();
final FocusNode _focusNode = FocusNode();
final PageController _pageController = PageController();
final ScrollController _filterScrollController = ScrollController();
Timer? _debounceTimer;
Map<String, List<MediaItem>> _searchResults = {
'artists': [],
@@ -83,6 +89,7 @@ class SearchScreenState extends State<SearchScreen> {
'tracks': [],
'playlists': [],
'audiobooks': [],
'podcasts': [],
};
bool _isSearching = false;
bool _hasSearched = false;
@@ -91,6 +98,17 @@ class SearchScreenState extends State<SearchScreen> {
List<String> _recentSearches = [];
bool _libraryOnly = false;
String? _expandedTrackId; // Track ID for expanded quick actions
bool _hasSearchText = false; // PERF: Track separately to avoid rebuild on every keystroke
// PERF: Cache list items per filter to avoid rebuilding during PageView animation
Map<String, List<_ListItem>> _cachedListItems = {};
// PERF: Cache available filters to avoid recalculating during build
List<String>? _cachedAvailableFilters;
// Scroll-to-hide search bar (vertical scroll only)
bool _isSearchBarVisible = true;
double _lastVerticalScrollOffset = 0;
static const double _scrollThreshold = 10.0;
@override
void initState() {
@@ -100,6 +118,99 @@ class SearchScreenState extends State<SearchScreen> {
_loadRecentSearches();
}
/// Get list of available filters based on current results (cached)
List<String> _getAvailableFilters() {
if (_cachedAvailableFilters != null) return _cachedAvailableFilters!;
final filters = <String>['all'];
if (_searchResults['artists']?.isNotEmpty == true) filters.add('artists');
if (_searchResults['albums']?.isNotEmpty == true) filters.add('albums');
if (_searchResults['tracks']?.isNotEmpty == true) filters.add('tracks');
if (_searchResults['playlists']?.isNotEmpty == true) filters.add('playlists');
if (_searchResults['audiobooks']?.isNotEmpty == true) filters.add('audiobooks');
// Always show podcasts filter (placeholder until fully integrated)
filters.add('podcasts');
_cachedAvailableFilters = filters;
return filters;
}
/// Animate to a specific filter
void _animateToFilter(String filter) {
final filters = _getAvailableFilters();
final index = filters.indexOf(filter);
if (index >= 0 && _pageController.hasClients) {
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
/// Handle page change from swipe gesture
void _onPageChanged(int pageIndex) {
final filters = _getAvailableFilters();
if (pageIndex >= 0 && pageIndex < filters.length) {
setState(() {
_activeFilter = filters[pageIndex];
});
_scrollFilterIntoView(pageIndex);
}
}
/// Scroll filter bar to keep active filter visible
/// Only scrolls when the filter would be obscured (off-screen)
/// Uses post-frame callback to avoid interfering with PageView animation
void _scrollFilterIntoView(int filterIndex) {
if (!_filterScrollController.hasClients) return;
// Defer scroll calculation to after the frame to avoid layout during animation
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_filterScrollController.hasClients) return;
final maxScroll = _filterScrollController.position.maxScrollExtent;
// If all filters fit on screen, don't scroll at all
if (maxScroll <= 0) return;
// Approximate width per filter chip (padding + text)
const chipWidth = 80.0;
const horizontalPadding = 16.0;
final currentScroll = _filterScrollController.offset;
final viewportWidth = _filterScrollController.position.viewportDimension;
// Calculate the left and right edges of the active filter chip
final chipLeft = (filterIndex * chipWidth);
final chipRight = chipLeft + chipWidth;
// Calculate what's currently visible in the viewport
final visibleLeft = currentScroll;
final visibleRight = currentScroll + viewportWidth - (horizontalPadding * 2);
// Only scroll if the chip is actually obscured
double? targetOffset;
if (chipRight > visibleRight) {
// Chip is cut off on the right - scroll right just enough to show it
targetOffset = chipRight - viewportWidth + (horizontalPadding * 2);
} else if (chipLeft < visibleLeft) {
// Chip is cut off on the left - scroll left just enough to show it
targetOffset = chipLeft;
}
// Only animate if we need to scroll
if (targetOffset != null) {
final clampedOffset = targetOffset.clamp(0.0, maxScroll);
_filterScrollController.animateTo(
clampedOffset,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
);
}
});
}
Future<void> _loadRecentSearches() async {
if (!DatabaseService.instance.isInitialized) return;
final searches = await DatabaseService.instance.getRecentSearches();
@@ -127,9 +238,35 @@ class SearchScreenState extends State<SearchScreen> {
_debounceTimer?.cancel();
_searchController.dispose();
_focusNode.dispose();
_pageController.dispose();
_filterScrollController.dispose();
super.dispose();
}
/// Handle scroll notifications - only vertical scroll hides search bar
bool _handleScrollNotification(ScrollNotification notification) {
// Only respond to vertical scroll (not horizontal PageView swipe)
if (notification.metrics.axis != Axis.vertical) {
return false;
}
if (notification is ScrollUpdateNotification) {
final currentOffset = notification.metrics.pixels;
final delta = currentOffset - _lastVerticalScrollOffset;
if (delta.abs() > _scrollThreshold) {
final shouldShow = delta < 0 || currentOffset <= 0;
if (shouldShow != _isSearchBarVisible) {
setState(() {
_isSearchBarVisible = shouldShow;
});
}
_lastVerticalScrollOffset = currentOffset;
}
}
return false;
}
void _onSearchChanged(String query) {
_debounceTimer?.cancel();
_debounceTimer = Timer(Timings.searchDebounce, () {
@@ -140,9 +277,11 @@ class SearchScreenState extends State<SearchScreen> {
Future<void> _performSearch(String query, {bool keepFocus = false}) async {
if (query.isEmpty) {
setState(() {
_searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
_searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'podcasts': []};
_hasSearched = false;
_searchError = null;
_cachedListItems.clear(); // PERF: Clear cache
_cachedAvailableFilters = null;
});
return;
}
@@ -161,6 +300,8 @@ class SearchScreenState extends State<SearchScreen> {
_searchResults = results;
_isSearching = false;
_hasSearched = true;
_cachedListItems.clear(); // PERF: Clear cache on new results
_cachedAvailableFilters = null;
});
// Save to search history if we got results
@@ -192,45 +333,79 @@ class SearchScreenState extends State<SearchScreen> {
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
// PERF: Only rebuild when connection status changes, not on every provider update
final isConnected = context.select<MusicAssistantProvider, bool>((p) => p.isConnected);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colorScheme.background,
appBar: AppBar(
backgroundColor: colorScheme.surface,
elevation: 0,
title: TextField(
body: SafeArea(
child: Column(
children: [
// Animated search bar - hides on scroll down
AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
height: _isSearchBarVisible ? kToolbarHeight + 16 : 0,
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
// Search field
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 16),
child: TextField(
controller: _searchController,
focusNode: _focusNode,
style: TextStyle(color: colorScheme.onSurface),
cursorColor: colorScheme.primary,
textInputAction: TextInputAction.search,
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
hintText: S.of(context)!.searchMusic,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
border: InputBorder.none,
suffixIcon: _searchController.text.isNotEmpty
suffixIcon: _hasSearchText
? IconButton(
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
onPressed: () {
_searchController.clear();
setState(() {
_searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
_hasSearchText = false;
_searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'podcasts': []};
_hasSearched = false;
_searchError = null;
_cachedListItems.clear();
_cachedAvailableFilters = null;
});
},
)
: null,
),
onChanged: (value) {
setState(() {}); // Update clear button visibility
// PERF: Only rebuild when clear button visibility changes
final hasText = value.isNotEmpty;
if (hasText != _hasSearchText) {
setState(() {
_hasSearchText = hasText;
});
}
_onSearchChanged(value);
},
onSubmitted: (query) => _performSearch(query),
),
actions: [
),
),
// Library-only toggle
Tooltip(
message: S.of(context)!.libraryOnly,
@@ -243,7 +418,6 @@ class SearchScreenState extends State<SearchScreen> {
setState(() {
_libraryOnly = !_libraryOnly;
});
// Re-search if there's a query
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
}
@@ -252,9 +426,18 @@ class SearchScreenState extends State<SearchScreen> {
),
],
),
body: !maProvider.isConnected
),
),
),
// Main content
Expanded(
child: !isConnected
? DisconnectedState.simple(context)
: _buildSearchContent(),
),
],
),
),
);
}
@@ -369,53 +552,51 @@ class SearchScreenState extends State<SearchScreen> {
return EmptyState.search(context: context);
}
// Column layout - filter bar above results, no overlay
return Column(
children: [
// Filters
SingleChildScrollView(
// Filter tabs - always visible
Padding(
padding: const EdgeInsets.only(top: 8),
child: SingleChildScrollView(
controller: _filterScrollController,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
_buildFilterChip(S.of(context)!.all, 'all'),
const SizedBox(width: 8),
if (artists.isNotEmpty) ...[
_buildFilterChip(S.of(context)!.artists, 'artists'),
const SizedBox(width: 8),
],
if (albums.isNotEmpty) ...[
_buildFilterChip(S.of(context)!.albums, 'albums'),
const SizedBox(width: 8),
],
if (tracks.isNotEmpty) ...[
_buildFilterChip(S.of(context)!.tracks, 'tracks'),
const SizedBox(width: 8),
],
if (playlists.isNotEmpty) ...[
_buildFilterChip(S.of(context)!.playlists, 'playlists'),
const SizedBox(width: 8),
],
if (audiobooks.isNotEmpty) ...[
_buildFilterChip(S.of(context)!.audiobooks, 'audiobooks'),
],
],
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _buildFilterSelector(colorScheme),
),
),
// Results
// Results with swipeable pages
Expanded(
child: Builder(
builder: (context) {
final listItems = _buildListItems(artists, albums, tracks, playlists, audiobooks);
return ListView.builder(
padding: EdgeInsets.fromLTRB(16, 8, 16, BottomSpacing.navBarOnly),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
child: NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: PageView.builder(
controller: _pageController,
onPageChanged: _onPageChanged,
itemCount: _getAvailableFilters().length,
itemBuilder: (context, pageIndex) {
final filters = _getAvailableFilters();
final filterForPage = filters[pageIndex];
// PERF: Use cached list items to avoid rebuilding during animation
final listItems = _cachedListItems[filterForPage] ??= _buildListItemsForFilter(
filterForPage, artists, albums, tracks, playlists, audiobooks,
);
// PERF: Wrap each page in RepaintBoundary to isolate repaints during swipe
return RepaintBoundary(
key: ValueKey('page_$filterForPage'),
child: ListView.builder(
// PERF: Use key to preserve scroll position per filter
key: PageStorageKey('list_$filterForPage'),
padding: EdgeInsets.fromLTRB(16, 0, 16, BottomSpacing.navBarOnly),
cacheExtent: 500,
addAutomaticKeepAlives: false,
// PERF: false because each tile already has RepaintBoundary
addRepaintBoundaries: false,
itemCount: listItems.length,
itemBuilder: (context, index) {
final item = listItems[index];
final showTypeInSubtitle = _activeFilter == 'all';
final showTypeInSubtitle = filterForPage == 'all';
switch (item.type) {
case _ListItemType.header:
return _buildSectionHeader(item.headerTitle!, item.headerCount!);
@@ -433,10 +614,12 @@ class SearchScreenState extends State<SearchScreen> {
return const SizedBox(height: 24);
}
},
),
);
},
),
),
),
],
);
}
@@ -567,7 +750,9 @@ class SearchScreenState extends State<SearchScreen> {
return crossRefArtists;
}
List<_ListItem> _buildListItems(
/// Build list items for a specific filter (used by PageView)
List<_ListItem> _buildListItemsForFilter(
String filter,
List<MediaItem> artists,
List<MediaItem> albums,
List<MediaItem> tracks,
@@ -578,10 +763,9 @@ class SearchScreenState extends State<SearchScreen> {
final query = _searchController.text;
// For 'all' filter, create unified relevance-sorted list
if (_activeFilter == 'all') {
if (filter == 'all') {
final scoredItems = <_ListItem>[];
// Score and add all items
for (final artist in artists) {
final score = _calculateRelevanceScore(artist, query);
scoredItems.add(_ListItem.artist(artist, relevanceScore: score));
@@ -603,51 +787,46 @@ class SearchScreenState extends State<SearchScreen> {
scoredItems.add(_ListItem.audiobook(audiobook, relevanceScore: score));
}
// Smart cross-referencing: Add artists from matched tracks/albums
// if the artist name matches the query but wasn't in direct results
final crossRefArtists = _extractCrossReferencedArtists(
query,
artists.cast<Artist>(),
albums.cast<Album>(),
tracks.cast<Track>(),
artists.whereType<Artist>().toList(),
albums.whereType<Album>().toList(),
tracks.whereType<Track>().toList(),
);
for (final artist in crossRefArtists) {
// Give cross-referenced artists a lower score (25) since they're indirect matches
scoredItems.add(_ListItem.artist(artist, relevanceScore: 25));
}
// Sort by relevance score descending
scoredItems.sort((a, b) => (b.relevanceScore ?? 0).compareTo(a.relevanceScore ?? 0));
return scoredItems;
}
// For specific type filters, use sectioned view (no headers needed for single type)
if (_activeFilter == 'artists' && artists.isNotEmpty) {
// For specific type filters
if (filter == 'artists' && artists.isNotEmpty) {
for (final artist in artists) {
items.add(_ListItem.artist(artist));
}
}
if (_activeFilter == 'albums' && albums.isNotEmpty) {
if (filter == 'albums' && albums.isNotEmpty) {
for (final album in albums) {
items.add(_ListItem.album(album));
}
}
if (_activeFilter == 'tracks' && tracks.isNotEmpty) {
if (filter == 'tracks' && tracks.isNotEmpty) {
for (final track in tracks) {
items.add(_ListItem.track(track));
}
}
if (_activeFilter == 'playlists' && playlists.isNotEmpty) {
if (filter == 'playlists' && playlists.isNotEmpty) {
for (final playlist in playlists) {
items.add(_ListItem.playlist(playlist));
}
}
if (_activeFilter == 'audiobooks' && audiobooks.isNotEmpty) {
if (filter == 'audiobooks' && audiobooks.isNotEmpty) {
for (final audiobook in audiobooks) {
items.add(_ListItem.audiobook(audiobook));
}
@@ -656,25 +835,58 @@ class SearchScreenState extends State<SearchScreen> {
return items;
}
Widget _buildFilterChip(String label, String value) {
final isSelected = _activeFilter == value;
final colorScheme = Theme.of(context).colorScheme;
/// Build joined segmented filter selector (like library media type selector)
Widget _buildFilterSelector(ColorScheme colorScheme) {
final filters = _getAvailableFilters();
final l10n = S.of(context)!;
return FilterChip(
label: Text(label),
selected: isSelected,
showCheckmark: false,
onSelected: (selected) {
String getLabel(String filter) {
switch (filter) {
case 'all': return l10n.all;
case 'artists': return l10n.artists;
case 'albums': return l10n.albums;
case 'tracks': return l10n.tracks;
case 'playlists': return l10n.playlists;
case 'audiobooks': return l10n.audiobooks;
case 'podcasts': return l10n.podcasts;
default: return filter;
}
}
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: filters.map((filter) {
final isSelected = _activeFilter == filter;
return Material(
// Use theme-aware colors for light/dark mode support
color: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceVariant.withOpacity(0.6),
child: InkWell(
onTap: () {
setState(() {
_activeFilter = value;
_activeFilter = filter;
});
_animateToFilter(filter);
},
backgroundColor: colorScheme.surfaceVariant.withOpacity(0.3),
selectedColor: colorScheme.primaryContainer,
side: BorderSide.none,
labelStyle: TextStyle(
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Text(
getLabel(filter),
style: TextStyle(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant.withOpacity(0.8),
fontSize: 14,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
),
),
),
),
);
}).toList(),
),
);
}
@@ -698,14 +910,21 @@ class SearchScreenState extends State<SearchScreen> {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(artist, size: 256);
// Use 'search' suffix to avoid hero tag conflicts with library cards
const heroSuffix = '_search';
final artistId = artist.uri ?? artist.itemId;
return RepaintBoundary(
child: ListTile(
key: ValueKey(artist.uri ?? artist.itemId),
leading: ArtistAvatar(
key: ValueKey(artistId),
leading: Hero(
tag: HeroTags.artistImage + artistId + heroSuffix,
child: ArtistAvatar(
artist: artist,
radius: 24,
imageSize: 128,
),
),
title: Text(
artist.name,
style: TextStyle(
@@ -720,11 +939,14 @@ class SearchScreenState extends State<SearchScreen> {
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
onTap: () {
// Update adaptive colors before navigation
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ArtistDetailsScreen(
FadeSlidePageRoute(
child: ArtistDetailsScreen(
artist: artist,
heroTagSuffix: 'search',
initialImageUrl: imageUrl,
),
),
@@ -742,10 +964,16 @@ class SearchScreenState extends State<SearchScreen> {
? '${album.artistsString} • ${S.of(context)!.albumSingular}'
: album.artistsString;
// Use 'search' suffix to avoid hero tag conflicts with library cards
const heroSuffix = '_search';
final albumId = album.uri ?? album.itemId;
return RepaintBoundary(
child: ListTile(
key: ValueKey(album.uri ?? album.itemId),
leading: Container(
key: ValueKey(albumId),
leading: Hero(
tag: HeroTags.albumCover + albumId + heroSuffix,
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
@@ -762,7 +990,12 @@ class SearchScreenState extends State<SearchScreen> {
? Icon(Icons.album_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
title: Text(
),
title: Hero(
tag: HeroTags.albumTitle + albumId + heroSuffix,
child: Material(
color: Colors.transparent,
child: Text(
album.nameWithYear,
style: TextStyle(
color: colorScheme.onSurface,
@@ -771,18 +1004,29 @@ class SearchScreenState extends State<SearchScreen> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
),
),
subtitle: Hero(
tag: HeroTags.artistName + albumId + heroSuffix,
child: Material(
color: Colors.transparent,
child: Text(
subtitleText,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
onTap: () {
// Update adaptive colors before navigation
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AlbumDetailsScreen(
FadeSlidePageRoute(
child: AlbumDetailsScreen(
album: album,
heroTagSuffix: 'search',
initialImageUrl: imageUrl,
),
),
@@ -871,12 +1115,12 @@ class SearchScreenState extends State<SearchScreen> {
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Radio button
// Radio button (uses current player - 1 tap)
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showRadioMenu(track),
onPressed: () => _playRadio(track),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
@@ -887,12 +1131,28 @@ class SearchScreenState extends State<SearchScreen> {
),
),
const SizedBox(width: 10),
// Add to queue button
// Radio On button (pick player - 2 taps)
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showAddToQueueMenu(track),
onPressed: () => _showRadioOnMenu(track),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined, size: 20),
),
),
const SizedBox(width: 10),
// Add to queue button (uses current player - 1 tap)
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _addToQueue(track),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
@@ -1008,10 +1268,16 @@ class SearchScreenState extends State<SearchScreen> {
? '$authorText • ${S.of(context)!.audiobookSingular}'
: authorText;
// Use 'search' suffix to avoid hero tag conflicts with library cards
const heroSuffix = '_search';
final audiobookId = audiobook.uri ?? audiobook.itemId;
return RepaintBoundary(
child: ListTile(
key: ValueKey(audiobook.uri ?? audiobook.itemId),
leading: Container(
key: ValueKey(audiobookId),
leading: Hero(
tag: HeroTags.audiobookCover + audiobookId + heroSuffix,
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
@@ -1028,7 +1294,12 @@ class SearchScreenState extends State<SearchScreen> {
? Icon(Icons.headphones_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
title: Text(
),
title: Hero(
tag: HeroTags.audiobookTitle + audiobookId + heroSuffix,
child: Material(
color: Colors.transparent,
child: Text(
audiobook.name,
style: TextStyle(
color: colorScheme.onSurface,
@@ -1037,6 +1308,8 @@ class SearchScreenState extends State<SearchScreen> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
subtitle: Text(
subtitleText,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
@@ -1050,11 +1323,14 @@ class SearchScreenState extends State<SearchScreen> {
)
: null,
onTap: () {
// Update adaptive colors before navigation
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AudiobookDetailScreen(
FadeSlidePageRoute(
child: AudiobookDetailScreen(
audiobook: audiobook,
heroTagSuffix: 'search',
initialImageUrl: imageUrl,
),
),
@@ -1081,51 +1357,19 @@ class SearchScreenState extends State<SearchScreen> {
);
}
void _showRadioMenu(Track track) {
/// Play track radio on current player (1 tap)
Future<void> _playRadio(Track track) async {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
final player = maProvider.selectedPlayer;
GlobalPlayerOverlay.hidePlayer();
if (player == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Text(
S.of(context)!.playOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
try {
maProvider.selectPlayer(player);
await maProvider.playRadio(player.playerId, track);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -1142,63 +1386,56 @@ class SearchScreenState extends State<SearchScreen> {
);
}
}
},
}
/// Show player picker for Radio On (2 taps)
void _showRadioOnMenu(Track track) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async {
try {
maProvider.selectPlayer(player);
await maProvider.playRadio(player.playerId, track);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.startingRadioOnPlayer(track.name, player.name)),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.failedToStartRadio(e.toString()))),
);
}
}
},
),
),
const SizedBox(height: 16),
],
),
),
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
}
void _showAddToQueueMenu(Track track) {
/// Add track to queue on current player (1 tap)
Future<void> _addToQueue(Track track) async {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
final player = maProvider.selectedPlayer;
GlobalPlayerOverlay.hidePlayer();
if (player == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
try {
await maProvider.addTrackToQueue(player.playerId, track);
if (mounted) {
@@ -1216,18 +1453,6 @@ class SearchScreenState extends State<SearchScreen> {
);
}
}
},
);
},
),
),
const SizedBox(height: 16),
],
),
),
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
}
Future<void> _toggleTrackFavorite(Track track) async {
+126 -5
View File
@@ -44,6 +44,10 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
double _dragOffset = 0;
bool _isDragging = false;
// Scroll controller for when player list overflows
final ScrollController _scrollController = ScrollController();
bool _isScrollable = false;
// Cache extracted colors per player for per-device accent colors
final Map<String, ColorScheme?> _playerColors = {};
@@ -128,6 +132,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
void dispose() {
_revealController.dispose();
_refreshTimer?.cancel();
_scrollController.dispose();
super.dispose();
}
@@ -157,19 +162,51 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
});
}
/// Check if scroll is at top (or not scrollable)
bool get _isAtTop {
if (!_isScrollable) return true;
if (!_scrollController.hasClients) return true;
return _scrollController.offset <= 0;
}
void _handleVerticalDragUpdate(DragUpdateDetails details) {
final delta = details.primaryDelta ?? 0;
// If scrollable and not at top, let scroll handle it
if (_isScrollable && !_isAtTop) {
// Scroll the list
if (_scrollController.hasClients) {
final newOffset = _scrollController.offset - delta;
_scrollController.jumpTo(newOffset.clamp(0.0, _scrollController.position.maxScrollExtent));
}
return;
}
// At top: check if trying to scroll down (view more) or dismiss
if (_isScrollable && delta < 0 && _scrollController.hasClients) {
// Swiping up (delta negative) = scroll down to see more players
final newOffset = _scrollController.offset - delta;
_scrollController.jumpTo(newOffset.clamp(0.0, _scrollController.position.maxScrollExtent));
return;
}
// Only process dismiss gesture when swiping DOWN (positive delta = finger moving down)
// Ignore swipe up when not scrollable - cards should stay fixed
if (delta <= 0) return;
// Swiping down = dismiss gesture
setState(() {
_isDragging = true;
// Negate delta so swipe down = cards move down (toward dismissal)
_dragOffset -= details.primaryDelta ?? 0;
// Clamp: allow moving down (positive) freely, limit moving up to 20px
_dragOffset = _dragOffset.clamp(-20.0, double.infinity);
_dragOffset += delta;
});
}
void _handleVerticalDragEnd(DragEndDetails details) {
final velocity = details.primaryVelocity ?? 0;
// If not dragging (was scrolling), don't dismiss
if (!_isDragging) return;
// Dismiss if dragged down enough or with enough velocity
if (_dragOffset > 100 || velocity > 500) {
HapticFeedback.lightImpact();
@@ -215,6 +252,21 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
const cardSpacing = 12.0;
final totalStackHeight = players.length * cardHeight + (players.length - 1) * cardSpacing;
// Calculate max available height (80% of screen minus mini player area)
final screenHeight = MediaQuery.of(context).size.height;
final statusBarHeight = MediaQuery.of(context).padding.top;
final bottomPadding = widget.miniPlayerBottom + widget.miniPlayerHeight + 20;
final maxListHeight = (screenHeight - statusBarHeight - bottomPadding) * 0.8;
// Determine if scrolling is needed
final needsScroll = totalStackHeight > maxListHeight;
// Update scrollable state (used by gesture handlers)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isScrollable != needsScroll) {
_isScrollable = needsScroll;
}
});
return AnimatedBuilder(
animation: _revealAnimation,
builder: (context, child) {
@@ -289,7 +341,76 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
);
},
),
// Build player cards - all slide from behind mini player
// Build player cards - scrollable when overflow, otherwise static
// Using ListView.builder for many players: only builds visible items
// This significantly improves animation performance with 10+ players
if (needsScroll)
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxListHeight),
child: ListView.builder(
controller: _scrollController,
physics: const ClampingScrollPhysics(),
itemCount: players.length,
// Fixed height per item for optimal scroll performance
itemExtent: cardHeight + cardSpacing,
itemBuilder: (context, index) {
final player = players[index];
final isPlaying = player.state == 'playing';
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
String? albumArtUrl;
if (playerTrack != null && player.available && player.powered) {
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
}
final playerColorScheme = _playerColors[player.playerId];
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
// Animation: slide from behind mini player
// Only calculate for items that will be built (visible ones)
const baseOffset = 80.0;
final reverseIndex = players.length - 1 - index;
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
final slideOffset = distanceToTravel * (1.0 - t);
return Transform.translate(
offset: Offset(0, slideOffset),
child: Padding(
padding: const EdgeInsets.only(bottom: cardSpacing),
child: PlayerCard(
player: player,
trackInfo: playerTrack,
albumArtUrl: albumArtUrl,
isSelected: false,
isPlaying: isPlaying,
isGrouped: player.isGrouped,
backgroundColor: cardBgColor,
textColor: cardTextColor,
onTap: () {
HapticFeedback.mediumImpact();
maProvider.selectPlayer(player);
dismiss();
},
onLongPress: () {
HapticFeedback.mediumImpact();
maProvider.togglePlayerSync(player.playerId);
},
onPlayPause: () {
if (isPlaying) {
maProvider.pausePlayer(player.playerId);
} else {
maProvider.resumePlayer(player.playerId);
}
},
onSkipNext: () => maProvider.nextTrack(player.playerId),
onPower: () => maProvider.togglePower(player.playerId),
),
),
);
},
),
)
else
// Non-scrollable list for when players fit
...List.generate(players.length, (index) {
final player = players[index];
final isPlaying = player.state == 'playing';
+226
View File
@@ -0,0 +1,226 @@
import 'package:flutter/material.dart';
import '../models/player.dart';
import '../l10n/app_localizations.dart';
/// A themed bottom sheet for selecting a player.
///
/// Supports adaptive theming - pass [adaptiveColorScheme] to use colors
/// extracted from album art, matching the detail screen backgrounds.
class PlayerPickerSheet extends StatelessWidget {
final String title;
final List<Player> players;
final Player? selectedPlayer;
final ColorScheme? adaptiveColorScheme;
final Future<void> Function(Player player) onPlayerSelected;
const PlayerPickerSheet({
super.key,
required this.title,
required this.players,
this.selectedPlayer,
this.adaptiveColorScheme,
required this.onPlayerSelected,
});
@override
Widget build(BuildContext context) {
final themeColorScheme = Theme.of(context).colorScheme;
final colorScheme = adaptiveColorScheme ?? themeColorScheme;
// Use surface container for slightly elevated look, or adaptive surface
final backgroundColor = adaptiveColorScheme?.surface ?? themeColorScheme.surfaceContainerHigh;
final onBackgroundColor = adaptiveColorScheme?.onSurface ?? themeColorScheme.onSurface;
return Container(
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 8),
child: Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: onBackgroundColor.withOpacity(0.3),
borderRadius: BorderRadius.circular(2),
),
),
),
// Title
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: onBackgroundColor,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 8),
// Player list
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(
S.of(context)!.noPlayersAvailable,
style: TextStyle(color: onBackgroundColor.withOpacity(0.6)),
),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
final isSelected = selectedPlayer?.playerId == player.playerId;
return _PlayerTile(
player: player,
isSelected: isSelected,
colorScheme: colorScheme,
onBackgroundColor: onBackgroundColor,
onTap: () => onPlayerSelected(player),
);
},
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 70),
],
),
);
}
}
class _PlayerTile extends StatelessWidget {
final Player player;
final bool isSelected;
final ColorScheme colorScheme;
final Color onBackgroundColor;
final VoidCallback onTap;
const _PlayerTile({
required this.player,
required this.isSelected,
required this.colorScheme,
required this.onBackgroundColor,
required this.onTap,
});
@override
Widget build(BuildContext context) {
// Status color based on player state
Color statusColor;
if (!player.available) {
statusColor = Colors.grey.withOpacity(0.5);
} else if (!player.powered) {
statusColor = Colors.grey;
} else if (player.state == 'playing') {
statusColor = Colors.green;
} else {
statusColor = Colors.orange; // Idle/paused but powered
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Material(
color: isSelected
? colorScheme.primaryContainer.withOpacity(0.3)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Status indicator
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: statusColor,
),
),
const SizedBox(width: 12),
// Speaker icon
Icon(
Icons.speaker_rounded,
color: isSelected
? colorScheme.primary
: onBackgroundColor.withOpacity(0.7),
size: 24,
),
const SizedBox(width: 12),
// Player name
Expanded(
child: Text(
player.name,
style: TextStyle(
color: isSelected
? colorScheme.primary
: onBackgroundColor,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
fontSize: 16,
),
),
),
// Selected checkmark
if (isSelected)
Icon(
Icons.check_circle_rounded,
color: colorScheme.primary,
size: 20,
),
],
),
),
),
),
);
}
}
/// Shows the player picker bottom sheet with adaptive theming.
///
/// Returns the selected player, or null if dismissed.
Future<void> showPlayerPickerSheet({
required BuildContext context,
required String title,
required List<Player> players,
Player? selectedPlayer,
ColorScheme? adaptiveColorScheme,
required Future<void> Function(Player player) onPlayerSelected,
}) {
return showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => PlayerPickerSheet(
title: title,
players: players,
selectedPlayer: selectedPlayer,
adaptiveColorScheme: adaptiveColorScheme,
onPlayerSelected: (player) async {
Navigator.pop(context);
await onPlayerSelected(player);
},
),
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.7.0-beta+30
version: 2.7.2-beta+31
environment:
sdk: '>=3.0.0 <4.0.0'