Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29758eb9bb | ||
|
|
332de9e00c | ||
|
|
6f503c8540 | ||
|
|
9950ff2063 | ||
|
|
4c777a93e1 | ||
|
|
a96578bbf0 | ||
|
|
8fa95705e1 | ||
|
|
9dcca2ce54 | ||
|
|
4152c3d67f | ||
|
|
d84fb58a5c | ||
|
|
128d417014 | ||
|
|
176fa5c8b9 | ||
|
|
ccaf764f52 | ||
|
|
ccda231fb2 | ||
|
|
0f82829172 | ||
|
|
0ccde33c85 |
+14
-2
@@ -197,9 +197,21 @@ class Player {
|
|||||||
final activeSource = json['active_source'] as String?;
|
final activeSource = json['active_source'] as String?;
|
||||||
|
|
||||||
// Detect external source (optical, Spotify, AirPlay, etc.)
|
// 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;
|
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>?;
|
final currentMedia = json['current_media'] as Map<String, dynamic>?;
|
||||||
if (currentMedia != null) {
|
if (currentMedia != null) {
|
||||||
final uri = currentMedia['uri'] as String?;
|
final uri = currentMedia['uri'] as String?;
|
||||||
|
|||||||
@@ -2415,7 +2415,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
(p) => p.playerId == builtinPlayerId && p.available,
|
(p) => p.playerId == builtinPlayerId && p.available,
|
||||||
);
|
);
|
||||||
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
|
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
|
||||||
} catch (e) {}
|
} catch (e) {
|
||||||
|
// Local player not found or not available - check why
|
||||||
|
final localPlayer = _availablePlayers
|
||||||
|
.where((p) => p.playerId == builtinPlayerId)
|
||||||
|
.toList();
|
||||||
|
if (localPlayer.isEmpty) {
|
||||||
|
_logger.log('⚠️ Priority 2 skipped: local player ($builtinPlayerId) not in available players list');
|
||||||
|
} else {
|
||||||
|
_logger.log('⚠️ Priority 2 skipped: local player found but available=${localPlayer.first.available}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (playerToSelect == null && builtinPlayerId == null) {
|
||||||
|
_logger.log('⚠️ Priority 2 skipped: builtinPlayerId is null');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -558,7 +558,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1132,7 +1132,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared "Play on..." bottom sheet with fixed height and scrollable list
|
/// Shared "Play on..." bottom sheet that sizes to content
|
||||||
void _showPlayOnSheet(
|
void _showPlayOnSheet(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
List players, {
|
List players, {
|
||||||
@@ -1141,21 +1141,16 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
// Slide mini player down out of the way
|
// Slide mini player down out of the way
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
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(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
isScrollControlled: true,
|
|
||||||
builder: (context) => Container(
|
builder: (context) => Container(
|
||||||
height: sheetHeight,
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
@@ -1163,13 +1158,15 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Expanded(
|
if (players.isEmpty)
|
||||||
child: players.isEmpty
|
Padding(
|
||||||
? Center(
|
padding: const EdgeInsets.all(32.0),
|
||||||
child: Text(S.of(context)!.noPlayersAvailable),
|
child: Text(S.of(context)!.noPlayersAvailable),
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
else
|
||||||
padding: EdgeInsets.only(bottom: bottomPadding + 16),
|
Flexible(
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
itemCount: players.length,
|
itemCount: players.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final player = players[index];
|
final player = players[index];
|
||||||
@@ -1187,6 +1184,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -243,57 +243,25 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _startArtistRadio() async {
|
void _showRadioMenu(BuildContext context) {
|
||||||
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) {
|
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final players = maProvider.availablePlayers;
|
final players = maProvider.availablePlayers;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
// Slide mini player down out of the way
|
||||||
|
GlobalPlayerOverlay.hidePlayer();
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: Colors.transparent,
|
||||||
shape: const RoundedRectangleBorder(
|
builder: (context) => Container(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
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(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
S.of(context)!.startRadioOn(widget.artist.name),
|
S.of(context)!.startRadioOn(widget.artist.name),
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
@@ -314,7 +282,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
Icons.speaker,
|
Icons.speaker,
|
||||||
color: colorScheme.onSurface,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
title: Text(player.name),
|
title: Text(player.name),
|
||||||
onTap: () async {
|
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 maProvider = context.read<MusicAssistantProvider>();
|
||||||
final selectedPlayer = maProvider.selectedPlayer;
|
final players = maProvider.availablePlayers;
|
||||||
|
|
||||||
if (selectedPlayer == null) {
|
// Slide mini player down out of the way
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
GlobalPlayerOverlay.hidePlayer();
|
||||||
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);
|
||||||
final api = maProvider.api;
|
final api = maProvider.api;
|
||||||
if (api == null) {
|
if (api == null) {
|
||||||
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(S.of(context)!.notConnected)),
|
SnackBar(content: Text(S.of(context)!.notConnected)),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// For adding to queue with radio mode, we use radio_mode but with 'add' option
|
await api.playArtistRadioToQueue(player.playerId, widget.artist);
|
||||||
await api.playArtistRadioToQueue(selectedPlayer.playerId, widget.artist);
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
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 {
|
Future<void> _loadArtistImage() async {
|
||||||
@@ -635,7 +652,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 50,
|
height: 50,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _startArtistRadio,
|
onPressed: () => _showRadioMenu(context),
|
||||||
icon: const Icon(Icons.radio),
|
icon: const Icon(Icons.radio),
|
||||||
label: Text(S.of(context)!.radio),
|
label: Text(S.of(context)!.radio),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
@@ -648,23 +665,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
|
||||||
|
|
||||||
// "Play on..." Button (Square)
|
|
||||||
SizedBox(
|
|
||||||
height: 50,
|
|
||||||
width: 50,
|
|
||||||
child: FilledButton.tonal(
|
|
||||||
onPressed: () => _showPlayOnMenu(context),
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Icon(Icons.speaker_group_outlined),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
@@ -673,7 +673,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
height: 50,
|
height: 50,
|
||||||
width: 50,
|
width: 50,
|
||||||
child: FilledButton.tonal(
|
child: FilledButton.tonal(
|
||||||
onPressed: _addArtistRadioToQueue,
|
onPressed: () => _showAddToQueueMenu(context),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
|
|||||||
@@ -211,23 +211,19 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final players = maProvider.availablePlayers;
|
final players = maProvider.availablePlayers;
|
||||||
|
|
||||||
|
// Slide mini player down out of the way
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
GlobalPlayerOverlay.hidePlayer();
|
||||||
|
|
||||||
final screenHeight = MediaQuery.of(context).size.height;
|
|
||||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
|
||||||
final sheetHeight = (screenHeight * 0.45) + bottomPadding;
|
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
isScrollControlled: true,
|
|
||||||
builder: (context) => Container(
|
builder: (context) => Container(
|
||||||
height: sheetHeight,
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
@@ -235,11 +231,15 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Expanded(
|
if (players.isEmpty)
|
||||||
child: players.isEmpty
|
Padding(
|
||||||
? Center(child: Text(S.of(context)!.noPlayersAvailable))
|
padding: const EdgeInsets.all(32.0),
|
||||||
: ListView.builder(
|
child: Text(S.of(context)!.noPlayersAvailable),
|
||||||
padding: EdgeInsets.only(bottom: bottomPadding + 16),
|
)
|
||||||
|
else
|
||||||
|
Flexible(
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
itemCount: players.length,
|
itemCount: players.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final player = players[index];
|
final player = players[index];
|
||||||
@@ -262,10 +262,12 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
).whenComplete(() {
|
).whenComplete(() {
|
||||||
|
// Slide mini player back up when sheet is dismissed
|
||||||
GlobalPlayerOverlay.showPlayer();
|
GlobalPlayerOverlay.showPlayer();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+426
-201
@@ -8,9 +8,13 @@ import '../models/media_item.dart';
|
|||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
import '../services/database_service.dart';
|
import '../services/database_service.dart';
|
||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
|
import '../widgets/player_picker_sheet.dart';
|
||||||
import '../widgets/common/empty_state.dart';
|
import '../widgets/common/empty_state.dart';
|
||||||
import '../widgets/common/disconnected_state.dart';
|
import '../widgets/common/disconnected_state.dart';
|
||||||
import '../widgets/artist_avatar.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 'album_details_screen.dart';
|
||||||
import 'artist_details_screen.dart';
|
import 'artist_details_screen.dart';
|
||||||
import 'playlist_details_screen.dart';
|
import 'playlist_details_screen.dart';
|
||||||
@@ -76,6 +80,8 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
final _logger = DebugLogger();
|
final _logger = DebugLogger();
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final FocusNode _focusNode = FocusNode();
|
final FocusNode _focusNode = FocusNode();
|
||||||
|
final PageController _pageController = PageController();
|
||||||
|
final ScrollController _filterScrollController = ScrollController();
|
||||||
Timer? _debounceTimer;
|
Timer? _debounceTimer;
|
||||||
Map<String, List<MediaItem>> _searchResults = {
|
Map<String, List<MediaItem>> _searchResults = {
|
||||||
'artists': [],
|
'artists': [],
|
||||||
@@ -83,6 +89,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
'tracks': [],
|
'tracks': [],
|
||||||
'playlists': [],
|
'playlists': [],
|
||||||
'audiobooks': [],
|
'audiobooks': [],
|
||||||
|
'podcasts': [],
|
||||||
};
|
};
|
||||||
bool _isSearching = false;
|
bool _isSearching = false;
|
||||||
bool _hasSearched = false;
|
bool _hasSearched = false;
|
||||||
@@ -91,6 +98,17 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
List<String> _recentSearches = [];
|
List<String> _recentSearches = [];
|
||||||
bool _libraryOnly = false;
|
bool _libraryOnly = false;
|
||||||
String? _expandedTrackId; // Track ID for expanded quick actions
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -100,6 +118,99 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
_loadRecentSearches();
|
_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 {
|
Future<void> _loadRecentSearches() async {
|
||||||
if (!DatabaseService.instance.isInitialized) return;
|
if (!DatabaseService.instance.isInitialized) return;
|
||||||
final searches = await DatabaseService.instance.getRecentSearches();
|
final searches = await DatabaseService.instance.getRecentSearches();
|
||||||
@@ -127,9 +238,35 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
_debounceTimer?.cancel();
|
_debounceTimer?.cancel();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
|
_pageController.dispose();
|
||||||
|
_filterScrollController.dispose();
|
||||||
super.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) {
|
void _onSearchChanged(String query) {
|
||||||
_debounceTimer?.cancel();
|
_debounceTimer?.cancel();
|
||||||
_debounceTimer = Timer(Timings.searchDebounce, () {
|
_debounceTimer = Timer(Timings.searchDebounce, () {
|
||||||
@@ -140,9 +277,11 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
Future<void> _performSearch(String query, {bool keepFocus = false}) async {
|
Future<void> _performSearch(String query, {bool keepFocus = false}) async {
|
||||||
if (query.isEmpty) {
|
if (query.isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
_searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'podcasts': []};
|
||||||
_hasSearched = false;
|
_hasSearched = false;
|
||||||
_searchError = null;
|
_searchError = null;
|
||||||
|
_cachedListItems.clear(); // PERF: Clear cache
|
||||||
|
_cachedAvailableFilters = null;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -161,6 +300,8 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
_searchResults = results;
|
_searchResults = results;
|
||||||
_isSearching = false;
|
_isSearching = false;
|
||||||
_hasSearched = true;
|
_hasSearched = true;
|
||||||
|
_cachedListItems.clear(); // PERF: Clear cache on new results
|
||||||
|
_cachedAvailableFilters = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save to search history if we got results
|
// Save to search history if we got results
|
||||||
@@ -192,45 +333,79 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colorScheme.background,
|
backgroundColor: colorScheme.background,
|
||||||
appBar: AppBar(
|
body: SafeArea(
|
||||||
backgroundColor: colorScheme.surface,
|
child: Column(
|
||||||
elevation: 0,
|
children: [
|
||||||
title: TextField(
|
// 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,
|
controller: _searchController,
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
cursorColor: colorScheme.primary,
|
cursorColor: colorScheme.primary,
|
||||||
textInputAction: TextInputAction.search,
|
textInputAction: TextInputAction.search,
|
||||||
|
textAlignVertical: TextAlignVertical.center,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
hintText: S.of(context)!.searchMusic,
|
hintText: S.of(context)!.searchMusic,
|
||||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
suffixIcon: _searchController.text.isNotEmpty
|
suffixIcon: _hasSearchText
|
||||||
? IconButton(
|
? IconButton(
|
||||||
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
|
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
setState(() {
|
setState(() {
|
||||||
_searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
_hasSearchText = false;
|
||||||
|
_searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'podcasts': []};
|
||||||
_hasSearched = false;
|
_hasSearched = false;
|
||||||
_searchError = null;
|
_searchError = null;
|
||||||
|
_cachedListItems.clear();
|
||||||
|
_cachedAvailableFilters = null;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
onChanged: (value) {
|
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);
|
_onSearchChanged(value);
|
||||||
},
|
},
|
||||||
onSubmitted: (query) => _performSearch(query),
|
onSubmitted: (query) => _performSearch(query),
|
||||||
),
|
),
|
||||||
actions: [
|
),
|
||||||
|
),
|
||||||
// Library-only toggle
|
// Library-only toggle
|
||||||
Tooltip(
|
Tooltip(
|
||||||
message: S.of(context)!.libraryOnly,
|
message: S.of(context)!.libraryOnly,
|
||||||
@@ -243,7 +418,6 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_libraryOnly = !_libraryOnly;
|
_libraryOnly = !_libraryOnly;
|
||||||
});
|
});
|
||||||
// Re-search if there's a query
|
|
||||||
if (_searchController.text.isNotEmpty) {
|
if (_searchController.text.isNotEmpty) {
|
||||||
_performSearch(_searchController.text);
|
_performSearch(_searchController.text);
|
||||||
}
|
}
|
||||||
@@ -252,9 +426,18 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: !maProvider.isConnected
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Main content
|
||||||
|
Expanded(
|
||||||
|
child: !isConnected
|
||||||
? DisconnectedState.simple(context)
|
? DisconnectedState.simple(context)
|
||||||
: _buildSearchContent(),
|
: _buildSearchContent(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,53 +552,51 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
return EmptyState.search(context: context);
|
return EmptyState.search(context: context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Column layout - filter bar above results, no overlay
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Filters
|
// Filter tabs - always visible
|
||||||
SingleChildScrollView(
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
controller: _filterScrollController,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Row(
|
child: _buildFilterSelector(colorScheme),
|
||||||
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'),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Results with swipeable pages
|
||||||
// Results
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Builder(
|
child: NotificationListener<ScrollNotification>(
|
||||||
builder: (context) {
|
onNotification: _handleScrollNotification,
|
||||||
final listItems = _buildListItems(artists, albums, tracks, playlists, audiobooks);
|
child: PageView.builder(
|
||||||
return ListView.builder(
|
controller: _pageController,
|
||||||
padding: EdgeInsets.fromLTRB(16, 8, 16, BottomSpacing.navBarOnly),
|
onPageChanged: _onPageChanged,
|
||||||
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
itemCount: _getAvailableFilters().length,
|
||||||
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
|
itemBuilder: (context, pageIndex) {
|
||||||
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
|
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,
|
itemCount: listItems.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = listItems[index];
|
final item = listItems[index];
|
||||||
final showTypeInSubtitle = _activeFilter == 'all';
|
final showTypeInSubtitle = filterForPage == 'all';
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case _ListItemType.header:
|
case _ListItemType.header:
|
||||||
return _buildSectionHeader(item.headerTitle!, item.headerCount!);
|
return _buildSectionHeader(item.headerTitle!, item.headerCount!);
|
||||||
@@ -433,10 +614,12 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
return const SizedBox(height: 24);
|
return const SizedBox(height: 24);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -567,7 +750,9 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
return crossRefArtists;
|
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> artists,
|
||||||
List<MediaItem> albums,
|
List<MediaItem> albums,
|
||||||
List<MediaItem> tracks,
|
List<MediaItem> tracks,
|
||||||
@@ -578,10 +763,9 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
final query = _searchController.text;
|
final query = _searchController.text;
|
||||||
|
|
||||||
// For 'all' filter, create unified relevance-sorted list
|
// For 'all' filter, create unified relevance-sorted list
|
||||||
if (_activeFilter == 'all') {
|
if (filter == 'all') {
|
||||||
final scoredItems = <_ListItem>[];
|
final scoredItems = <_ListItem>[];
|
||||||
|
|
||||||
// Score and add all items
|
|
||||||
for (final artist in artists) {
|
for (final artist in artists) {
|
||||||
final score = _calculateRelevanceScore(artist, query);
|
final score = _calculateRelevanceScore(artist, query);
|
||||||
scoredItems.add(_ListItem.artist(artist, relevanceScore: score));
|
scoredItems.add(_ListItem.artist(artist, relevanceScore: score));
|
||||||
@@ -603,51 +787,46 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
scoredItems.add(_ListItem.audiobook(audiobook, relevanceScore: score));
|
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(
|
final crossRefArtists = _extractCrossReferencedArtists(
|
||||||
query,
|
query,
|
||||||
artists.cast<Artist>(),
|
artists.whereType<Artist>().toList(),
|
||||||
albums.cast<Album>(),
|
albums.whereType<Album>().toList(),
|
||||||
tracks.cast<Track>(),
|
tracks.whereType<Track>().toList(),
|
||||||
);
|
);
|
||||||
for (final artist in crossRefArtists) {
|
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));
|
scoredItems.add(_ListItem.artist(artist, relevanceScore: 25));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by relevance score descending
|
|
||||||
scoredItems.sort((a, b) => (b.relevanceScore ?? 0).compareTo(a.relevanceScore ?? 0));
|
scoredItems.sort((a, b) => (b.relevanceScore ?? 0).compareTo(a.relevanceScore ?? 0));
|
||||||
|
|
||||||
return scoredItems;
|
return scoredItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For specific type filters, use sectioned view (no headers needed for single type)
|
// For specific type filters
|
||||||
if (_activeFilter == 'artists' && artists.isNotEmpty) {
|
if (filter == 'artists' && artists.isNotEmpty) {
|
||||||
for (final artist in artists) {
|
for (final artist in artists) {
|
||||||
items.add(_ListItem.artist(artist));
|
items.add(_ListItem.artist(artist));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_activeFilter == 'albums' && albums.isNotEmpty) {
|
if (filter == 'albums' && albums.isNotEmpty) {
|
||||||
for (final album in albums) {
|
for (final album in albums) {
|
||||||
items.add(_ListItem.album(album));
|
items.add(_ListItem.album(album));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_activeFilter == 'tracks' && tracks.isNotEmpty) {
|
if (filter == 'tracks' && tracks.isNotEmpty) {
|
||||||
for (final track in tracks) {
|
for (final track in tracks) {
|
||||||
items.add(_ListItem.track(track));
|
items.add(_ListItem.track(track));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_activeFilter == 'playlists' && playlists.isNotEmpty) {
|
if (filter == 'playlists' && playlists.isNotEmpty) {
|
||||||
for (final playlist in playlists) {
|
for (final playlist in playlists) {
|
||||||
items.add(_ListItem.playlist(playlist));
|
items.add(_ListItem.playlist(playlist));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_activeFilter == 'audiobooks' && audiobooks.isNotEmpty) {
|
if (filter == 'audiobooks' && audiobooks.isNotEmpty) {
|
||||||
for (final audiobook in audiobooks) {
|
for (final audiobook in audiobooks) {
|
||||||
items.add(_ListItem.audiobook(audiobook));
|
items.add(_ListItem.audiobook(audiobook));
|
||||||
}
|
}
|
||||||
@@ -656,25 +835,58 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFilterChip(String label, String value) {
|
/// Build joined segmented filter selector (like library media type selector)
|
||||||
final isSelected = _activeFilter == value;
|
Widget _buildFilterSelector(ColorScheme colorScheme) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final filters = _getAvailableFilters();
|
||||||
|
final l10n = S.of(context)!;
|
||||||
|
|
||||||
return FilterChip(
|
String getLabel(String filter) {
|
||||||
label: Text(label),
|
switch (filter) {
|
||||||
selected: isSelected,
|
case 'all': return l10n.all;
|
||||||
showCheckmark: false,
|
case 'artists': return l10n.artists;
|
||||||
onSelected: (selected) {
|
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(() {
|
setState(() {
|
||||||
_activeFilter = value;
|
_activeFilter = filter;
|
||||||
});
|
});
|
||||||
|
_animateToFilter(filter);
|
||||||
},
|
},
|
||||||
backgroundColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
child: Padding(
|
||||||
selectedColor: colorScheme.primaryContainer,
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
side: BorderSide.none,
|
child: Text(
|
||||||
labelStyle: TextStyle(
|
getLabel(filter),
|
||||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
style: TextStyle(
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
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 maProvider = context.read<MusicAssistantProvider>();
|
||||||
final imageUrl = maProvider.getImageUrl(artist, size: 256);
|
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(
|
return RepaintBoundary(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
key: ValueKey(artist.uri ?? artist.itemId),
|
key: ValueKey(artistId),
|
||||||
leading: ArtistAvatar(
|
leading: Hero(
|
||||||
|
tag: HeroTags.artistImage + artistId + heroSuffix,
|
||||||
|
child: ArtistAvatar(
|
||||||
artist: artist,
|
artist: artist,
|
||||||
radius: 24,
|
radius: 24,
|
||||||
imageSize: 128,
|
imageSize: 128,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
artist.name,
|
artist.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -720,11 +939,14 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
// Update adaptive colors before navigation
|
||||||
|
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
FadeSlidePageRoute(
|
||||||
builder: (context) => ArtistDetailsScreen(
|
child: ArtistDetailsScreen(
|
||||||
artist: artist,
|
artist: artist,
|
||||||
|
heroTagSuffix: 'search',
|
||||||
initialImageUrl: imageUrl,
|
initialImageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -742,10 +964,16 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
? '${album.artistsString} • ${S.of(context)!.albumSingular}'
|
? '${album.artistsString} • ${S.of(context)!.albumSingular}'
|
||||||
: album.artistsString;
|
: album.artistsString;
|
||||||
|
|
||||||
|
// Use 'search' suffix to avoid hero tag conflicts with library cards
|
||||||
|
const heroSuffix = '_search';
|
||||||
|
final albumId = album.uri ?? album.itemId;
|
||||||
|
|
||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
key: ValueKey(album.uri ?? album.itemId),
|
key: ValueKey(albumId),
|
||||||
leading: Container(
|
leading: Hero(
|
||||||
|
tag: HeroTags.albumCover + albumId + heroSuffix,
|
||||||
|
child: Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -762,7 +990,12 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
? Icon(Icons.album_rounded, color: colorScheme.onSurfaceVariant)
|
? Icon(Icons.album_rounded, color: colorScheme.onSurfaceVariant)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
title: Text(
|
),
|
||||||
|
title: Hero(
|
||||||
|
tag: HeroTags.albumTitle + albumId + heroSuffix,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Text(
|
||||||
album.nameWithYear,
|
album.nameWithYear,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
@@ -771,18 +1004,29 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
),
|
||||||
|
),
|
||||||
|
subtitle: Hero(
|
||||||
|
tag: HeroTags.artistName + albumId + heroSuffix,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Text(
|
||||||
subtitleText,
|
subtitleText,
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
// Update adaptive colors before navigation
|
||||||
|
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
FadeSlidePageRoute(
|
||||||
builder: (context) => AlbumDetailsScreen(
|
child: AlbumDetailsScreen(
|
||||||
album: album,
|
album: album,
|
||||||
|
heroTagSuffix: 'search',
|
||||||
initialImageUrl: imageUrl,
|
initialImageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -871,12 +1115,12 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
// Radio button
|
// Radio button (uses current player - 1 tap)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 44,
|
height: 44,
|
||||||
width: 44,
|
width: 44,
|
||||||
child: FilledButton.tonal(
|
child: FilledButton.tonal(
|
||||||
onPressed: () => _showRadioMenu(track),
|
onPressed: () => _playRadio(track),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -887,12 +1131,28 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
// Add to queue button
|
// Radio On button (pick player - 2 taps)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 44,
|
height: 44,
|
||||||
width: 44,
|
width: 44,
|
||||||
child: FilledButton.tonal(
|
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(
|
style: FilledButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -1008,10 +1268,16 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
? '$authorText • ${S.of(context)!.audiobookSingular}'
|
? '$authorText • ${S.of(context)!.audiobookSingular}'
|
||||||
: authorText;
|
: authorText;
|
||||||
|
|
||||||
|
// Use 'search' suffix to avoid hero tag conflicts with library cards
|
||||||
|
const heroSuffix = '_search';
|
||||||
|
final audiobookId = audiobook.uri ?? audiobook.itemId;
|
||||||
|
|
||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
key: ValueKey(audiobook.uri ?? audiobook.itemId),
|
key: ValueKey(audiobookId),
|
||||||
leading: Container(
|
leading: Hero(
|
||||||
|
tag: HeroTags.audiobookCover + audiobookId + heroSuffix,
|
||||||
|
child: Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -1028,7 +1294,12 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
? Icon(Icons.headphones_rounded, color: colorScheme.onSurfaceVariant)
|
? Icon(Icons.headphones_rounded, color: colorScheme.onSurfaceVariant)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
title: Text(
|
),
|
||||||
|
title: Hero(
|
||||||
|
tag: HeroTags.audiobookTitle + audiobookId + heroSuffix,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Text(
|
||||||
audiobook.name,
|
audiobook.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
@@ -1037,6 +1308,8 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
subtitleText,
|
subtitleText,
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
@@ -1050,11 +1323,14 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
// Update adaptive colors before navigation
|
||||||
|
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
FadeSlidePageRoute(
|
||||||
builder: (context) => AudiobookDetailScreen(
|
child: AudiobookDetailScreen(
|
||||||
audiobook: audiobook,
|
audiobook: audiobook,
|
||||||
|
heroTagSuffix: 'search',
|
||||||
initialImageUrl: imageUrl,
|
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 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 {
|
try {
|
||||||
maProvider.selectPlayer(player);
|
|
||||||
await maProvider.playRadio(player.playerId, track);
|
await maProvider.playRadio(player.playerId, track);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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(() {
|
).whenComplete(() {
|
||||||
GlobalPlayerOverlay.showPlayer();
|
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 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 {
|
try {
|
||||||
await maProvider.addTrackToQueue(player.playerId, track);
|
await maProvider.addTrackToQueue(player.playerId, track);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -1216,18 +1453,6 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).whenComplete(() {
|
|
||||||
GlobalPlayerOverlay.showPlayer();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleTrackFavorite(Track track) async {
|
Future<void> _toggleTrackFavorite(Track track) async {
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
double _dragOffset = 0;
|
double _dragOffset = 0;
|
||||||
bool _isDragging = false;
|
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
|
// Cache extracted colors per player for per-device accent colors
|
||||||
final Map<String, ColorScheme?> _playerColors = {};
|
final Map<String, ColorScheme?> _playerColors = {};
|
||||||
|
|
||||||
@@ -128,6 +132,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_revealController.dispose();
|
_revealController.dispose();
|
||||||
_refreshTimer?.cancel();
|
_refreshTimer?.cancel();
|
||||||
|
_scrollController.dispose();
|
||||||
super.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) {
|
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(() {
|
setState(() {
|
||||||
_isDragging = true;
|
_isDragging = true;
|
||||||
// Negate delta so swipe down = cards move down (toward dismissal)
|
_dragOffset += delta;
|
||||||
_dragOffset -= details.primaryDelta ?? 0;
|
|
||||||
// Clamp: allow moving down (positive) freely, limit moving up to 20px
|
|
||||||
_dragOffset = _dragOffset.clamp(-20.0, double.infinity);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleVerticalDragEnd(DragEndDetails details) {
|
void _handleVerticalDragEnd(DragEndDetails details) {
|
||||||
final velocity = details.primaryVelocity ?? 0;
|
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
|
// Dismiss if dragged down enough or with enough velocity
|
||||||
if (_dragOffset > 100 || velocity > 500) {
|
if (_dragOffset > 100 || velocity > 500) {
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
@@ -215,6 +252,21 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
const cardSpacing = 12.0;
|
const cardSpacing = 12.0;
|
||||||
final totalStackHeight = players.length * cardHeight + (players.length - 1) * cardSpacing;
|
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(
|
return AnimatedBuilder(
|
||||||
animation: _revealAnimation,
|
animation: _revealAnimation,
|
||||||
builder: (context, child) {
|
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) {
|
...List.generate(players.length, (index) {
|
||||||
final player = players[index];
|
final player = players[index];
|
||||||
final isPlaying = player.state == 'playing';
|
final isPlaying = player.state == 'playing';
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../models/player.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// A themed bottom sheet for selecting a player.
|
||||||
|
///
|
||||||
|
/// Supports adaptive theming - pass [adaptiveColorScheme] to use colors
|
||||||
|
/// extracted from album art, matching the detail screen backgrounds.
|
||||||
|
class PlayerPickerSheet extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final List<Player> players;
|
||||||
|
final Player? selectedPlayer;
|
||||||
|
final ColorScheme? adaptiveColorScheme;
|
||||||
|
final Future<void> Function(Player player) onPlayerSelected;
|
||||||
|
|
||||||
|
const PlayerPickerSheet({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.players,
|
||||||
|
this.selectedPlayer,
|
||||||
|
this.adaptiveColorScheme,
|
||||||
|
required this.onPlayerSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final themeColorScheme = Theme.of(context).colorScheme;
|
||||||
|
final colorScheme = adaptiveColorScheme ?? themeColorScheme;
|
||||||
|
|
||||||
|
// Use surface container for slightly elevated look, or adaptive surface
|
||||||
|
final backgroundColor = adaptiveColorScheme?.surface ?? themeColorScheme.surfaceContainerHigh;
|
||||||
|
final onBackgroundColor = adaptiveColorScheme?.onSurface ?? themeColorScheme.onSurface;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Drag handle
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12, bottom: 8),
|
||||||
|
child: Container(
|
||||||
|
width: 32,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: onBackgroundColor.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Title
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: onBackgroundColor,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Player list
|
||||||
|
if (players.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(32.0),
|
||||||
|
child: Text(
|
||||||
|
S.of(context)!.noPlayersAvailable,
|
||||||
|
style: TextStyle(color: onBackgroundColor.withOpacity(0.6)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Flexible(
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
itemCount: players.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final player = players[index];
|
||||||
|
final isSelected = selectedPlayer?.playerId == player.playerId;
|
||||||
|
|
||||||
|
return _PlayerTile(
|
||||||
|
player: player,
|
||||||
|
isSelected: isSelected,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
onBackgroundColor: onBackgroundColor,
|
||||||
|
onTap: () => onPlayerSelected(player),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
SizedBox(height: MediaQuery.of(context).padding.bottom + 70),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlayerTile extends StatelessWidget {
|
||||||
|
final Player player;
|
||||||
|
final bool isSelected;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
final Color onBackgroundColor;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _PlayerTile({
|
||||||
|
required this.player,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.colorScheme,
|
||||||
|
required this.onBackgroundColor,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Status color based on player state
|
||||||
|
Color statusColor;
|
||||||
|
if (!player.available) {
|
||||||
|
statusColor = Colors.grey.withOpacity(0.5);
|
||||||
|
} else if (!player.powered) {
|
||||||
|
statusColor = Colors.grey;
|
||||||
|
} else if (player.state == 'playing') {
|
||||||
|
statusColor = Colors.green;
|
||||||
|
} else {
|
||||||
|
statusColor = Colors.orange; // Idle/paused but powered
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Material(
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.primaryContainer.withOpacity(0.3)
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Status indicator
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: statusColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// Speaker icon
|
||||||
|
Icon(
|
||||||
|
Icons.speaker_rounded,
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.primary
|
||||||
|
: onBackgroundColor.withOpacity(0.7),
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// Player name
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
player.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.primary
|
||||||
|
: onBackgroundColor,
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Selected checkmark
|
||||||
|
if (isSelected)
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle_rounded,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the player picker bottom sheet with adaptive theming.
|
||||||
|
///
|
||||||
|
/// Returns the selected player, or null if dismissed.
|
||||||
|
Future<void> showPlayerPickerSheet({
|
||||||
|
required BuildContext context,
|
||||||
|
required String title,
|
||||||
|
required List<Player> players,
|
||||||
|
Player? selectedPlayer,
|
||||||
|
ColorScheme? adaptiveColorScheme,
|
||||||
|
required Future<void> Function(Player player) onPlayerSelected,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => PlayerPickerSheet(
|
||||||
|
title: title,
|
||||||
|
players: players,
|
||||||
|
selectedPlayer: selectedPlayer,
|
||||||
|
adaptiveColorScheme: adaptiveColorScheme,
|
||||||
|
onPlayerSelected: (player) async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await onPlayerSelected(player);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: ensemble
|
name: ensemble
|
||||||
description: A minimalistic Music Assistant client built with Flutter
|
description: A minimalistic Music Assistant client built with Flutter
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.7.0-beta+30
|
version: 2.7.2-beta+31
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|||||||
Reference in New Issue
Block a user