Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f871f0078 | ||
|
|
573a5f6fe7 | ||
|
|
cb87492c9c | ||
|
|
b1b91e8dca | ||
|
|
ba0f21856a | ||
|
|
218bec9607 | ||
|
|
2d491e1ae9 | ||
|
|
1ab51bbf25 | ||
|
|
5e6da3ec2f | ||
|
|
3894574a05 | ||
|
|
7fe4e553ee | ||
|
|
459eb316b2 | ||
|
|
d3d03fd4d1 | ||
|
|
8f5008b902 | ||
|
|
c3acd0029e | ||
|
|
925feb89b6 | ||
|
|
29758eb9bb | ||
|
|
332de9e00c | ||
|
|
6f503c8540 | ||
|
|
9950ff2063 | ||
|
|
4c777a93e1 | ||
|
|
a96578bbf0 | ||
|
|
8fa95705e1 | ||
|
|
9dcca2ce54 | ||
|
|
4152c3d67f | ||
|
|
d84fb58a5c | ||
|
|
128d417014 | ||
|
|
176fa5c8b9 | ||
|
|
ccaf764f52 | ||
|
|
ccda231fb2 | ||
|
|
0f82829172 | ||
|
|
0ccde33c85 |
+20
-2
@@ -16,6 +16,7 @@ class Player {
|
||||
final String? syncedTo; // Player ID this player is synced to (null if leader or not synced)
|
||||
final String? activeSource; // The currently active source for this player
|
||||
final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active
|
||||
final String? appId; // The app_id from MA - 'music_assistant' when MA is playing, else external source
|
||||
|
||||
Player({
|
||||
required this.playerId,
|
||||
@@ -33,6 +34,7 @@ class Player {
|
||||
this.syncedTo,
|
||||
this.activeSource,
|
||||
this.isExternalSource = false,
|
||||
this.appId,
|
||||
});
|
||||
|
||||
/// Create a copy of this Player with some fields replaced
|
||||
@@ -52,6 +54,7 @@ class Player {
|
||||
String? syncedTo,
|
||||
String? activeSource,
|
||||
bool? isExternalSource,
|
||||
String? appId,
|
||||
}) {
|
||||
return Player(
|
||||
playerId: playerId ?? this.playerId,
|
||||
@@ -69,6 +72,7 @@ class Player {
|
||||
syncedTo: syncedTo ?? this.syncedTo,
|
||||
activeSource: activeSource ?? this.activeSource,
|
||||
isExternalSource: isExternalSource ?? this.isExternalSource,
|
||||
appId: appId ?? this.appId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,9 +201,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?;
|
||||
@@ -252,6 +268,7 @@ class Player {
|
||||
syncedTo: syncedTo,
|
||||
activeSource: activeSource,
|
||||
isExternalSource: isExternalSource,
|
||||
appId: appId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -272,6 +289,7 @@ class Player {
|
||||
if (syncedTo != null) 'synced_to': syncedTo,
|
||||
if (activeSource != null) 'active_source': activeSource,
|
||||
'is_external_source': isExternalSource,
|
||||
if (appId != null) 'app_id': appId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2196,7 +2196,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
Future<void> _loadAndSelectPlayers({bool forceRefresh = false, bool coldStart = false}) async {
|
||||
try {
|
||||
// Don't skip on coldStart - we need to apply full auto-selection priority logic
|
||||
if (!forceRefresh &&
|
||||
!coldStart &&
|
||||
_cacheService.isPlayersCacheValid() &&
|
||||
_availablePlayers.isNotEmpty) {
|
||||
return;
|
||||
@@ -2370,8 +2372,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
.firstWhere((p) => p.playerId == _selectedPlayer!.playerId)
|
||||
.state;
|
||||
final currentIsPlaying = currentPlayerState == 'playing';
|
||||
// Exclude external sources - they're not playing MA content
|
||||
final playingPlayers = _availablePlayers.where(
|
||||
(p) => p.state == 'playing' && p.available,
|
||||
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
|
||||
).toList();
|
||||
|
||||
// Switch to playing player only if current isn't playing and exactly one other is
|
||||
@@ -2400,8 +2403,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
if (!preferLocalPlayer) {
|
||||
// Priority 1 (normal): Single playing player (skip if multiple are playing)
|
||||
// Exclude external sources - they're not playing MA content
|
||||
final playingPlayers = _availablePlayers.where(
|
||||
(p) => p.state == 'playing' && p.available,
|
||||
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
|
||||
).toList();
|
||||
if (playingPlayers.length == 1) {
|
||||
playerToSelect = playingPlayers.first;
|
||||
@@ -2415,7 +2419,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2462,10 +2478,16 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// IMPORTANT: Always set from cache, even if null - this prevents showing
|
||||
// stale track info when switching to a non-playing player.
|
||||
// For grouped child players, this returns the leader's track
|
||||
// IMPORTANT: Clear track for external sources - they're playing non-MA content
|
||||
if (player.isExternalSource) {
|
||||
_currentTrack = null;
|
||||
} else {
|
||||
_currentTrack = getCachedTrackForPlayer(player.playerId);
|
||||
}
|
||||
|
||||
// Initialize position tracker for this player
|
||||
// Initialize position tracker for this player (skip for external sources)
|
||||
_positionTracker.onPlayerSelected(player.playerId);
|
||||
if (!player.isExternalSource) {
|
||||
_positionTracker.updateFromServer(
|
||||
playerId: player.playerId,
|
||||
position: player.elapsedTime ?? 0.0,
|
||||
@@ -2473,6 +2495,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
duration: _currentTrack?.duration,
|
||||
serverTimestamp: player.elapsedTimeLastUpdated,
|
||||
);
|
||||
}
|
||||
|
||||
// Switch audio handler mode based on player type
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
@@ -2756,7 +2779,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
// Log app_id for playing players to help diagnose external source detection
|
||||
if (player.state == 'playing') {
|
||||
_logger.log('🔍 Preload ${player.name}: state=${player.state}, app_id=${player.appId}, isExternalSource=${player.isExternalSource}');
|
||||
} else {
|
||||
_logger.log('🔍 Preload ${player.name}: state=${player.state}, available=${player.available}');
|
||||
}
|
||||
|
||||
// Skip external sources - they're not playing MA content
|
||||
if (player.isExternalSource) {
|
||||
_logger.log('🔍 Preload ${player.name}: SKIPPED - external source (app_id=${player.appId})');
|
||||
_cacheService.setCachedTrackForPlayer(player.playerId, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!player.available || !player.powered) {
|
||||
_logger.log('🔍 Preload ${player.name}: SKIPPED - not available or powered');
|
||||
@@ -2888,6 +2923,28 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_selectedPlayer = updatedPlayer;
|
||||
stateChanged = true;
|
||||
|
||||
// Handle external sources (Spotify Connect, TV optical, etc.)
|
||||
// These players are "playing" from MA's perspective but not MA content
|
||||
if (updatedPlayer.isExternalSource) {
|
||||
if (_currentTrack != null) {
|
||||
_currentTrack = null;
|
||||
_cacheService.setCachedTrackForPlayer(updatedPlayer.playerId, null);
|
||||
stateChanged = true;
|
||||
_persistPlaybackState();
|
||||
}
|
||||
if (_currentAudiobook != null) {
|
||||
_logger.log('📚 External source active, clearing audiobook context');
|
||||
_currentAudiobook = null;
|
||||
stateChanged = true;
|
||||
}
|
||||
// Clear notification for external source
|
||||
audioHandler.clearRemotePlaybackState();
|
||||
if (stateChanged) {
|
||||
notifyListeners();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Feed position tracker with server data
|
||||
// Log raw values to debug position issues
|
||||
final rawElapsedTime = updatedPlayer.elapsedTime;
|
||||
@@ -3933,10 +3990,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleShuffle(String queueId) async {
|
||||
Future<void> toggleShuffle(String queueId, bool shuffleEnabled) async {
|
||||
_logger.log('🔀 toggleShuffle called: queueId=$queueId, shuffleEnabled=$shuffleEnabled');
|
||||
try {
|
||||
await _api?.toggleShuffle(queueId);
|
||||
await _api?.toggleShuffle(queueId, shuffleEnabled);
|
||||
_logger.log('🔀 toggleShuffle: API call completed');
|
||||
} catch (e) {
|
||||
_logger.log('🔀 toggleShuffle ERROR: $e');
|
||||
ErrorHandler.logError('Toggle shuffle', e);
|
||||
rethrow;
|
||||
}
|
||||
|
||||
@@ -575,10 +575,11 @@ class PlayerProvider with ChangeNotifier {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Try playing player (exclude external sources - not playing MA content)
|
||||
if (playerToSelect == null) {
|
||||
try {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.state == 'playing' && p.available,
|
||||
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
|
||||
);
|
||||
} catch (e) {}
|
||||
}
|
||||
@@ -906,10 +907,10 @@ class PlayerProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleShuffle(String queueId) async {
|
||||
Future<void> toggleShuffle(String queueId, bool shuffleEnabled) async {
|
||||
if (_api == null) return;
|
||||
try {
|
||||
await _api!.toggleShuffle(queueId);
|
||||
await _api!.toggleShuffle(queueId, shuffleEnabled);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Toggle shuffle', e);
|
||||
rethrow;
|
||||
|
||||
@@ -558,7 +558,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -703,10 +703,16 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: colorScheme.background,
|
||||
body: CustomScrollView(
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive cover size: 50% of screen width, clamped between 200-320
|
||||
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
|
||||
final expandedHeight = coverSize + 70;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 350, // Increased height for bigger art
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.background,
|
||||
leading: IconButton(
|
||||
@@ -727,11 +733,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
child: Hero(
|
||||
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
||||
child: Container(
|
||||
width: 280, // Increased size
|
||||
height: 280,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(16), // Slightly more rounded
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
@@ -749,7 +755,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
child: imageUrl == null
|
||||
? Icon(
|
||||
Icons.album_rounded,
|
||||
size: 120,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
)
|
||||
: null,
|
||||
@@ -1080,6 +1086,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1132,7 +1140,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 +1149,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 +1166,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 +1192,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 {
|
||||
@@ -534,10 +551,16 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: colorScheme.background,
|
||||
body: CustomScrollView(
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive cover size: 40% of screen width, clamped between 160-280 (smaller for circular artist image)
|
||||
final coverSize = (constraints.maxWidth * 0.4).clamp(160.0, 280.0);
|
||||
final expandedHeight = coverSize + 100;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 300,
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.background,
|
||||
leading: IconButton(
|
||||
@@ -557,8 +580,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + _heroTagSuffix,
|
||||
child: ClipOval(
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
@@ -568,13 +591,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
fadeOutDuration: Duration.zero,
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.person_rounded,
|
||||
size: 100,
|
||||
size: coverSize * 0.5,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.person_rounded,
|
||||
size: 100,
|
||||
size: coverSize * 0.5,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
@@ -635,7 +658,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 +671,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 +679,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(
|
||||
@@ -847,6 +853,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -466,10 +468,16 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: CustomScrollView(
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive cover size: 50% of screen width, clamped between 200-320
|
||||
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
|
||||
final expandedHeight = coverSize + 70;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 350,
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
leading: IconButton(
|
||||
@@ -538,8 +546,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: 280,
|
||||
height: 280,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
@@ -550,14 +558,14 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
placeholder: (_, __) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: 120,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: 120,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
@@ -565,7 +573,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
: Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: 120,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
@@ -809,6 +817,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 140)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -275,11 +275,17 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: CustomScrollView(
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive cover size: 50% of screen width, clamped between 200-320
|
||||
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
|
||||
final expandedHeight = coverSize + 70;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// App bar with series cover collage (matches audiobook detail screen)
|
||||
SliverAppBar(
|
||||
expandedHeight: 350,
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
leading: IconButton(
|
||||
@@ -292,12 +298,12 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 60),
|
||||
// Series cover collage - same size as audiobook detail cover
|
||||
// Series cover collage - responsive size
|
||||
Hero(
|
||||
tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
|
||||
child: Container(
|
||||
width: 280,
|
||||
height: 280,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -449,6 +455,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
child: SizedBox(height: BottomSpacing.withMiniPlayer),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -1,36 +1,99 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'debug_logger.dart';
|
||||
import 'settings_service.dart';
|
||||
|
||||
/// Service to generate unique per-installation player IDs
|
||||
/// Simplified to match KMP client pattern: single storage key, lazy generation
|
||||
/// This prevents the critical bug where multiple devices of the same model
|
||||
/// would share the same player ID and trigger playback on all devices
|
||||
/// Service to generate unique per-device player IDs
|
||||
/// Uses stable device identifiers (ANDROID_ID) combined with MA username
|
||||
/// to create persistent player IDs that survive storage wipes and support
|
||||
/// multiple devices per account
|
||||
class DeviceIdService {
|
||||
static const String _keyLocalPlayerId = 'local_player_id';
|
||||
static final _logger = DebugLogger();
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// Get or generate a unique player ID for this installation
|
||||
/// ID is generated once and persisted across app restarts
|
||||
/// Format: ensemble_<uuid>
|
||||
///
|
||||
/// This matches the KMP client pattern: generate on first access, store once
|
||||
static Future<String> getOrCreateDevicePlayerId() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
// Cache stable device ID to avoid repeated platform calls
|
||||
static String? _cachedStableDeviceId;
|
||||
|
||||
// Single source of truth - no complex migration logic
|
||||
final existingId = prefs.getString(_keyLocalPlayerId);
|
||||
if (existingId != null && existingId.startsWith('ensemble_')) {
|
||||
_logger.log('Using existing player ID: $existingId');
|
||||
return existingId;
|
||||
/// Get stable device identifier that persists across storage wipes
|
||||
/// - Android: Uses ANDROID_ID (persists until factory reset)
|
||||
/// - iOS: Uses identifierForVendor (persists until app reinstall)
|
||||
static Future<String> _getStableDeviceId() async {
|
||||
if (_cachedStableDeviceId != null) {
|
||||
return _cachedStableDeviceId!;
|
||||
}
|
||||
|
||||
// Generate new ID on first access
|
||||
final playerId = 'ensemble_${_uuid.v4()}';
|
||||
await prefs.setString(_keyLocalPlayerId, playerId);
|
||||
_logger.log('Generated new player ID: $playerId');
|
||||
return playerId;
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final android = await deviceInfo.androidInfo;
|
||||
// ANDROID_ID is unique per device+user combo, persists across storage wipes
|
||||
// Only resets on factory reset
|
||||
_cachedStableDeviceId = android.id;
|
||||
} else if (Platform.isIOS) {
|
||||
final ios = await deviceInfo.iosInfo;
|
||||
// identifierForVendor persists across app updates but resets on reinstall
|
||||
_cachedStableDeviceId = ios.identifierForVendor ?? 'unknown_ios';
|
||||
} else {
|
||||
// Fallback for other platforms (web, desktop)
|
||||
_cachedStableDeviceId = 'unknown_platform';
|
||||
}
|
||||
|
||||
return _cachedStableDeviceId!;
|
||||
}
|
||||
|
||||
/// Detect if device is a tablet based on screen size
|
||||
/// Uses the standard 600dp threshold (shortest side >= 600 = tablet)
|
||||
static bool get isTablet {
|
||||
try {
|
||||
final view = ui.PlatformDispatcher.instance.views.first;
|
||||
final size = view.physicalSize / view.devicePixelRatio;
|
||||
return size.shortestSide >= 600;
|
||||
} catch (e) {
|
||||
_logger.log('Failed to detect device type: $e');
|
||||
return false; // Default to phone if detection fails
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or generate a unique player ID for this device
|
||||
/// Format: ensemble_{username}_{deviceId} or ensemble_{deviceId}
|
||||
///
|
||||
/// Key behaviors:
|
||||
/// - Uses stable device ID that survives storage wipes
|
||||
/// - Includes MA username to support multiple devices per account
|
||||
/// - Regenerates if username changes (user logged in with different account)
|
||||
static Future<String> getOrCreateDevicePlayerId() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final stableDeviceId = await _getStableDeviceId();
|
||||
final maUsername = await SettingsService.getUsername();
|
||||
|
||||
// Generate expected ID based on current username and device
|
||||
// Use shorter device ID suffix (first 8 chars) to keep ID reasonable length
|
||||
final deviceIdSuffix = stableDeviceId.length > 8
|
||||
? stableDeviceId.substring(0, 8)
|
||||
: stableDeviceId;
|
||||
|
||||
final expectedId = maUsername != null && maUsername.isNotEmpty
|
||||
? 'ensemble_${maUsername}_$deviceIdSuffix'
|
||||
: 'ensemble_$deviceIdSuffix';
|
||||
|
||||
// Check if we already have the correct ID
|
||||
final existingId = prefs.getString(_keyLocalPlayerId);
|
||||
if (existingId == expectedId) {
|
||||
_logger.log('Using existing player ID: $expectedId');
|
||||
return expectedId;
|
||||
}
|
||||
|
||||
// ID needs to be updated (first login, username changed, or migration)
|
||||
if (existingId != null) {
|
||||
_logger.log('Updating player ID from $existingId to $expectedId');
|
||||
} else {
|
||||
_logger.log('Generated new player ID: $expectedId');
|
||||
}
|
||||
|
||||
await prefs.setString(_keyLocalPlayerId, expectedId);
|
||||
return expectedId;
|
||||
}
|
||||
|
||||
/// Adopt an existing player ID (used when claiming a ghost player)
|
||||
@@ -45,4 +108,12 @@ class DeviceIdService {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyLocalPlayerId) == null;
|
||||
}
|
||||
|
||||
/// Force regeneration of player ID on next call
|
||||
/// Useful after login/logout to ensure ID matches current user
|
||||
static Future<void> invalidateCachedId() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_keyLocalPlayerId);
|
||||
_logger.log('Invalidated cached player ID');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1528,6 +1528,7 @@ class MusicAssistantAPI {
|
||||
currentIndex = queueResult['current_index'] as int?;
|
||||
shuffleEnabled = queueResult['shuffle_enabled'] as bool?;
|
||||
repeatMode = queueResult['repeat_mode'] as String?;
|
||||
_logger.log('🔀 Queue metadata: shuffle_enabled=$shuffleEnabled, repeat_mode=$repeatMode');
|
||||
|
||||
final currentItemData = queueResult['current_item'] as Map<String, dynamic>?;
|
||||
final currentItemName = currentItemData?['name'] as String?;
|
||||
@@ -2067,11 +2068,11 @@ class MusicAssistantAPI {
|
||||
}
|
||||
|
||||
/// Toggle shuffle mode for queue
|
||||
Future<void> toggleShuffle(String queueId) async {
|
||||
Future<void> toggleShuffle(String queueId, bool shuffleEnabled) async {
|
||||
try {
|
||||
await _sendCommand(
|
||||
'player_queues/shuffle',
|
||||
args: {'queue_id': queueId},
|
||||
args: {'queue_id': queueId, 'shuffle_enabled': shuffleEnabled},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling shuffle: $e');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'dart:convert';
|
||||
import 'device_id_service.dart';
|
||||
|
||||
class SettingsService {
|
||||
static const String _keyServerUrl = 'server_url';
|
||||
@@ -394,12 +395,14 @@ class SettingsService {
|
||||
}
|
||||
|
||||
// Helper to create player name with possessive apostrophe
|
||||
// Automatically detects Phone vs Tablet based on screen size
|
||||
static String _makePlayerName(String ownerName) {
|
||||
// Handle possessive: "Chris" -> "Chris' Phone", "Mom" -> "Mom's Phone"
|
||||
final deviceType = DeviceIdService.isTablet ? 'Tablet' : 'Phone';
|
||||
// Handle possessive: "Chris" -> "Chris' Tablet", "Mom" -> "Mom's Phone"
|
||||
if (ownerName.toLowerCase().endsWith('s')) {
|
||||
return "$ownerName' Phone";
|
||||
return "$ownerName' $deviceType";
|
||||
} else {
|
||||
return "$ownerName's Phone";
|
||||
return "$ownerName's $deviceType";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -345,6 +345,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
}
|
||||
|
||||
// 1. Show cached queue immediately (if available)
|
||||
// Only load from cache if we don't already have a queue (preserves optimistic updates)
|
||||
if (_queue == null) {
|
||||
final cachedQueue = await maProvider.getCachedQueue(player.playerId);
|
||||
if (cachedQueue != null && cachedQueue.items.isNotEmpty && mounted) {
|
||||
setState(() {
|
||||
@@ -354,17 +356,35 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
} else {
|
||||
setState(() => _isLoadingQueue = true);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch fresh queue in background
|
||||
if (maProvider.api != null) {
|
||||
try {
|
||||
final freshQueue = await maProvider.getQueue(player.playerId);
|
||||
if (mounted && freshQueue != null) {
|
||||
// Only update if queue changed
|
||||
debugPrint('🔀 Queue loaded: shuffle=${freshQueue.shuffle}, shuffleEnabled=${freshQueue.shuffleEnabled}');
|
||||
// Check if queue items were reordered by comparing first few item IDs
|
||||
bool itemsReordered = false;
|
||||
if (_queue != null && _queue!.items.isNotEmpty && freshQueue.items.isNotEmpty) {
|
||||
// Compare first 3 items to detect reordering (shuffle changes item order)
|
||||
final maxCheck = (_queue!.items.length < 3 ? _queue!.items.length : 3).clamp(1, freshQueue.items.length);
|
||||
for (int i = 0; i < maxCheck; i++) {
|
||||
if (_queue!.items[i].queueItemId != freshQueue.items[i].queueItemId) {
|
||||
itemsReordered = true;
|
||||
debugPrint('🔀 Queue items reordered detected at position $i');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update if queue metadata or item order changed
|
||||
final queueChanged = _queue == null ||
|
||||
_queue!.items.length != freshQueue.items.length ||
|
||||
_queue!.currentIndex != freshQueue.currentIndex;
|
||||
_queue!.currentIndex != freshQueue.currentIndex ||
|
||||
_queue!.shuffle != freshQueue.shuffle ||
|
||||
itemsReordered;
|
||||
if (queueChanged) {
|
||||
debugPrint('🔀 Queue changed, updating UI (reordered=$itemsReordered)');
|
||||
setState(() {
|
||||
_queue = freshQueue;
|
||||
_isLoadingQueue = false;
|
||||
@@ -406,16 +426,69 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
}
|
||||
|
||||
Future<void> _toggleShuffle() async {
|
||||
if (_queue == null) return;
|
||||
debugPrint('🔀 Shuffle button pressed');
|
||||
if (_queue == null) {
|
||||
debugPrint('🔀 Shuffle: _queue is null, returning');
|
||||
return;
|
||||
}
|
||||
final newShuffleState = !_queue!.shuffle;
|
||||
debugPrint('🔀 Shuffle: current=${_queue!.shuffle}, toggling to $newShuffleState');
|
||||
|
||||
// Optimistically update local state for immediate visual feedback
|
||||
setState(() {
|
||||
_queue = PlayerQueue(
|
||||
playerId: _queue!.playerId,
|
||||
items: _queue!.items,
|
||||
currentIndex: _queue!.currentIndex,
|
||||
shuffleEnabled: newShuffleState,
|
||||
repeatMode: _queue!.repeatMode,
|
||||
);
|
||||
});
|
||||
debugPrint('🔀 Shuffle: optimistic update applied, shuffleEnabled=$newShuffleState');
|
||||
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
await maProvider.toggleShuffle(_queue!.playerId);
|
||||
// Toggle: if currently shuffled, disable; if not shuffled, enable
|
||||
await maProvider.toggleShuffle(_queue!.playerId, newShuffleState);
|
||||
debugPrint('🔀 Shuffle: command sent, waiting for server to reorder queue');
|
||||
// Small delay to allow server to finish reordering queue items
|
||||
await Future.delayed(const Duration(milliseconds: 150));
|
||||
debugPrint('🔀 Shuffle: reloading queue');
|
||||
await _loadQueue();
|
||||
}
|
||||
|
||||
Future<void> _cycleRepeat() async {
|
||||
if (_queue == null) return;
|
||||
|
||||
// Calculate next repeat mode: off -> all -> one -> off
|
||||
String nextMode;
|
||||
switch (_queue!.repeatMode) {
|
||||
case 'off':
|
||||
case null:
|
||||
nextMode = 'all';
|
||||
break;
|
||||
case 'all':
|
||||
nextMode = 'one';
|
||||
break;
|
||||
case 'one':
|
||||
nextMode = 'off';
|
||||
break;
|
||||
default:
|
||||
nextMode = 'off';
|
||||
}
|
||||
|
||||
// Optimistically update local state for immediate visual feedback
|
||||
setState(() {
|
||||
_queue = PlayerQueue(
|
||||
playerId: _queue!.playerId,
|
||||
items: _queue!.items,
|
||||
currentIndex: _queue!.currentIndex,
|
||||
shuffleEnabled: _queue!.shuffleEnabled,
|
||||
repeatMode: nextMode,
|
||||
);
|
||||
});
|
||||
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
await maProvider.cycleRepeatMode(_queue!.playerId, _queue!.repeatMode);
|
||||
await maProvider.setRepeatMode(_queue!.playerId, nextMode);
|
||||
await _loadQueue();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -19,11 +19,12 @@ class PlayerSelector extends StatelessWidget {
|
||||
final availablePlayers = maProvider.availablePlayers;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Count players that are currently playing (excluding selected player)
|
||||
// Count players that are currently playing MA content (excluding selected player)
|
||||
// User already sees if selected player is playing, badge shows "other" activity
|
||||
// Exclude external sources (Spotify Connect, TV optical, etc.) - they're not playing MA content
|
||||
final selectedPlayerId = selectedPlayer?.playerId;
|
||||
final playingCount = availablePlayers
|
||||
.where((p) => p.state == 'playing' && p.playerId != selectedPlayerId)
|
||||
.where((p) => p.state == 'playing' && p.playerId != selectedPlayerId && !p.isExternalSource)
|
||||
.length;
|
||||
|
||||
return Padding(
|
||||
|
||||
+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.3-beta
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
Reference in New Issue
Block a user