Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
950af5f141 | ||
|
|
4cc1cc57d9 | ||
|
|
770dfac664 | ||
|
|
8b7a6edbba | ||
|
|
2c3b2a2bf6 | ||
|
|
b73fe04b45 | ||
|
|
c3c12f848e | ||
|
|
f116056c55 | ||
|
|
28db3f7ebb | ||
|
|
3646de2a33 | ||
|
|
c97ddd8d5c | ||
|
|
c23c7748c2 | ||
|
|
0cd58436f8 | ||
|
|
5b7e178181 | ||
|
|
2ff25a4ffb | ||
|
|
5aeac9f0fb | ||
|
|
8430abf5fd | ||
|
|
489503247e | ||
|
|
0e9ebc6a4a | ||
|
|
d5a4167d19 | ||
|
|
5a503941dd | ||
|
|
657dba0703 | ||
|
|
d9657b462d | ||
|
|
a0430dd674 | ||
|
|
e1fc9d00b7 | ||
|
|
754aa81e2a | ||
|
|
d4d51b00d9 | ||
|
|
fd391755a7 | ||
|
|
dbe395d88f | ||
|
|
9f7a1f4f50 | ||
|
|
2219996aea | ||
|
|
38ec41505c | ||
|
|
7b38b2ffa3 | ||
|
|
f6e21de414 | ||
|
|
0130a10396 | ||
|
|
573891f29a | ||
|
|
3b493e59e5 | ||
|
|
bc5a3014e4 | ||
|
|
19110ef4fe | ||
|
|
47ecb715b2 | ||
|
|
6c5d34c0ee | ||
|
|
dd18d7bdb5 | ||
|
|
69e9b4ece5 | ||
|
|
9ee9ddf29a | ||
|
|
32b8085b08 | ||
|
|
f9329ef289 | ||
|
|
d2619994b9 | ||
|
|
f064fe507c | ||
|
|
5eaf101b05 | ||
|
|
184dfdf670 | ||
|
|
56c93e808f | ||
|
|
7e40725a5f | ||
|
|
4ba4c44b56 | ||
|
|
a0dee2ab92 | ||
|
|
0b0ca089c7 | ||
|
|
91606e7e82 | ||
|
|
6a5a071ebe | ||
|
|
fd5730a87f | ||
|
|
33d3c65e52 | ||
|
|
e3d6afe9eb | ||
|
|
b7fe6ac06b | ||
|
|
505de2ff11 | ||
|
|
903051c8cb | ||
|
|
b2ed5ea085 | ||
|
|
8a6eba8775 | ||
|
|
3c6c3a14ba | ||
|
|
3ffe67a178 | ||
|
|
5c5b340915 | ||
|
|
a0d4d85ff9 | ||
|
|
abff289a12 | ||
|
|
93def48a8e |
@@ -43,9 +43,15 @@ I am not a developer. This application was created using **Claude Code** and **G
|
|||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/screenshots/Screenshot_20251124-193838.png" alt="Home Screen" width="250">
|
<img src="assets/screenshots/1.png" alt="Connection Screen" width="250">
|
||||||
<img src="assets/screenshots/Screenshot_20251124-193915.png" alt="Now Playing" width="250">
|
<img src="assets/screenshots/2.png" alt="Home Screen" width="250">
|
||||||
<img src="assets/screenshots/Screenshot_20251124-193936.png" alt="Player Selection" width="250">
|
<img src="assets/screenshots/3.png" alt="Album Details" width="250">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/screenshots/4.png" alt="Now Playing" width="250">
|
||||||
|
<img src="assets/screenshots/5.png" alt="Device Selection" width="250">
|
||||||
|
<img src="assets/screenshots/6.png" alt="Settings" width="250">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|||||||
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 618 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 496 KiB |
|
Before Width: | Height: | Size: 470 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 104 KiB |
@@ -40,6 +40,33 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
String? get error => _error;
|
String? get error => _error;
|
||||||
bool get isConnected => _connectionState == MAConnectionState.connected;
|
bool get isConnected => _connectionState == MAConnectionState.connected;
|
||||||
|
|
||||||
|
// Search state persistence
|
||||||
|
String _lastSearchQuery = '';
|
||||||
|
Map<String, List<MediaItem>> _lastSearchResults = {
|
||||||
|
'artists': [],
|
||||||
|
'albums': [],
|
||||||
|
'tracks': [],
|
||||||
|
};
|
||||||
|
|
||||||
|
String get lastSearchQuery => _lastSearchQuery;
|
||||||
|
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
|
||||||
|
|
||||||
|
void saveSearchState(String query, Map<String, List<MediaItem>> results) {
|
||||||
|
_lastSearchQuery = query;
|
||||||
|
_lastSearchResults = results;
|
||||||
|
// No notifyListeners() needed here as SearchScreen manages its own UI updates
|
||||||
|
// and just reads this on init
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearSearchState() {
|
||||||
|
_lastSearchQuery = '';
|
||||||
|
_lastSearchResults = {
|
||||||
|
'artists': [],
|
||||||
|
'albums': [],
|
||||||
|
'tracks': [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Player selection getters
|
// Player selection getters
|
||||||
Player? get selectedPlayer => _selectedPlayer;
|
Player? get selectedPlayer => _selectedPlayer;
|
||||||
List<Player> get availablePlayers => _availablePlayers;
|
List<Player> get availablePlayers => _availablePlayers;
|
||||||
@@ -232,13 +259,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, List<MediaItem>>> search(String query) async {
|
Future<Map<String, List<MediaItem>>> search(String query, {bool libraryOnly = false}) async {
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
return {'artists': [], 'albums': [], 'tracks': []};
|
return {'artists': [], 'albums': [], 'tracks': []};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await _api!.search(query);
|
return await _api!.search(query, libraryOnly: libraryOnly);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ErrorHandler.logError('Search', e);
|
ErrorHandler.logError('Search', e);
|
||||||
return {'artists': [], 'albums': [], 'tracks': []};
|
return {'artists': [], 'albums': [], 'tracks': []};
|
||||||
@@ -265,9 +292,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
return await _api?.getQueue(playerId);
|
return await _api?.getQueue(playerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> playTrack(String playerId, Track track) async {
|
Future<void> playTrack(String playerId, Track track, {bool clearQueue = true}) async {
|
||||||
try {
|
try {
|
||||||
await _api?.playTrack(playerId, track);
|
await _api?.playTrack(playerId, track, clearQueue: clearQueue);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
final errorInfo = ErrorHandler.handleError(e, context: 'Play track');
|
final errorInfo = ErrorHandler.handleError(e, context: 'Play track');
|
||||||
_error = errorInfo.userMessage;
|
_error = errorInfo.userMessage;
|
||||||
@@ -277,9 +304,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex}) async {
|
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex, bool clearQueue = true}) async {
|
||||||
try {
|
try {
|
||||||
await _api?.playTracks(playerId, tracks, startIndex: startIndex);
|
await _api?.playTracks(playerId, tracks, startIndex: startIndex, clearQueue: clearQueue);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
final errorInfo = ErrorHandler.handleError(e, context: 'Play tracks');
|
final errorInfo = ErrorHandler.handleError(e, context: 'Play tracks');
|
||||||
_error = errorInfo.userMessage;
|
_error = errorInfo.userMessage;
|
||||||
@@ -289,6 +316,18 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> playRadio(String playerId, Track track) async {
|
||||||
|
try {
|
||||||
|
await _api?.playRadio(playerId, track);
|
||||||
|
} catch (e) {
|
||||||
|
final errorInfo = ErrorHandler.handleError(e, context: 'Play radio');
|
||||||
|
_error = errorInfo.userMessage;
|
||||||
|
ErrorHandler.logError('Play radio', e);
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> getCurrentStreamUrl(String playerId) async {
|
Future<String?> getCurrentStreamUrl(String playerId) async {
|
||||||
return await _api?.getCurrentStreamUrl(playerId);
|
return await _api?.getCurrentStreamUrl(playerId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,22 +5,35 @@ import '../providers/music_assistant_provider.dart';
|
|||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
import '../theme/palette_helper.dart';
|
import '../theme/palette_helper.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
|
import '../services/metadata_service.dart';
|
||||||
|
import '../widgets/mini_player.dart';
|
||||||
|
import 'artist_details_screen.dart';
|
||||||
|
|
||||||
class AlbumDetailsScreen extends StatefulWidget {
|
class AlbumDetailsScreen extends StatefulWidget {
|
||||||
final Album album;
|
final Album album;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
|
||||||
const AlbumDetailsScreen({super.key, required this.album});
|
const AlbumDetailsScreen({
|
||||||
|
super.key,
|
||||||
|
required this.album,
|
||||||
|
this.heroTagSuffix,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AlbumDetailsScreen> createState() => _AlbumDetailsScreenState();
|
State<AlbumDetailsScreen> createState() => _AlbumDetailsScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTickerProviderStateMixin {
|
||||||
List<Track> _tracks = [];
|
List<Track> _tracks = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isFavorite = false;
|
bool _isFavorite = false;
|
||||||
ColorScheme? _lightColorScheme;
|
ColorScheme? _lightColorScheme;
|
||||||
ColorScheme? _darkColorScheme;
|
ColorScheme? _darkColorScheme;
|
||||||
|
int? _expandedTrackIndex;
|
||||||
|
bool _isDescriptionExpanded = false;
|
||||||
|
String? _albumDescription;
|
||||||
|
|
||||||
|
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -28,6 +41,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
_isFavorite = widget.album.favorite ?? false;
|
_isFavorite = widget.album.favorite ?? false;
|
||||||
_loadTracks();
|
_loadTracks();
|
||||||
_extractColors();
|
_extractColors();
|
||||||
|
_loadAlbumDescription();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _extractColors() async {
|
Future<void> _extractColors() async {
|
||||||
@@ -149,6 +163,140 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _addAlbumToQueue() async {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final player = maProvider.selectedPlayer;
|
||||||
|
if (player == null) {
|
||||||
|
_showError('No player selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
print('➕ Adding album to queue on ${player.name}');
|
||||||
|
|
||||||
|
// Add all tracks to queue without clearing
|
||||||
|
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0, clearQueue: false);
|
||||||
|
print('✓ Album added to queue on ${player.name}');
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Album added to queue'),
|
||||||
|
duration: Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Error adding album to queue: $e');
|
||||||
|
_showError('Failed to add album to queue: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addTrackToQueue(BuildContext context, int index) {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final players = maProvider.availablePlayers;
|
||||||
|
|
||||||
|
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(
|
||||||
|
'Add to queue on...',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (players.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(32.0),
|
||||||
|
child: Text('No players available'),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Flexible(
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: players.length,
|
||||||
|
itemBuilder: (context, playerIndex) {
|
||||||
|
final player = players[playerIndex];
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Icons.speaker,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
title: Text(player.name),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
try {
|
||||||
|
// Add tracks from this index onwards to queue
|
||||||
|
await maProvider.playTracks(
|
||||||
|
player.playerId,
|
||||||
|
_tracks,
|
||||||
|
startIndex: index,
|
||||||
|
clearQueue: false,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Tracks added to queue'),
|
||||||
|
duration: Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Error adding to queue: $e');
|
||||||
|
_showError('Failed to add to queue: $e');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToArtist() {
|
||||||
|
// Navigate to the first artist if available
|
||||||
|
if (widget.album.artists != null && widget.album.artists!.isNotEmpty) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => ArtistDetailsScreen(artist: widget.album.artists!.first),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadAlbumDescription() async {
|
||||||
|
final artistName = widget.album.artists?.firstOrNull?.name ?? '';
|
||||||
|
final albumName = widget.album.name;
|
||||||
|
|
||||||
|
if (artistName.isEmpty || albumName.isEmpty) return;
|
||||||
|
|
||||||
|
final description = await MetadataService.getAlbumDescription(
|
||||||
|
artistName,
|
||||||
|
albumName,
|
||||||
|
widget.album.metadata,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_albumDescription = description;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showError(String message) {
|
void _showError(String message) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -182,8 +330,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colorScheme.background,
|
backgroundColor: colorScheme.background,
|
||||||
body: CustomScrollView(
|
body: Stack(
|
||||||
slivers: [
|
children: [
|
||||||
|
CustomScrollView(
|
||||||
|
slivers: [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 350, // Increased height for bigger art
|
expandedHeight: 350, // Increased height for bigger art
|
||||||
pinned: true,
|
pinned: true,
|
||||||
@@ -199,7 +349,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 60),
|
const SizedBox(height: 60),
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId),
|
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 280, // Increased size
|
width: 280, // Increased size
|
||||||
height: 280,
|
height: 280,
|
||||||
@@ -240,7 +390,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.albumTitle + (widget.album.uri ?? widget.album.itemId),
|
tag: HeroTags.albumTitle + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -254,18 +404,48 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId),
|
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Text(
|
child: InkWell(
|
||||||
widget.album.artistsString,
|
onTap: () => _navigateToArtist(),
|
||||||
style: textTheme.titleMedium?.copyWith(
|
borderRadius: BorderRadius.circular(8),
|
||||||
color: colorScheme.onBackground.withOpacity(0.7),
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||||
|
child: Text(
|
||||||
|
widget.album.artistsString,
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onBackground.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 16),
|
||||||
|
// Album Description
|
||||||
|
if (_albumDescription != null && _albumDescription!.isNotEmpty) ...[
|
||||||
|
InkWell(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isDescriptionExpanded = !_isDescriptionExpanded;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Text(
|
||||||
|
_albumDescription!,
|
||||||
|
style: textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onBackground.withOpacity(0.8),
|
||||||
|
),
|
||||||
|
maxLines: _isDescriptionExpanded ? null : 2,
|
||||||
|
overflow: _isDescriptionExpanded ? null : TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
// Main Play Button
|
// Main Play Button
|
||||||
@@ -289,26 +469,43 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
// "Play on..." Button
|
// "Play on..." Button (Square)
|
||||||
Expanded(
|
SizedBox(
|
||||||
flex: 1,
|
height: 50,
|
||||||
child: SizedBox(
|
width: 50,
|
||||||
height: 50,
|
child: FilledButton.tonal(
|
||||||
child: FilledButton.tonal(
|
onPressed: _isLoading || _tracks.isEmpty ? null : () => _showPlayOnMenu(context),
|
||||||
onPressed: _isLoading || _tracks.isEmpty ? null : () => _showPlayOnMenu(context),
|
style: FilledButton.styleFrom(
|
||||||
style: FilledButton.styleFrom(
|
padding: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.speaker_group_outlined),
|
|
||||||
),
|
),
|
||||||
|
child: const Icon(Icons.speaker_group_outlined),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// "Add Album to Queue" Button (Square)
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
width: 50,
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: _isLoading || _tracks.isEmpty ? null : _addAlbumToQueue,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.playlist_add),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
// Favorite Button
|
// Favorite Button
|
||||||
Container(
|
Container(
|
||||||
height: 50,
|
height: 50,
|
||||||
@@ -363,56 +560,246 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) {
|
(context, index) {
|
||||||
final track = _tracks[index];
|
final track = _tracks[index];
|
||||||
return ListTile(
|
final isExpanded = _expandedTrackIndex == index;
|
||||||
leading: Container(
|
|
||||||
width: 40,
|
return Column(
|
||||||
height: 40,
|
mainAxisSize: MainAxisSize.min,
|
||||||
decoration: BoxDecoration(
|
children: [
|
||||||
color: colorScheme.surfaceVariant,
|
ListTile(
|
||||||
borderRadius: BorderRadius.circular(8),
|
leading: Container(
|
||||||
),
|
width: 40,
|
||||||
child: Center(
|
height: 40,
|
||||||
child: Text(
|
decoration: BoxDecoration(
|
||||||
'${track.position ?? index + 1}',
|
color: colorScheme.surfaceVariant,
|
||||||
style: TextStyle(
|
borderRadius: BorderRadius.circular(8),
|
||||||
color: colorScheme.onSurfaceVariant,
|
),
|
||||||
fontWeight: FontWeight.bold,
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'${track.position ?? index + 1}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
title: Text(
|
||||||
|
track.name,
|
||||||
|
style: textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
track.artistsString,
|
||||||
|
style: textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
trailing: track.duration != null
|
||||||
|
? Text(
|
||||||
|
_formatDuration(track.duration!),
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () => _playTrack(index),
|
||||||
|
onLongPress: () {
|
||||||
|
setState(() {
|
||||||
|
_expandedTrackIndex = isExpanded ? null : index;
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
AnimatedSize(
|
||||||
title: Text(
|
duration: const Duration(milliseconds: 300),
|
||||||
track.name,
|
curve: Curves.easeInOut,
|
||||||
style: textTheme.bodyLarge?.copyWith(
|
child: isExpanded
|
||||||
color: colorScheme.onSurface,
|
? Padding(
|
||||||
fontWeight: FontWeight.w500,
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 48,
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: () => _showPlayRadioMenu(context, index),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.tertiaryContainer,
|
||||||
|
foregroundColor: colorScheme.onTertiaryContainer,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Start radio'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 48,
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: () => _addTrackToQueue(context, index),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.secondaryContainer,
|
||||||
|
foregroundColor: colorScheme.onSecondaryContainer,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Add to queue'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
],
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
track.artistsString,
|
|
||||||
style: textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.6),
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
trailing: track.duration != null
|
|
||||||
? Text(
|
|
||||||
_formatDuration(track.duration!),
|
|
||||||
style: textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.5),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
onTap: () => _playTrack(index),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
childCount: _tracks.length,
|
childCount: _tracks.length,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 88)), // Extra space for floating mini player (80px height + 8px bottom margin)
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
// Floating mini player at bottom of screen
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: const MiniPlayer(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showPlayAlbumFromHereMenu(BuildContext context, int startIndex) {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final players = maProvider.availablePlayers;
|
||||||
|
|
||||||
|
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(
|
||||||
|
'Play on...',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (players.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(32.0),
|
||||||
|
child: Text('No players available'),
|
||||||
|
)
|
||||||
|
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: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
// Set this as the active player
|
||||||
|
maProvider.selectPlayer(player);
|
||||||
|
// Play album from this track onwards
|
||||||
|
maProvider.playTracks(
|
||||||
|
player.playerId,
|
||||||
|
_tracks,
|
||||||
|
startIndex: startIndex,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final players = maProvider.availablePlayers;
|
||||||
|
final track = _tracks[trackIndex];
|
||||||
|
|
||||||
|
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(
|
||||||
|
'Play on...',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (players.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(32.0),
|
||||||
|
child: Text('No players available'),
|
||||||
|
)
|
||||||
|
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: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
// Set this as the active player
|
||||||
|
maProvider.selectPlayer(player);
|
||||||
|
// Play radio based on this track
|
||||||
|
maProvider.playRadio(
|
||||||
|
player.playerId,
|
||||||
|
track,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -467,7 +854,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,11 +6,18 @@ import 'album_details_screen.dart';
|
|||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
import '../theme/palette_helper.dart';
|
import '../theme/palette_helper.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
|
import '../services/metadata_service.dart';
|
||||||
|
import '../widgets/mini_player.dart';
|
||||||
|
|
||||||
class ArtistDetailsScreen extends StatefulWidget {
|
class ArtistDetailsScreen extends StatefulWidget {
|
||||||
final Artist artist;
|
final Artist artist;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
|
||||||
const ArtistDetailsScreen({super.key, required this.artist});
|
const ArtistDetailsScreen({
|
||||||
|
super.key,
|
||||||
|
required this.artist,
|
||||||
|
this.heroTagSuffix,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ArtistDetailsScreen> createState() => _ArtistDetailsScreenState();
|
State<ArtistDetailsScreen> createState() => _ArtistDetailsScreenState();
|
||||||
@@ -18,15 +25,21 @@ class ArtistDetailsScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||||
List<Album> _albums = [];
|
List<Album> _albums = [];
|
||||||
|
List<Album> _providerAlbums = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
ColorScheme? _lightColorScheme;
|
ColorScheme? _lightColorScheme;
|
||||||
ColorScheme? _darkColorScheme;
|
ColorScheme? _darkColorScheme;
|
||||||
|
bool _isDescriptionExpanded = false;
|
||||||
|
String? _artistDescription;
|
||||||
|
|
||||||
|
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadArtistAlbums();
|
_loadArtistAlbums();
|
||||||
_extractColors();
|
_extractColors();
|
||||||
|
_loadArtistDescription();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _extractColors() async {
|
Future<void> _extractColors() async {
|
||||||
@@ -51,59 +64,71 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadArtistDescription() async {
|
||||||
|
final artistName = widget.artist.name;
|
||||||
|
|
||||||
|
if (artistName.isEmpty) return;
|
||||||
|
|
||||||
|
final description = await MetadataService.getArtistDescription(
|
||||||
|
artistName,
|
||||||
|
widget.artist.metadata,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_artistDescription = description;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadArtistAlbums() async {
|
Future<void> _loadArtistAlbums() async {
|
||||||
final provider = context.read<MusicAssistantProvider>();
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
// Load albums for this specific artist by filtering
|
|
||||||
if (provider.api != null) {
|
if (provider.api != null) {
|
||||||
print('🎵 Loading albums for artist: ${widget.artist.name}');
|
// 1. Get Library Albums
|
||||||
print(' Provider: ${widget.artist.provider}');
|
|
||||||
print(' ItemId: ${widget.artist.itemId}');
|
|
||||||
|
|
||||||
// Get all albums and filter locally (API filtering not reliable yet)
|
|
||||||
final allAlbums = await provider.api!.getAlbums();
|
final allAlbums = await provider.api!.getAlbums();
|
||||||
|
|
||||||
// Filter albums that include this artist
|
var libraryAlbums = allAlbums.where((album) {
|
||||||
var artistAlbums = allAlbums.where((album) {
|
|
||||||
if (album.artists == null) return false;
|
if (album.artists == null) return false;
|
||||||
return album.artists!.any((artist) =>
|
return album.artists!.any((artist) =>
|
||||||
artist.itemId == widget.artist.itemId ||
|
artist.itemId == widget.artist.itemId ||
|
||||||
artist.name == widget.artist.name // Fallback to name match
|
artist.name == widget.artist.name
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
print(' Got ${artistAlbums.length} library albums for this artist');
|
List<Album> providerAlbums = [];
|
||||||
|
|
||||||
// Fallback: If no library albums, try searching the provider
|
// 2. Get Provider Albums (via search)
|
||||||
if (artistAlbums.isEmpty && widget.artist.name.isNotEmpty) {
|
if (widget.artist.name.isNotEmpty) {
|
||||||
print('🔍 No library albums found. Searching provider for "${widget.artist.name}"...');
|
|
||||||
try {
|
try {
|
||||||
final searchResults = await provider.search(widget.artist.name);
|
final searchResults = await provider.search(widget.artist.name);
|
||||||
final searchAlbums = searchResults['albums'] ?? [];
|
final searchAlbums = (searchResults['albums'] as List<dynamic>?)
|
||||||
|
?.map((item) => item as Album)
|
||||||
|
.toList() ?? [];
|
||||||
|
|
||||||
// Filter search results to ensure they are actually for this artist
|
// Filter: Must match artist name
|
||||||
final validSearchAlbums = searchAlbums.where((item) {
|
providerAlbums = searchAlbums.where((album) {
|
||||||
// Ensure item is an Album and check artist match
|
return album.artists?.any((a) =>
|
||||||
if (item is! Album) return false;
|
|
||||||
return item.artists?.any((a) =>
|
|
||||||
a.name.toLowerCase() == widget.artist.name.toLowerCase()
|
a.name.toLowerCase() == widget.artist.name.toLowerCase()
|
||||||
) ?? false;
|
) ?? false;
|
||||||
}).cast<Album>().toList();
|
}).toList();
|
||||||
|
|
||||||
|
// Filter: Must NOT be in libraryAlbums
|
||||||
|
final libraryAlbumNames = libraryAlbums.map((a) => a.name.toLowerCase()).toSet();
|
||||||
|
|
||||||
|
providerAlbums = providerAlbums.where((a) =>
|
||||||
|
!libraryAlbumNames.contains(a.name.toLowerCase())
|
||||||
|
).toList();
|
||||||
|
|
||||||
if (validSearchAlbums.isNotEmpty) {
|
|
||||||
print('✅ Found ${validSearchAlbums.length} albums via search fallback');
|
|
||||||
artistAlbums = validSearchAlbums;
|
|
||||||
} else {
|
|
||||||
print('⚠️ Search returned no valid albums for this artist');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('❌ Error searching for artist albums: $e');
|
print('Error searching provider albums: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_albums = artistAlbums;
|
_albums = libraryAlbums;
|
||||||
|
_providerAlbums = providerAlbums;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -122,24 +147,21 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
final themeProvider = context.watch<ThemeProvider>();
|
final themeProvider = context.watch<ThemeProvider>();
|
||||||
final imageUrl = maProvider.getImageUrl(widget.artist, size: 512);
|
final imageUrl = maProvider.getImageUrl(widget.artist, size: 512);
|
||||||
|
|
||||||
// Determine if we should use adaptive theme colors
|
|
||||||
final useAdaptiveTheme = themeProvider.adaptiveTheme;
|
final useAdaptiveTheme = themeProvider.adaptiveTheme;
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
// Get the color scheme to use
|
|
||||||
ColorScheme? adaptiveScheme;
|
ColorScheme? adaptiveScheme;
|
||||||
if (useAdaptiveTheme) {
|
if (useAdaptiveTheme) {
|
||||||
adaptiveScheme = isDark ? _darkColorScheme : _lightColorScheme;
|
adaptiveScheme = isDark ? _darkColorScheme : _lightColorScheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use adaptive scheme if available, otherwise use global theme
|
|
||||||
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
|
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colorScheme.background,
|
backgroundColor: colorScheme.background,
|
||||||
body: CustomScrollView(
|
body: Stack(
|
||||||
slivers: [
|
children: [
|
||||||
|
CustomScrollView(
|
||||||
|
slivers: [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 300,
|
expandedHeight: 300,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
@@ -155,7 +177,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 60),
|
const SizedBox(height: 60),
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId),
|
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + _heroTagSuffix,
|
||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: 100,
|
radius: 100,
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
@@ -181,7 +203,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId),
|
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + _heroTagSuffix,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -193,15 +215,29 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 16),
|
||||||
Text(
|
if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[
|
||||||
'Albums',
|
InkWell(
|
||||||
style: textTheme.titleLarge?.copyWith(
|
onTap: () {
|
||||||
color: colorScheme.onBackground,
|
setState(() {
|
||||||
fontWeight: FontWeight.bold,
|
_isDescriptionExpanded = !_isDescriptionExpanded;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Text(
|
||||||
|
_artistDescription!,
|
||||||
|
style: textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onBackground.withOpacity(0.8),
|
||||||
|
),
|
||||||
|
maxLines: _isDescriptionExpanded ? null : 2,
|
||||||
|
overflow: _isDescriptionExpanded ? null : TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 12),
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -212,7 +248,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (_albums.isEmpty)
|
else if (_albums.isEmpty && _providerAlbums.isEmpty)
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -224,25 +260,85 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else ...[
|
||||||
SliverPadding(
|
// Library Albums Section
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
if (_albums.isNotEmpty) ...[
|
||||||
sliver: SliverGrid(
|
SliverToBoxAdapter(
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
child: Padding(
|
||||||
crossAxisCount: 2,
|
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
|
||||||
childAspectRatio: 0.75,
|
child: Text(
|
||||||
crossAxisSpacing: 12,
|
'In Library',
|
||||||
mainAxisSpacing: 12,
|
style: textTheme.titleLarge?.copyWith(
|
||||||
),
|
color: colorScheme.onBackground,
|
||||||
delegate: SliverChildBuilderDelegate(
|
fontWeight: FontWeight.bold,
|
||||||
(context, index) {
|
),
|
||||||
final album = _albums[index];
|
),
|
||||||
return _buildAlbumCard(album, maProvider);
|
|
||||||
},
|
|
||||||
childCount: _albums.length,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
childAspectRatio: 0.75,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final album = _albums[index];
|
||||||
|
return _buildAlbumCard(album, maProvider);
|
||||||
|
},
|
||||||
|
childCount: _albums.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Provider Albums Section
|
||||||
|
if (_providerAlbums.isNotEmpty) ...[
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24.0, 32.0, 24.0, 12.0),
|
||||||
|
child: Text(
|
||||||
|
'From Providers',
|
||||||
|
style: textTheme.titleLarge?.copyWith(
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
childAspectRatio: 0.75,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final album = _providerAlbums[index];
|
||||||
|
return _buildAlbumCard(album, maProvider);
|
||||||
|
},
|
||||||
|
childCount: _providerAlbums.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 88)), // Extra space for floating mini player (80px height + 8px bottom margin)
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Floating mini player at bottom of screen
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: const MiniPlayer(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,13 +14,7 @@ class HomeScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen> {
|
class _HomeScreenState extends State<HomeScreen> {
|
||||||
int _selectedIndex = 0;
|
int _selectedIndex = 0;
|
||||||
|
final GlobalKey<SearchScreenState> _searchScreenKey = GlobalKey<SearchScreenState>();
|
||||||
final List<Widget> _screens = [
|
|
||||||
const NewHomeScreen(),
|
|
||||||
const NewLibraryScreen(),
|
|
||||||
const SearchScreen(),
|
|
||||||
const SettingsScreen(),
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -37,15 +31,35 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: colorScheme.background,
|
backgroundColor: colorScheme.background,
|
||||||
body: _screens[_selectedIndex],
|
body: Stack(
|
||||||
bottomNavigationBar: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
// Mini player
|
// Home and Library use IndexedStack for state preservation
|
||||||
const MiniPlayer(),
|
Offstage(
|
||||||
|
offstage: _selectedIndex > 1,
|
||||||
// Bottom navigation bar
|
child: IndexedStack(
|
||||||
Container(
|
index: _selectedIndex.clamp(0, 1),
|
||||||
|
children: const [
|
||||||
|
NewHomeScreen(),
|
||||||
|
NewLibraryScreen(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Search and Settings are conditionally rendered (removed from tree when not visible)
|
||||||
|
if (_selectedIndex == 2)
|
||||||
|
SearchScreen(key: _searchScreenKey),
|
||||||
|
if (_selectedIndex == 3)
|
||||||
|
const SettingsScreen(),
|
||||||
|
// Floating mini player - positioned above bottom nav, hidden on settings
|
||||||
|
if (_selectedIndex != 3)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: const MiniPlayer(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
bottomNavigationBar: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surface,
|
color: colorScheme.surface,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
@@ -62,6 +76,14 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_selectedIndex = index;
|
_selectedIndex = index;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-focus search field when switching to search tab
|
||||||
|
if (index == 2) {
|
||||||
|
// Use post-frame callback to ensure SearchScreen is built first
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_searchScreenKey.currentState?.requestFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
selectedItemColor: colorScheme.primary,
|
selectedItemColor: colorScheme.primary,
|
||||||
@@ -94,8 +116,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,10 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
|||||||
itemCount: provider.albums.length,
|
itemCount: provider.albums.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final album = provider.albums[index];
|
final album = provider.albums[index];
|
||||||
return AlbumCard(album: album);
|
return AlbumCard(
|
||||||
|
album: album,
|
||||||
|
heroTagSuffix: 'library',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -100,11 +100,13 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
final imageUrl = provider.getImageUrl(artist, size: 128);
|
final imageUrl = provider.getImageUrl(artist, size: 128);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
const suffix = '_library';
|
||||||
|
|
||||||
return Builder(
|
return Builder(
|
||||||
builder: (context) => ListTile(
|
builder: (context) => ListTile(
|
||||||
leading: Hero(
|
leading: Hero(
|
||||||
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId),
|
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
|
||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: 24,
|
radius: 24,
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
@@ -115,7 +117,7 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Hero(
|
title: Hero(
|
||||||
tag: HeroTags.artistName + (artist.uri ?? artist.itemId),
|
tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -133,7 +135,10 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => ArtistDetailsScreen(artist: artist),
|
builder: (context) => ArtistDetailsScreen(
|
||||||
|
artist: artist,
|
||||||
|
heroTagSuffix: 'library',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -153,6 +153,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
if (provider.isConnected) {
|
if (provider.isConnected) {
|
||||||
// Navigate to home screen
|
// Navigate to home screen
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
// Ensure keyboard is closed before navigating
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
Navigator.of(context).pushReplacement(
|
Navigator.of(context).pushReplacement(
|
||||||
MaterialPageRoute(builder: (context) => const HomeScreen()),
|
MaterialPageRoute(builder: (context) => const HomeScreen()),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,11 +8,32 @@ import '../widgets/logo_text.dart';
|
|||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
|
|
||||||
class NewHomeScreen extends StatelessWidget {
|
class NewHomeScreen extends StatefulWidget {
|
||||||
const NewHomeScreen({super.key});
|
const NewHomeScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NewHomeScreen> createState() => _NewHomeScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin {
|
||||||
|
Key _refreshKey = UniqueKey();
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
|
Future<void> _onRefresh() async {
|
||||||
|
// Simulate a short delay or verify connection
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_refreshKey = UniqueKey();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -33,17 +54,6 @@ class NewHomeScreen extends StatelessWidget {
|
|||||||
titleSpacing: 0,
|
titleSpacing: 0,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.search),
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const SearchScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const PlayerSelector(),
|
const PlayerSelector(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -52,7 +62,12 @@ class NewHomeScreen extends StatelessWidget {
|
|||||||
builder: (context, maProvider, child) {
|
builder: (context, maProvider, child) {
|
||||||
return !maProvider.isConnected
|
return !maProvider.isConnected
|
||||||
? _buildDisconnectedView(context, maProvider)
|
? _buildDisconnectedView(context, maProvider)
|
||||||
: _buildConnectedView(context, maProvider);
|
: RefreshIndicator(
|
||||||
|
onRefresh: _onRefresh,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
child: _buildConnectedView(context, maProvider),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -124,14 +139,16 @@ class NewHomeScreen extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _buildConnectedView(
|
Widget _buildConnectedView(
|
||||||
BuildContext context, MusicAssistantProvider provider) {
|
BuildContext context, MusicAssistantProvider provider) {
|
||||||
|
// Pass the unique key to force rebuild of children when refreshed
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
key: _refreshKey,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Recently played albums
|
// Recently played albums (extracted from recently played tracks)
|
||||||
AlbumRow(
|
AlbumRow(
|
||||||
title: 'Recently Played',
|
title: 'Recently Played',
|
||||||
loadAlbums: () async {
|
loadAlbums: () async {
|
||||||
|
|||||||
@@ -32,18 +32,6 @@ class NewLibraryScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.search),
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const SearchScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
color: colorScheme.onBackground,
|
|
||||||
),
|
|
||||||
const PlayerSelector(),
|
const PlayerSelector(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|||||||
|
|
||||||
return Hero(
|
return Hero(
|
||||||
tag: HeroTags.nowPlayingBackground,
|
tag: HeroTags.nowPlayingBackground,
|
||||||
|
transitionOnUserGestures: true,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -219,6 +220,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|||||||
return Center(
|
return Center(
|
||||||
child: Hero(
|
child: Hero(
|
||||||
tag: HeroTags.nowPlayingArt,
|
tag: HeroTags.nowPlayingArt,
|
||||||
|
transitionOnUserGestures: true,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
@@ -391,6 +393,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|||||||
// Previous
|
// Previous
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.nowPlayingPreviousButton,
|
tag: HeroTags.nowPlayingPreviousButton,
|
||||||
|
transitionOnUserGestures: true,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: AnimatedIconButton(
|
child: AnimatedIconButton(
|
||||||
@@ -416,6 +419,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|||||||
// Play/Pause
|
// Play/Pause
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.nowPlayingPlayButton,
|
tag: HeroTags.nowPlayingPlayButton,
|
||||||
|
transitionOnUserGestures: true,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: _AnimatedPlayButton(
|
child: _AnimatedPlayButton(
|
||||||
@@ -441,6 +445,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|||||||
// Next
|
// Next
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.nowPlayingNextButton,
|
tag: HeroTags.nowPlayingNextButton,
|
||||||
|
transitionOnUserGestures: true,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: AnimatedIconButton(
|
child: AnimatedIconButton(
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ class SearchScreen extends StatefulWidget {
|
|||||||
const SearchScreen({super.key});
|
const SearchScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SearchScreen> createState() => _SearchScreenState();
|
State<SearchScreen> createState() => SearchScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SearchScreenState extends State<SearchScreen> {
|
class SearchScreenState extends State<SearchScreen> {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final FocusNode _focusNode = FocusNode();
|
final FocusNode _focusNode = FocusNode();
|
||||||
Map<String, List<MediaItem>> _searchResults = {
|
Map<String, List<MediaItem>> _searchResults = {
|
||||||
@@ -22,14 +22,25 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
};
|
};
|
||||||
bool _isSearching = false;
|
bool _isSearching = false;
|
||||||
bool _hasSearched = false;
|
bool _hasSearched = false;
|
||||||
|
String _activeFilter = 'all'; // 'all', 'artists', 'albums', 'tracks'
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Auto-focus the search field when screen opens
|
// Don't auto-focus - let user tap to focus
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
// This prevents keyboard popup bug when SearchScreen is in widget tree but not visible
|
||||||
|
}
|
||||||
|
|
||||||
|
void requestFocus() {
|
||||||
|
if (mounted) {
|
||||||
_focusNode.requestFocus();
|
_focusNode.requestFocus();
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeFocus() {
|
||||||
|
if (mounted) {
|
||||||
|
_focusNode.unfocus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -195,30 +206,78 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView(
|
return Column(
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
children: [
|
children: [
|
||||||
if (artists.isNotEmpty) ...[
|
// Filters
|
||||||
_buildSectionHeader('Artists', artists.length),
|
SingleChildScrollView(
|
||||||
const SizedBox(height: 8),
|
scrollDirection: Axis.horizontal,
|
||||||
...artists.map((item) => _buildArtistTile(item as Artist)),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
const SizedBox(height: 24),
|
child: Row(
|
||||||
],
|
children: [
|
||||||
if (albums.isNotEmpty) ...[
|
_buildFilterChip('All', 'all'),
|
||||||
_buildSectionHeader('Albums', albums.length),
|
const SizedBox(width: 8),
|
||||||
const SizedBox(height: 8),
|
if (artists.isNotEmpty) ...[
|
||||||
...albums.map((item) => _buildAlbumTile(item as Album)),
|
_buildFilterChip('Artists', 'artists'),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
if (tracks.isNotEmpty) ...[
|
if (albums.isNotEmpty) ...[
|
||||||
_buildSectionHeader('Tracks', tracks.length),
|
_buildFilterChip('Albums', 'albums'),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(width: 8),
|
||||||
...tracks.map((item) => _buildTrackTile(item as Track)),
|
],
|
||||||
],
|
if (tracks.isNotEmpty) ...[
|
||||||
|
_buildFilterChip('Tracks', 'tracks'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Results
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
children: [
|
||||||
|
if ((_activeFilter == 'all' || _activeFilter == 'artists') && artists.isNotEmpty) ...[
|
||||||
|
if (_activeFilter == 'all') _buildSectionHeader('Artists', artists.length),
|
||||||
|
...artists.map((item) => _buildArtistTile(item as Artist)),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
if ((_activeFilter == 'all' || _activeFilter == 'albums') && albums.isNotEmpty) ...[
|
||||||
|
if (_activeFilter == 'all') _buildSectionHeader('Albums', albums.length),
|
||||||
|
...albums.map((item) => _buildAlbumTile(item as Album)),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
if ((_activeFilter == 'all' || _activeFilter == 'tracks') && tracks.isNotEmpty) ...[
|
||||||
|
if (_activeFilter == 'all') _buildSectionHeader('Tracks', tracks.length),
|
||||||
|
...tracks.map((item) => _buildTrackTile(item as Track)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildFilterChip(String label, String value) {
|
||||||
|
final isSelected = _activeFilter == value;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
_activeFilter = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectedColor: colorScheme.primaryContainer,
|
||||||
|
checkmarkColor: colorScheme.onPrimaryContainer,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurface,
|
||||||
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildSectionHeader(String title, int count) {
|
Widget _buildSectionHeader(String title, int count) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final _portController = TextEditingController(text: '8095');
|
final _portController = TextEditingController(text: '8095');
|
||||||
final _usernameController = TextEditingController();
|
final _usernameController = TextEditingController();
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
|
final _lastFmApiKeyController = TextEditingController();
|
||||||
|
final _audioDbApiKeyController = TextEditingController();
|
||||||
final _authService = AuthService();
|
final _authService = AuthService();
|
||||||
final _logger = DebugLogger();
|
final _logger = DebugLogger();
|
||||||
bool _isConnecting = false;
|
bool _isConnecting = false;
|
||||||
@@ -48,6 +50,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
if (password != null) {
|
if (password != null) {
|
||||||
_passwordController.text = password;
|
_passwordController.text = password;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final lastFmKey = await SettingsService.getLastFmApiKey();
|
||||||
|
if (lastFmKey != null) {
|
||||||
|
_lastFmApiKeyController.text = lastFmKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
final audioDbKey = await SettingsService.getTheAudioDbApiKey();
|
||||||
|
if (audioDbKey != null) {
|
||||||
|
_audioDbApiKeyController.text = audioDbKey;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -56,6 +68,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_portController.dispose();
|
_portController.dispose();
|
||||||
_usernameController.dispose();
|
_usernameController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
|
_lastFmApiKeyController.dispose();
|
||||||
|
_audioDbApiKeyController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,6 +524,96 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
Text(
|
||||||
|
'Metadata APIs (Optional)',
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Add API keys to fetch artist biographies and album descriptions when Music Assistant doesn\'t have them',
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onBackground.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
controller: _lastFmApiKeyController,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Last.fm API Key',
|
||||||
|
hintText: 'Get free key at last.fm/api',
|
||||||
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
prefixIcon: Icon(
|
||||||
|
Icons.music_note_rounded,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
|
),
|
||||||
|
suffixIcon: _lastFmApiKeyController.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_lastFmApiKeyController.clear();
|
||||||
|
});
|
||||||
|
SettingsService.setLastFmApiKey(null);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
SettingsService.setLastFmApiKey(value.trim().isEmpty ? null : value.trim());
|
||||||
|
setState(() {}); // Update UI to show/hide clear button
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
controller: _audioDbApiKeyController,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'TheAudioDB API Key (Premium)',
|
||||||
|
hintText: 'Use "2" for free tier or premium key',
|
||||||
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
prefixIcon: Icon(
|
||||||
|
Icons.audiotrack_rounded,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
|
),
|
||||||
|
suffixIcon: _audioDbApiKeyController.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_audioDbApiKeyController.clear();
|
||||||
|
});
|
||||||
|
SettingsService.setTheAudioDbApiKey(null);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
SettingsService.setTheAudioDbApiKey(value.trim().isEmpty ? null : value.trim());
|
||||||
|
setState(() {}); // Update UI to show/hide clear button
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 50,
|
height: 50,
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'settings_service.dart';
|
||||||
|
|
||||||
|
class MetadataService {
|
||||||
|
// Cache to avoid repeated API calls for the same artist/album
|
||||||
|
static final Map<String, String> _cache = {};
|
||||||
|
|
||||||
|
/// Fetches artist biography/description with fallback chain:
|
||||||
|
/// 1. Music Assistant metadata (passed in)
|
||||||
|
/// 2. Last.fm API (if key configured)
|
||||||
|
/// 3. TheAudioDB API (if key configured)
|
||||||
|
static Future<String?> getArtistDescription(
|
||||||
|
String artistName,
|
||||||
|
Map<String, dynamic>? musicAssistantMetadata,
|
||||||
|
) async {
|
||||||
|
// Try Music Assistant metadata first
|
||||||
|
if (musicAssistantMetadata != null) {
|
||||||
|
final maDescription = musicAssistantMetadata['description'] ??
|
||||||
|
musicAssistantMetadata['biography'] ??
|
||||||
|
musicAssistantMetadata['wiki'] ??
|
||||||
|
musicAssistantMetadata['bio'] ??
|
||||||
|
musicAssistantMetadata['summary'];
|
||||||
|
|
||||||
|
if (maDescription != null && (maDescription as String).trim().isNotEmpty) {
|
||||||
|
return maDescription;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
final cacheKey = 'artist:$artistName';
|
||||||
|
if (_cache.containsKey(cacheKey)) {
|
||||||
|
return _cache[cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Last.fm API
|
||||||
|
final lastFmKey = await SettingsService.getLastFmApiKey();
|
||||||
|
if (lastFmKey != null && lastFmKey.isNotEmpty) {
|
||||||
|
final lastFmDesc = await _fetchFromLastFm(artistName, null, lastFmKey);
|
||||||
|
if (lastFmDesc != null) {
|
||||||
|
_cache[cacheKey] = lastFmDesc;
|
||||||
|
return lastFmDesc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try TheAudioDB API
|
||||||
|
final audioDbKey = await SettingsService.getTheAudioDbApiKey();
|
||||||
|
if (audioDbKey != null && audioDbKey.isNotEmpty) {
|
||||||
|
final audioDbDesc = await _fetchFromTheAudioDb(artistName, audioDbKey);
|
||||||
|
if (audioDbDesc != null) {
|
||||||
|
_cache[cacheKey] = audioDbDesc;
|
||||||
|
return audioDbDesc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches album description with fallback chain:
|
||||||
|
/// 1. Music Assistant metadata (passed in)
|
||||||
|
/// 2. Last.fm API (if key configured)
|
||||||
|
static Future<String?> getAlbumDescription(
|
||||||
|
String artistName,
|
||||||
|
String albumName,
|
||||||
|
Map<String, dynamic>? musicAssistantMetadata,
|
||||||
|
) async {
|
||||||
|
// Try Music Assistant metadata first
|
||||||
|
if (musicAssistantMetadata != null) {
|
||||||
|
final maDescription = musicAssistantMetadata['description'] ??
|
||||||
|
musicAssistantMetadata['wiki'] ??
|
||||||
|
musicAssistantMetadata['biography'] ??
|
||||||
|
musicAssistantMetadata['summary'];
|
||||||
|
|
||||||
|
if (maDescription != null && (maDescription as String).trim().isNotEmpty) {
|
||||||
|
return maDescription;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
final cacheKey = 'album:$artistName:$albumName';
|
||||||
|
if (_cache.containsKey(cacheKey)) {
|
||||||
|
return _cache[cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Last.fm API (TheAudioDB doesn't have good album info)
|
||||||
|
final lastFmKey = await SettingsService.getLastFmApiKey();
|
||||||
|
if (lastFmKey != null && lastFmKey.isNotEmpty) {
|
||||||
|
final lastFmDesc = await _fetchFromLastFm(artistName, albumName, lastFmKey);
|
||||||
|
if (lastFmDesc != null) {
|
||||||
|
_cache[cacheKey] = lastFmDesc;
|
||||||
|
return lastFmDesc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String?> _fetchFromLastFm(
|
||||||
|
String artistName,
|
||||||
|
String? albumName,
|
||||||
|
String apiKey,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final String method;
|
||||||
|
final Map<String, String> params = {
|
||||||
|
'api_key': apiKey,
|
||||||
|
'format': 'json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (albumName != null) {
|
||||||
|
// Album info
|
||||||
|
method = 'album.getinfo';
|
||||||
|
params['artist'] = artistName;
|
||||||
|
params['album'] = albumName;
|
||||||
|
} else {
|
||||||
|
// Artist info
|
||||||
|
method = 'artist.getinfo';
|
||||||
|
params['artist'] = artistName;
|
||||||
|
}
|
||||||
|
|
||||||
|
params['method'] = method;
|
||||||
|
|
||||||
|
final uri = Uri.https('ws.audioscrobbler.com', '/2.0/', params);
|
||||||
|
final response = await http.get(uri).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = json.decode(response.body);
|
||||||
|
|
||||||
|
if (albumName != null) {
|
||||||
|
// Parse album response
|
||||||
|
final album = data['album'];
|
||||||
|
if (album != null) {
|
||||||
|
final wiki = album['wiki'];
|
||||||
|
if (wiki != null) {
|
||||||
|
// Prefer summary, fall back to content
|
||||||
|
return _cleanLastFmText(wiki['summary'] ?? wiki['content']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Parse artist response
|
||||||
|
final artist = data['artist'];
|
||||||
|
if (artist != null) {
|
||||||
|
final bio = artist['bio'];
|
||||||
|
if (bio != null) {
|
||||||
|
// Prefer summary, fall back to content
|
||||||
|
return _cleanLastFmText(bio['summary'] ?? bio['content']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('⚠️ Last.fm API error: $e');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String?> _fetchFromTheAudioDb(
|
||||||
|
String artistName,
|
||||||
|
String apiKey,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final uri = Uri.https(
|
||||||
|
'theaudiodb.com',
|
||||||
|
'/api/v1/json/$apiKey/search.php',
|
||||||
|
{'s': artistName},
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await http.get(uri).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = json.decode(response.body);
|
||||||
|
final artists = data['artists'];
|
||||||
|
|
||||||
|
if (artists != null && artists.isNotEmpty) {
|
||||||
|
final artist = artists[0];
|
||||||
|
// Try multiple language fields
|
||||||
|
return artist['strBiographyEN'] ??
|
||||||
|
artist['strBiographyDE'] ??
|
||||||
|
artist['strBiographyFR'] ??
|
||||||
|
artist['strBiographyIT'] ??
|
||||||
|
artist['strBiographyES'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('⚠️ TheAudioDB API error: $e');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes Last.fm HTML tags and links
|
||||||
|
static String? _cleanLastFmText(String? text) {
|
||||||
|
if (text == null) return null;
|
||||||
|
|
||||||
|
// Remove <a href...> tags
|
||||||
|
text = text.replaceAll(RegExp(r'<a[^>]*>'), '');
|
||||||
|
text = text.replaceAll('</a>', '');
|
||||||
|
|
||||||
|
// Remove "Read more on Last.fm" footer
|
||||||
|
text = text.replaceAll(RegExp(r'\s*<a[^>]*>.*?</a>.*$'), '');
|
||||||
|
|
||||||
|
// Clean up any remaining HTML
|
||||||
|
text = text.replaceAll(RegExp(r'<[^>]*>'), '');
|
||||||
|
|
||||||
|
return text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the metadata cache
|
||||||
|
static void clearCache() {
|
||||||
|
_cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -273,9 +273,10 @@ class MusicAssistantAPI {
|
|||||||
int? offset,
|
int? offset,
|
||||||
String? search,
|
String? search,
|
||||||
bool? favoriteOnly,
|
bool? favoriteOnly,
|
||||||
|
bool albumArtistsOnly = true,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
_logger.log('Fetching artists with limit=$limit, offset=$offset, search=$search');
|
_logger.log('Fetching artists with limit=$limit, offset=$offset, search=$search, albumArtistsOnly=$albumArtistsOnly');
|
||||||
final response = await _sendCommand(
|
final response = await _sendCommand(
|
||||||
'music/artists/library_items',
|
'music/artists/library_items',
|
||||||
args: {
|
args: {
|
||||||
@@ -283,6 +284,7 @@ class MusicAssistantAPI {
|
|||||||
if (offset != null) 'offset': offset,
|
if (offset != null) 'offset': offset,
|
||||||
if (search != null) 'search': search,
|
if (search != null) 'search': search,
|
||||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||||
|
'album_artists_only': albumArtistsOnly,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -303,14 +305,15 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Artist>> getRandomArtists({int limit = 10}) async {
|
Future<List<Artist>> getRandomArtists({int limit = 10, bool albumArtistsOnly = true}) async {
|
||||||
try {
|
try {
|
||||||
_logger.log('Fetching random artists (limit=$limit)');
|
_logger.log('Fetching random artists (limit=$limit, albumArtistsOnly=$albumArtistsOnly)');
|
||||||
final response = await _sendCommand(
|
final response = await _sendCommand(
|
||||||
'music/artists/library_items',
|
'music/artists/library_items',
|
||||||
args: {
|
args: {
|
||||||
'limit': limit,
|
'limit': limit,
|
||||||
'order_by': 'random',
|
'order_by': 'random',
|
||||||
|
'album_artists_only': albumArtistsOnly,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -404,44 +407,60 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get recently played albums
|
/// Get recently played albums
|
||||||
|
/// Gets recently played tracks, then fetches full track details to extract album info
|
||||||
Future<List<Album>> getRecentAlbums({int limit = 10}) async {
|
Future<List<Album>> getRecentAlbums({int limit = 10}) async {
|
||||||
try {
|
try {
|
||||||
_logger.log('Fetching recently played albums (limit=$limit)');
|
_logger.log('Fetching recently played albums (limit=$limit)');
|
||||||
// Fetch a large number of albums to ensure we include recently played ones,
|
|
||||||
// then sort client-side since server-side sorting can be unreliable.
|
// Get recently played tracks (simplified objects)
|
||||||
final response = await _sendCommand(
|
final response = await _sendCommand(
|
||||||
'music/albums/library_items',
|
'music/recently_played_items',
|
||||||
args: {
|
args: {
|
||||||
'limit': 2000, // Increase limit to capture most of the library
|
'limit': limit * 5,
|
||||||
|
'media_types': ['track'],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
final items = response['result'] as List<dynamic>?;
|
final items = response['result'] as List<dynamic>?;
|
||||||
if (items == null) {
|
if (items == null || items.isEmpty) {
|
||||||
_logger.log('⚠️ No items returned for recent albums');
|
_logger.log('⚠️ No recently played tracks found');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort client-side by timestamp_played or last_played
|
// Fetch full track details to get album data
|
||||||
items.sort((a, b) {
|
final seenAlbumIds = <String>{};
|
||||||
// Try to find timestamp in different possible locations
|
final albums = <Album>[];
|
||||||
final tA = (a['timestamp_played'] ?? a['last_played'] ?? a['metadata']?['timestamp_played'] ?? a['metadata']?['last_played'] ?? 0) as num;
|
|
||||||
final tB = (b['timestamp_played'] ?? b['last_played'] ?? b['metadata']?['timestamp_played'] ?? b['metadata']?['last_played'] ?? 0) as num;
|
|
||||||
return tB.compareTo(tA); // Descending
|
|
||||||
});
|
|
||||||
|
|
||||||
_logger.log('✅ Got ${items.length} albums, sorting by timestamp_played');
|
for (final item in items) {
|
||||||
|
if (albums.length >= limit) break;
|
||||||
// Take top 'limit'
|
|
||||||
final topItems = items.take(limit).toList();
|
|
||||||
|
|
||||||
if (topItems.isNotEmpty) {
|
try {
|
||||||
_logger.log(' First recent album: ${topItems[0]['name']}');
|
final trackUri = (item as Map<String, dynamic>)['uri'] as String?;
|
||||||
|
if (trackUri == null) continue;
|
||||||
|
|
||||||
|
final trackResponse = await _sendCommand(
|
||||||
|
'music/item_by_uri',
|
||||||
|
args: {'uri': trackUri},
|
||||||
|
).timeout(const Duration(seconds: 3), onTimeout: () => <String, dynamic>{'result': null});
|
||||||
|
|
||||||
|
final fullTrack = trackResponse['result'] as Map<String, dynamic>?;
|
||||||
|
final albumData = fullTrack?['album'] as Map<String, dynamic>?;
|
||||||
|
|
||||||
|
if (albumData != null) {
|
||||||
|
final albumId = albumData['item_id']?.toString() ?? albumData['uri']?.toString();
|
||||||
|
if (albumId != null && !seenAlbumIds.contains(albumId)) {
|
||||||
|
seenAlbumIds.add(albumId);
|
||||||
|
albums.add(Album.fromJson(albumData));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Skip tracks that fail to fetch
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return topItems
|
_logger.log('✅ Found ${albums.length} recently played albums');
|
||||||
.map((item) => Album.fromJson(item as Map<String, dynamic>))
|
return albums;
|
||||||
.toList();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('❌ Error getting recent albums: $e');
|
_logger.log('❌ Error getting recent albums: $e');
|
||||||
return [];
|
return [];
|
||||||
@@ -704,39 +723,42 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
Future<Map<String, List<MediaItem>>> search(String query) async {
|
/// Search across all providers (Spotify, etc.) and library
|
||||||
|
/// Set libraryOnly to true to search only local library
|
||||||
|
Future<Map<String, List<MediaItem>>> search(String query, {bool libraryOnly = false}) async {
|
||||||
return await RetryHelper.retryNetwork(
|
return await RetryHelper.retryNetwork(
|
||||||
operation: () async {
|
operation: () async {
|
||||||
try {
|
try {
|
||||||
_logger.log('Searching for: $query');
|
_logger.log('Searching for: $query (libraryOnly: $libraryOnly)');
|
||||||
|
|
||||||
// Search each media type separately
|
// Use the global search command that searches across all providers
|
||||||
final artistsResponse = await _sendCommand(
|
final searchResponse = await _sendCommand(
|
||||||
'music/artists/library_items',
|
'music/search',
|
||||||
args: {'search': query, 'limit': 50},
|
args: {
|
||||||
|
'search_query': query,
|
||||||
|
'limit': 50, // Results per media type
|
||||||
|
'library_only': libraryOnly,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
final albumsResponse = await _sendCommand(
|
final result = searchResponse['result'] as Map<String, dynamic>?;
|
||||||
'music/albums/library_items',
|
if (result == null) {
|
||||||
args: {'search': query, 'limit': 50},
|
_logger.log('No search results returned');
|
||||||
);
|
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': []};
|
||||||
|
}
|
||||||
|
|
||||||
final tracksResponse = await _sendCommand(
|
// Parse results from the search response
|
||||||
'music/tracks/library_items',
|
final artists = (result['artists'] as List<dynamic>?)
|
||||||
args: {'search': query, 'limit': 50},
|
|
||||||
);
|
|
||||||
|
|
||||||
final artists = (artistsResponse['result'] as List<dynamic>?)
|
|
||||||
?.map((item) => Artist.fromJson(item as Map<String, dynamic>))
|
?.map((item) => Artist.fromJson(item as Map<String, dynamic>))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[];
|
[];
|
||||||
|
|
||||||
final albums = (albumsResponse['result'] as List<dynamic>?)
|
final albums = (result['albums'] as List<dynamic>?)
|
||||||
?.map((item) => Album.fromJson(item as Map<String, dynamic>))
|
?.map((item) => Album.fromJson(item as Map<String, dynamic>))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[];
|
[];
|
||||||
|
|
||||||
final tracks = (tracksResponse['result'] as List<dynamic>?)
|
final tracks = (result['tracks'] as List<dynamic>?)
|
||||||
?.map((item) => Track.fromJson(item as Map<String, dynamic>))
|
?.map((item) => Track.fromJson(item as Map<String, dynamic>))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[];
|
[];
|
||||||
@@ -913,18 +935,20 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Play a single track via queue
|
/// Play a single track via queue
|
||||||
Future<void> playTrack(String playerId, Track track) async {
|
/// If clearQueue is true, replaces the queue (default behavior)
|
||||||
|
Future<void> playTrack(String playerId, Track track, {bool clearQueue = true}) async {
|
||||||
try {
|
try {
|
||||||
// Build URI from provider mappings
|
// Build URI from provider mappings
|
||||||
final uri = _buildTrackUri(track);
|
final uri = _buildTrackUri(track);
|
||||||
_logger.log('Playing track via queue: $uri on player $playerId');
|
final option = clearQueue ? 'replace' : 'play';
|
||||||
|
_logger.log('Playing track via queue: $uri on player $playerId (option: $option)');
|
||||||
|
|
||||||
await _sendCommand(
|
await _sendCommand(
|
||||||
'player_queues/play_media',
|
'player_queues/play_media',
|
||||||
args: {
|
args: {
|
||||||
'queue_id': playerId,
|
'queue_id': playerId,
|
||||||
'media': [uri], // Array of URI strings, not objects
|
'media': [uri], // Array of URI strings, not objects
|
||||||
'option': 'play', // Play immediately
|
'option': option, // 'replace' clears queue, 'play' adds to queue
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -936,25 +960,67 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Play multiple tracks via queue
|
/// Play multiple tracks via queue
|
||||||
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex}) async {
|
/// If clearQueue is true, replaces the queue (default behavior)
|
||||||
|
/// If startIndex is provided, only tracks from that index onwards will be queued
|
||||||
|
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex, bool clearQueue = true}) async {
|
||||||
return await RetryHelper.retryCritical(
|
return await RetryHelper.retryCritical(
|
||||||
operation: () async {
|
operation: () async {
|
||||||
// Build array of URI strings (not objects!)
|
// If startIndex is provided, slice the tracks list to start from that index
|
||||||
final mediaUris = tracks.map((track) => _buildTrackUri(track)).toList();
|
// This is a workaround since Music Assistant ignores the start_item parameter
|
||||||
|
final tracksToPlay = startIndex != null && startIndex > 0
|
||||||
|
? tracks.sublist(startIndex)
|
||||||
|
: tracks;
|
||||||
|
|
||||||
_logger.log('Playing ${tracks.length} tracks via queue on player $playerId');
|
// Build array of URI strings (not objects!)
|
||||||
|
final mediaUris = tracksToPlay.map((track) => _buildTrackUri(track)).toList();
|
||||||
|
|
||||||
|
final option = clearQueue ? 'replace' : 'play';
|
||||||
|
_logger.log('Playing ${tracksToPlay.length} tracks via queue on player $playerId (option: $option, startIndex: $startIndex, sliced: ${startIndex != null && startIndex > 0})');
|
||||||
|
|
||||||
|
final args = {
|
||||||
|
'queue_id': playerId,
|
||||||
|
'media': mediaUris, // Array of URI strings
|
||||||
|
'option': option, // 'replace' clears queue, 'play' adds to queue
|
||||||
|
};
|
||||||
|
|
||||||
|
_logger.log('📤 Sending play_media command with args: $args');
|
||||||
|
|
||||||
await _sendCommand(
|
await _sendCommand(
|
||||||
'player_queues/play_media',
|
'player_queues/play_media',
|
||||||
args: {
|
args: args,
|
||||||
'queue_id': playerId,
|
|
||||||
'media': mediaUris, // Array of URI strings
|
|
||||||
'option': 'play', // Play immediately
|
|
||||||
if (startIndex != null) 'start_item': startIndex,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
_logger.log('✓ ${tracks.length} tracks queued successfully');
|
_logger.log('✓ ${tracksToPlay.length} tracks queued successfully');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play radio based on a track (generates similar tracks)
|
||||||
|
Future<void> playRadio(String playerId, Track track) async {
|
||||||
|
return await RetryHelper.retryCritical(
|
||||||
|
operation: () async {
|
||||||
|
// Try using library URI if available, otherwise use provider URI
|
||||||
|
final libraryUri = track.uri;
|
||||||
|
final trackUri = libraryUri ?? _buildTrackUri(track);
|
||||||
|
_logger.log('Playing radio based on track: $trackUri (library URI: $libraryUri) on player $playerId');
|
||||||
|
|
||||||
|
final args = {
|
||||||
|
'queue_id': playerId,
|
||||||
|
'media': [trackUri],
|
||||||
|
'option': 'replace',
|
||||||
|
'radio_mode': true, // Enable radio mode for similar tracks
|
||||||
|
};
|
||||||
|
|
||||||
|
_logger.log('📤 Sending play_media command (RADIO MODE) with args: $args');
|
||||||
|
_logger.log(' Track details: provider=${track.provider}, itemId=${track.itemId}');
|
||||||
|
_logger.log(' Available providers: ${track.providerMappings?.map((m) => '${m.providerDomain}:${m.available}').join(', ')}');
|
||||||
|
|
||||||
|
await _sendCommand(
|
||||||
|
'player_queues/play_media',
|
||||||
|
args: args,
|
||||||
|
);
|
||||||
|
|
||||||
|
_logger.log('✓ Radio mode command sent successfully');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class SettingsService {
|
|||||||
static const String _keyUseMaterialTheme = 'use_material_theme';
|
static const String _keyUseMaterialTheme = 'use_material_theme';
|
||||||
static const String _keyAdaptiveTheme = 'adaptive_theme';
|
static const String _keyAdaptiveTheme = 'adaptive_theme';
|
||||||
static const String _keyCustomColor = 'custom_color';
|
static const String _keyCustomColor = 'custom_color';
|
||||||
|
static const String _keyLastFmApiKey = 'lastfm_api_key';
|
||||||
|
static const String _keyTheAudioDbApiKey = 'theaudiodb_api_key';
|
||||||
|
|
||||||
static Future<String?> getServerUrl() async {
|
static Future<String?> getServerUrl() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -156,6 +158,35 @@ class SettingsService {
|
|||||||
await prefs.setString(_keyCustomColor, color);
|
await prefs.setString(_keyCustomColor, color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Metadata API Keys
|
||||||
|
static Future<String?> getLastFmApiKey() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyLastFmApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setLastFmApiKey(String? key) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (key == null || key.isEmpty) {
|
||||||
|
await prefs.remove(_keyLastFmApiKey);
|
||||||
|
} else {
|
||||||
|
await prefs.setString(_keyLastFmApiKey, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String?> getTheAudioDbApiKey() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyTheAudioDbApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setTheAudioDbApiKey(String? key) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (key == null || key.isEmpty) {
|
||||||
|
await prefs.remove(_keyTheAudioDbApiKey);
|
||||||
|
} else {
|
||||||
|
await prefs.setString(_keyTheAudioDbApiKey, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> clearSettings() async {
|
static Future<void> clearSettings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.clear();
|
await prefs.clear();
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import '../constants/hero_tags.dart';
|
|||||||
class AlbumCard extends StatelessWidget {
|
class AlbumCard extends StatelessWidget {
|
||||||
final Album album;
|
final Album album;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
|
||||||
const AlbumCard({
|
const AlbumCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.album,
|
required this.album,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
|
this.heroTagSuffix,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -21,23 +23,29 @@ class AlbumCard extends StatelessWidget {
|
|||||||
final imageUrl = maProvider.api?.getImageUrl(album, size: 256);
|
final imageUrl = maProvider.api?.getImageUrl(album, size: 256);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap ?? () {
|
onTap: onTap ?? () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => AlbumDetailsScreen(album: album),
|
builder: (context) => AlbumDetailsScreen(
|
||||||
|
album: album,
|
||||||
|
heroTagSuffix: heroTagSuffix,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Album artwork with Hero animation
|
// Album artwork
|
||||||
Expanded(
|
AspectRatio(
|
||||||
|
aspectRatio: 1.0,
|
||||||
child: Hero(
|
child: Hero(
|
||||||
tag: HeroTags.albumCover + (album.uri ?? album.itemId),
|
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + suffix,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(12.0),
|
borderRadius: BorderRadius.circular(12.0),
|
||||||
@@ -62,9 +70,9 @@ class AlbumCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Album title with Hero animation
|
// Album title
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.albumTitle + (album.uri ?? album.itemId),
|
tag: HeroTags.albumTitle + (album.uri ?? album.itemId) + suffix,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -78,9 +86,9 @@ class AlbumCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Artist name with Hero animation
|
// Artist name
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.artistName + (album.uri ?? album.itemId),
|
tag: HeroTags.artistName + (album.uri ?? album.itemId) + suffix,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
@@ -7,28 +7,42 @@ import 'album_card.dart';
|
|||||||
class AlbumRow extends StatefulWidget {
|
class AlbumRow extends StatefulWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final Future<List<Album>> Function() loadAlbums;
|
final Future<List<Album>> Function() loadAlbums;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
|
||||||
const AlbumRow({
|
const AlbumRow({
|
||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.loadAlbums,
|
required this.loadAlbums,
|
||||||
|
this.heroTagSuffix,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AlbumRow> createState() => _AlbumRowState();
|
State<AlbumRow> createState() => _AlbumRowState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AlbumRowState extends State<AlbumRow> {
|
class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin {
|
||||||
late Future<List<Album>> _albumsFuture;
|
late Future<List<Album>> _albumsFuture;
|
||||||
|
bool _hasLoaded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_albumsFuture = widget.loadAlbums();
|
_loadAlbumsOnce();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadAlbumsOnce() {
|
||||||
|
if (!_hasLoaded) {
|
||||||
|
_albumsFuture = widget.loadAlbums();
|
||||||
|
_hasLoaded = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
@@ -80,7 +94,10 @@ class _AlbumRowState extends State<AlbumRow> {
|
|||||||
return Container(
|
return Container(
|
||||||
width: 150,
|
width: 150,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||||
child: AlbumCard(album: album),
|
child: AlbumCard(
|
||||||
|
album: album,
|
||||||
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import '../constants/hero_tags.dart';
|
|||||||
class ArtistCard extends StatelessWidget {
|
class ArtistCard extends StatelessWidget {
|
||||||
final Artist artist;
|
final Artist artist;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
|
||||||
const ArtistCard({
|
const ArtistCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.artist,
|
required this.artist,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
|
this.heroTagSuffix,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -21,28 +23,29 @@ class ArtistCard extends StatelessWidget {
|
|||||||
final imageUrl = maProvider.api?.getImageUrl(artist, size: 256);
|
final imageUrl = maProvider.api?.getImageUrl(artist, size: 256);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap ?? () {
|
onTap: onTap ?? () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => ArtistDetailsScreen(artist: artist),
|
builder: (context) => ArtistDetailsScreen(
|
||||||
|
artist: artist,
|
||||||
|
heroTagSuffix: heroTagSuffix,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Artist image - circular with Hero animation
|
// Artist image - circular
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId),
|
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
|
||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: 60, // Fixed size radius for row, or flexible? Row uses fixed width 120.
|
radius: 55,
|
||||||
// In Grid we might want flexible.
|
|
||||||
// Let's make it responsive to container width if possible, or just use AspectRatio.
|
|
||||||
// But for CircleAvatar, radius is explicit.
|
|
||||||
// Better to use Container with BoxShape.circle
|
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
|
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
|
||||||
child: imageUrl == null
|
child: imageUrl == null
|
||||||
@@ -51,9 +54,9 @@ class ArtistCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
// Artist name with Hero animation
|
// Artist name
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.artistName + (artist.uri ?? artist.itemId),
|
tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
@@ -7,28 +7,42 @@ import 'artist_card.dart';
|
|||||||
class ArtistRow extends StatefulWidget {
|
class ArtistRow extends StatefulWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final Future<List<Artist>> Function() loadArtists;
|
final Future<List<Artist>> Function() loadArtists;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
|
||||||
const ArtistRow({
|
const ArtistRow({
|
||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.loadArtists,
|
required this.loadArtists,
|
||||||
|
this.heroTagSuffix,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ArtistRow> createState() => _ArtistRowState();
|
State<ArtistRow> createState() => _ArtistRowState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ArtistRowState extends State<ArtistRow> {
|
class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixin {
|
||||||
late Future<List<Artist>> _artistsFuture;
|
late Future<List<Artist>> _artistsFuture;
|
||||||
|
bool _hasLoaded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_artistsFuture = widget.loadArtists();
|
_loadArtistsOnce();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadArtistsOnce() {
|
||||||
|
if (!_hasLoaded) {
|
||||||
|
_artistsFuture = widget.loadArtists();
|
||||||
|
_hasLoaded = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
@@ -80,7 +94,10 @@ class _ArtistRowState extends State<ArtistRow> {
|
|||||||
return Container(
|
return Container(
|
||||||
width: 120,
|
width: 120,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8.0),
|
margin: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
child: ArtistCard(artist: artist),
|
child: ArtistCard(
|
||||||
|
artist: artist,
|
||||||
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,202 +30,193 @@ class MiniPlayer extends StatelessWidget {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
PageRouteBuilder(
|
||||||
builder: (context) => const NowPlayingScreen(),
|
pageBuilder: (context, animation, secondaryAnimation) => const NowPlayingScreen(),
|
||||||
|
transitionDuration: const Duration(milliseconds: 500),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 350),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
return child;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Hero(
|
child: Container(
|
||||||
tag: HeroTags.nowPlayingBackground,
|
height: 80,
|
||||||
child: Material(
|
margin: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||||
color: colorScheme.surface,
|
child: Hero(
|
||||||
elevation: 0,
|
tag: HeroTags.nowPlayingBackground,
|
||||||
child: Container(
|
transitionOnUserGestures: true,
|
||||||
height: 64,
|
child: Material(
|
||||||
decoration: BoxDecoration(
|
color: colorScheme.primaryContainer,
|
||||||
boxShadow: [
|
borderRadius: BorderRadius.circular(20),
|
||||||
BoxShadow(
|
elevation: 4,
|
||||||
color: Colors.black.withOpacity(0.3),
|
shadowColor: Colors.black.withOpacity(0.3),
|
||||||
blurRadius: 8,
|
child: ClipRRect(
|
||||||
offset: const Offset(0, -2),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
child: Padding(
|
||||||
],
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||||
),
|
child: Row(
|
||||||
child: Column(
|
children: [
|
||||||
children: [
|
// Album art with Hero animation
|
||||||
// Status indicator bar
|
Hero(
|
||||||
Container(
|
tag: HeroTags.nowPlayingArt,
|
||||||
height: 2,
|
transitionOnUserGestures: true,
|
||||||
color: selectedPlayer.isPlaying
|
child: Container(
|
||||||
? colorScheme.primary.withOpacity(0.7)
|
width: 72,
|
||||||
: colorScheme.onSurface.withOpacity(0.1),
|
height: 72,
|
||||||
),
|
child: ClipRRect(
|
||||||
// Player content
|
borderRadius: BorderRadius.circular(12),
|
||||||
Expanded(
|
child: imageUrl != null
|
||||||
child: Padding(
|
? Image.network(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
imageUrl,
|
||||||
child: Row(
|
fit: BoxFit.cover,
|
||||||
children: [
|
cacheWidth: 96,
|
||||||
// Album art with Hero animation
|
cacheHeight: 96,
|
||||||
Hero(
|
filterQuality: FilterQuality.medium,
|
||||||
tag: HeroTags.nowPlayingArt,
|
errorBuilder: (context, error, stackTrace) {
|
||||||
child: Container(
|
return Container(
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: imageUrl != null
|
|
||||||
? Image.network(
|
|
||||||
imageUrl,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
cacheWidth: 96,
|
|
||||||
cacheHeight: 96,
|
|
||||||
filterQuality: FilterQuality.medium,
|
|
||||||
errorBuilder: (context, error, stackTrace) {
|
|
||||||
return Container(
|
|
||||||
color: colorScheme.surfaceVariant,
|
|
||||||
child: Icon(
|
|
||||||
Icons.music_note_rounded,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
color: colorScheme.surfaceVariant,
|
color: colorScheme.surfaceVariant,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.music_note_rounded,
|
Icons.music_note_rounded,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: colorScheme.surfaceVariant,
|
||||||
|
child: Icon(
|
||||||
|
Icons.music_note_rounded,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// Track info
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
currentTrack.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 4),
|
||||||
const SizedBox(width: 12),
|
Text(
|
||||||
// Track info
|
currentTrack.artistsString,
|
||||||
Expanded(
|
style: TextStyle(
|
||||||
child: Column(
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
fontSize: 14,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
),
|
||||||
children: [
|
maxLines: 1,
|
||||||
Text(
|
overflow: TextOverflow.ellipsis,
|
||||||
currentTrack.name,
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
currentTrack.artistsString,
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
// Queue button
|
),
|
||||||
IconButton(
|
),
|
||||||
icon: const Icon(Icons.queue_music),
|
// Queue button
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
IconButton(
|
||||||
iconSize: 22,
|
icon: const Icon(Icons.queue_music),
|
||||||
onPressed: () {
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
Navigator.push(
|
iconSize: 26,
|
||||||
context,
|
onPressed: () {
|
||||||
MaterialPageRoute(
|
Navigator.push(
|
||||||
builder: (context) => const QueueScreen(),
|
context,
|
||||||
),
|
MaterialPageRoute(
|
||||||
);
|
builder: (context) => const QueueScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// Playback controls for selected player
|
||||||
|
Hero(
|
||||||
|
tag: HeroTags.nowPlayingPreviousButton,
|
||||||
|
transitionOnUserGestures: true,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: AnimatedIconButton(
|
||||||
|
icon: Icons.skip_previous_rounded,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
iconSize: 30,
|
||||||
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
await maProvider.previousTrackSelectedPlayer();
|
||||||
|
} catch (e) {
|
||||||
|
print('❌ Error in previous track: $e');
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Error: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Volume control (compact mute button)
|
),
|
||||||
const VolumeControl(compact: true),
|
|
||||||
// Playback controls for selected player
|
|
||||||
Hero(
|
|
||||||
tag: HeroTags.nowPlayingPreviousButton,
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: AnimatedIconButton(
|
|
||||||
icon: Icons.skip_previous_rounded,
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
iconSize: 26,
|
|
||||||
onPressed: () async {
|
|
||||||
try {
|
|
||||||
await maProvider.previousTrackSelectedPlayer();
|
|
||||||
} catch (e) {
|
|
||||||
print('❌ Error in previous track: $e');
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Error: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Hero(
|
|
||||||
tag: HeroTags.nowPlayingPlayButton,
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: AnimatedIconButton(
|
|
||||||
icon: selectedPlayer.isPlaying
|
|
||||||
? Icons.pause_rounded
|
|
||||||
: Icons.play_arrow_rounded,
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
iconSize: 32,
|
|
||||||
onPressed: () async {
|
|
||||||
try {
|
|
||||||
await maProvider.playPauseSelectedPlayer();
|
|
||||||
} catch (e) {
|
|
||||||
print('❌ Error in play/pause: $e');
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Error: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Hero(
|
|
||||||
tag: HeroTags.nowPlayingNextButton,
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: AnimatedIconButton(
|
|
||||||
icon: Icons.skip_next_rounded,
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
iconSize: 28,
|
|
||||||
onPressed: () async {
|
|
||||||
try {
|
|
||||||
await maProvider.nextTrackSelectedPlayer();
|
|
||||||
} catch (e) {
|
|
||||||
print('❌ Error in next track: $e');
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Error: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
Hero(
|
||||||
|
tag: HeroTags.nowPlayingPlayButton,
|
||||||
|
transitionOnUserGestures: true,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: AnimatedIconButton(
|
||||||
|
icon: selectedPlayer.isPlaying
|
||||||
|
? Icons.pause_rounded
|
||||||
|
: Icons.play_arrow_rounded,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
iconSize: 38,
|
||||||
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
await maProvider.playPauseSelectedPlayer();
|
||||||
|
} catch (e) {
|
||||||
|
print('❌ Error in play/pause: $e');
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Error: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Hero(
|
||||||
|
tag: HeroTags.nowPlayingNextButton,
|
||||||
|
transitionOnUserGestures: true,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: AnimatedIconButton(
|
||||||
|
icon: Icons.skip_next_rounded,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
iconSize: 32,
|
||||||
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
await maProvider.nextTrackSelectedPlayer();
|
||||||
|
} catch (e) {
|
||||||
|
print('❌ Error in next track: $e');
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Error: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -12,28 +12,57 @@ class PlayerSelector extends StatelessWidget {
|
|||||||
final availablePlayers = maProvider.availablePlayers;
|
final availablePlayers = maProvider.availablePlayers;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return IconButton(
|
return Padding(
|
||||||
icon: Stack(
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
children: [
|
child: Container(
|
||||||
Icon(Icons.speaker_group_rounded, color: colorScheme.onSurface),
|
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
if (selectedPlayer != null)
|
decoration: BoxDecoration(
|
||||||
Positioned(
|
color: colorScheme.surface,
|
||||||
right: 0,
|
borderRadius: BorderRadius.circular(20),
|
||||||
bottom: 0,
|
boxShadow: [
|
||||||
child: Container(
|
BoxShadow(
|
||||||
width: 8,
|
color: Colors.black.withOpacity(0.1),
|
||||||
height: 8,
|
blurRadius: 10,
|
||||||
decoration: BoxDecoration(
|
offset: const Offset(0, 2),
|
||||||
color: Colors.green, // Keep green for "active" status or use primary
|
),
|
||||||
shape: BoxShape.circle,
|
],
|
||||||
border: Border.all(color: colorScheme.background, width: 1),
|
),
|
||||||
),
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _showPlayerSelector(context, maProvider, availablePlayers),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (selectedPlayer != null) ...[
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 120),
|
||||||
|
child: Text(
|
||||||
|
selectedPlayer.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
Icon(
|
||||||
|
Icons.speaker_group_rounded,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
tooltip: selectedPlayer?.name ?? 'Select Player',
|
|
||||||
onPressed: () => _showPlayerSelector(context, maProvider, availablePlayers),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||