Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29758eb9bb | ||
|
|
332de9e00c | ||
|
|
6f503c8540 | ||
|
|
9950ff2063 | ||
|
|
4c777a93e1 | ||
|
|
a96578bbf0 | ||
|
|
8fa95705e1 | ||
|
|
9dcca2ce54 | ||
|
|
4152c3d67f | ||
|
|
d84fb58a5c | ||
|
|
128d417014 | ||
|
|
176fa5c8b9 | ||
|
|
ccaf764f52 | ||
|
|
ccda231fb2 | ||
|
|
0f82829172 | ||
|
|
0ccde33c85 |
+14
-2
@@ -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?;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user