Compare commits
51
Commits
v1.6.1
...
v1.8.0-beta
@@ -87,6 +87,8 @@ Ensemble supports multiple authentication methods:
|
||||
- Network connectivity to your Music Assistant server
|
||||
- Android device (Android 5.0+)
|
||||
|
||||
> **Note:** Local device playback (streaming to your phone) is not currently supported on Music Assistant beta 20+. This is due to MA replacing the built-in player with Sendspin. Support will be added once Sendspin is more stable. Remote control of other players works fine.
|
||||
|
||||
## About Music Assistant
|
||||
|
||||
Ensemble is a mobile client for [Music Assistant](https://music-assistant.io/), an open-source music library manager that integrates with various music sources and streaming providers.
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'providers/music_assistant_provider.dart';
|
||||
import 'providers/navigation_provider.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'services/settings_service.dart';
|
||||
@@ -85,6 +86,18 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> didPopRoute() async {
|
||||
// Intercept back button at app level - runs BEFORE Navigator processes it
|
||||
// If player is expanded, collapse it and consume the back gesture
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
return true; // We handled it, don't let Navigator process it
|
||||
}
|
||||
// Let Navigator handle the back gesture normally
|
||||
return super.didPopRoute();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
@@ -134,6 +147,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
lightColorScheme: lightColorScheme,
|
||||
darkColorScheme: darkColorScheme,
|
||||
child: MaterialApp(
|
||||
navigatorKey: navigationProvider.navigatorKey,
|
||||
title: 'Ensemble',
|
||||
debugShowCheckedModeBanner: false,
|
||||
themeMode: themeProvider.themeMode,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
@@ -10,6 +12,7 @@ import '../services/auth/auth_manager.dart';
|
||||
import '../services/device_id_service.dart';
|
||||
import '../services/cache_service.dart';
|
||||
import '../services/local_player_service.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../main.dart' show audioHandler;
|
||||
|
||||
@@ -138,7 +141,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_serverUrl = serverUrl;
|
||||
await SettingsService.setServerUrl(serverUrl);
|
||||
|
||||
await _api?.disconnect();
|
||||
// Dispose the old API to stop any pending reconnects
|
||||
_api?.dispose();
|
||||
|
||||
_api = MusicAssistantAPI(serverUrl, _authManager);
|
||||
|
||||
@@ -172,7 +176,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
} else if (state == MAConnectionState.disconnected) {
|
||||
_availablePlayers = [];
|
||||
_selectedPlayer = null;
|
||||
_cacheService.clearAll();
|
||||
// Only clear detail caches on disconnect, NOT home screen caches
|
||||
// Home screen data should persist across reconnects for better UX
|
||||
_cacheService.clearAllDetailCaches();
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
@@ -1092,7 +1098,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
selectPlayer(playerToSelect);
|
||||
}
|
||||
|
||||
_preloadAdjacentPlayers(preloadAll: true);
|
||||
// Await preload so track data and images are ready for swipe gestures
|
||||
await _preloadAdjacentPlayers(preloadAll: true);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Load and select players', e);
|
||||
}
|
||||
@@ -1103,6 +1110,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
SettingsService.setLastSelectedPlayerId(player.playerId);
|
||||
|
||||
// Immediately set currentTrack from cache to avoid flash during player switch
|
||||
// This ensures the UI shows the correct track info immediately, before
|
||||
// the async _updatePlayerState() completes.
|
||||
// IMPORTANT: Always set from cache, even if null - this prevents showing
|
||||
// stale track info when switching to a non-playing player.
|
||||
_currentTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
|
||||
|
||||
_startPlayerStatePolling();
|
||||
|
||||
_preloadAdjacentPlayers();
|
||||
@@ -1140,9 +1154,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
if (prevIndex != currentIndex) playersToPreload.add(players[prevIndex]);
|
||||
if (nextIndex != currentIndex) playersToPreload.add(players[nextIndex]);
|
||||
|
||||
for (final player in playersToPreload) {
|
||||
_preloadPlayerTrack(player);
|
||||
}
|
||||
// Await all preloads so images are ready for next swipe
|
||||
await Future.wait(
|
||||
playersToPreload.map((player) => _preloadPlayerTrack(player)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _preloadPlayerTrack(Player player) async {
|
||||
@@ -1172,6 +1187,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
} else {
|
||||
_cacheService.setCachedTrackForPlayer(player.playerId, track);
|
||||
_logger.log('🔍 Preload ${player.name}: CACHED track "${track.name}"');
|
||||
|
||||
// Also precache the image so it's ready for swipe preview
|
||||
final imageUrl = getImageUrl(track, size: 512);
|
||||
if (imageUrl != null) {
|
||||
_precacheImage(imageUrl);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_logger.log('🔍 Preload ${player.name}: NO TRACK - queue empty');
|
||||
@@ -1185,6 +1206,34 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Precache an image URL so it loads instantly when displayed
|
||||
Future<void> _precacheImage(String url) async {
|
||||
try {
|
||||
final imageProvider = CachedNetworkImageProvider(url);
|
||||
final imageStream = imageProvider.resolve(const ImageConfiguration());
|
||||
final completer = Completer<void>();
|
||||
late ImageStreamListener listener;
|
||||
|
||||
listener = ImageStreamListener(
|
||||
(ImageInfo info, bool synchronousCall) {
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
imageStream.removeListener(listener);
|
||||
},
|
||||
onError: (exception, stackTrace) {
|
||||
if (!completer.isCompleted) completer.completeError(exception);
|
||||
imageStream.removeListener(listener);
|
||||
},
|
||||
);
|
||||
|
||||
imageStream.addListener(listener);
|
||||
await completer.future.timeout(const Duration(seconds: 5), onTimeout: () {
|
||||
imageStream.removeListener(listener);
|
||||
});
|
||||
} catch (e) {
|
||||
// Silently ignore precache errors - image will load on demand
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> preloadAllPlayerTracks() async {
|
||||
if (_api == null) return;
|
||||
|
||||
@@ -1453,6 +1502,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
return _api?.getImageUrl(item, size: size);
|
||||
}
|
||||
|
||||
/// Get artist image URL with fallback to external sources (Deezer, Fanart.tv)
|
||||
/// Returns a Future since fallback requires async API calls
|
||||
Future<String?> getArtistImageUrlWithFallback(Artist artist, {int size = 256}) async {
|
||||
// Try Music Assistant first
|
||||
final maUrl = _api?.getImageUrl(artist, size: size);
|
||||
if (maUrl != null) {
|
||||
return maUrl;
|
||||
}
|
||||
|
||||
// Fall back to external sources (Deezer, Fanart.tv, etc.)
|
||||
return MetadataService.getArtistImageUrl(artist.name);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAYER AND QUEUE MANAGEMENT
|
||||
// ============================================================================
|
||||
@@ -1507,6 +1569,18 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playArtistRadio(String playerId, Artist artist) async {
|
||||
try {
|
||||
await _api?.playArtistRadio(playerId, artist);
|
||||
} catch (e) {
|
||||
final errorInfo = ErrorHandler.handleError(e, context: 'Play artist radio');
|
||||
_error = errorInfo.userMessage;
|
||||
ErrorHandler.logError('Play artist radio', e);
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getCurrentStreamUrl(String playerId) async {
|
||||
return await _api?.getCurrentStreamUrl(playerId);
|
||||
}
|
||||
|
||||
@@ -43,8 +43,43 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
super.initState();
|
||||
_isFavorite = widget.album.favorite ?? false;
|
||||
_loadTracks();
|
||||
_extractColors();
|
||||
_loadAlbumDescription();
|
||||
_refreshFavoriteStatus();
|
||||
|
||||
// CRITICAL FIX: Defer color extraction until after the transition completes
|
||||
// This prevents expensive palette extraction during hero animation
|
||||
// Wait for the first frame to be painted, then extract colors
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Add small delay to ensure transition is complete
|
||||
Future.delayed(const Duration(milliseconds: 350), () {
|
||||
if (mounted) {
|
||||
_extractColors();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _refreshFavoriteStatus() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
if (maProvider.api == null) return;
|
||||
|
||||
// Need a URI to fetch fresh album data
|
||||
final albumUri = widget.album.uri;
|
||||
if (albumUri == null || albumUri.isEmpty) {
|
||||
_logger.log('Cannot refresh favorite status: album has no URI');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final freshAlbum = await maProvider.api!.getAlbumByUri(albumUri);
|
||||
if (freshAlbum != null && mounted) {
|
||||
setState(() {
|
||||
_isFavorite = freshAlbum.favorite ?? false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error refreshing favorite status: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _extractColors() async {
|
||||
@@ -74,19 +109,63 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
if (maProvider.api == null) return;
|
||||
|
||||
try {
|
||||
// Toggle the state locally first
|
||||
final newState = !_isFavorite;
|
||||
|
||||
await maProvider.api!.toggleFavorite(
|
||||
'album',
|
||||
widget.album.itemId,
|
||||
widget.album.provider,
|
||||
);
|
||||
if (newState) {
|
||||
// For adding: use the actual provider and itemId from provider_mappings
|
||||
String actualProvider = widget.album.provider;
|
||||
String actualItemId = widget.album.itemId;
|
||||
|
||||
if (widget.album.providerMappings != null && widget.album.providerMappings!.isNotEmpty) {
|
||||
// Find a non-library provider mapping (e.g., spotify, qobuz, etc.)
|
||||
final mapping = widget.album.providerMappings!.firstWhere(
|
||||
(m) => m.available && m.providerInstance != 'library',
|
||||
orElse: () => widget.album.providerMappings!.firstWhere(
|
||||
(m) => m.available,
|
||||
orElse: () => widget.album.providerMappings!.first,
|
||||
),
|
||||
);
|
||||
actualProvider = mapping.providerInstance;
|
||||
actualItemId = mapping.itemId;
|
||||
}
|
||||
|
||||
_logger.log('Adding to favorites: provider=$actualProvider, itemId=$actualItemId');
|
||||
await maProvider.api!.addToFavorites('album', actualItemId, actualProvider);
|
||||
} else {
|
||||
// For removing: need the library_item_id (numeric)
|
||||
// When provider is "library", itemId is the library ID as a string
|
||||
// Otherwise, we need to find it from provider_mappings
|
||||
int? libraryItemId;
|
||||
|
||||
if (widget.album.provider == 'library') {
|
||||
libraryItemId = int.tryParse(widget.album.itemId);
|
||||
} else if (widget.album.providerMappings != null) {
|
||||
// Find the library mapping
|
||||
final libraryMapping = widget.album.providerMappings!.firstWhere(
|
||||
(m) => m.providerInstance == 'library',
|
||||
orElse: () => widget.album.providerMappings!.first,
|
||||
);
|
||||
if (libraryMapping.providerInstance == 'library') {
|
||||
libraryItemId = int.tryParse(libraryMapping.itemId);
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId == null) {
|
||||
_logger.log('Error: Could not determine library_item_id for removal');
|
||||
throw Exception('Could not determine library ID for this album');
|
||||
}
|
||||
|
||||
_logger.log('Removing from favorites: libraryItemId=$libraryItemId');
|
||||
await maProvider.api!.removeFromFavorites('album', libraryItemId);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isFavorite = newState;
|
||||
});
|
||||
|
||||
// Invalidate home cache so the home screen shows updated favorite status
|
||||
maProvider.invalidateHomeCache();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -99,6 +178,100 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling favorite: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to update favorite: $e'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleTrackFavorite(int trackIndex) async {
|
||||
if (trackIndex < 0 || trackIndex >= _tracks.length) return;
|
||||
|
||||
final track = _tracks[trackIndex];
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
if (maProvider.api == null) return;
|
||||
|
||||
final currentFavorite = track.favorite ?? false;
|
||||
|
||||
try {
|
||||
if (currentFavorite) {
|
||||
// Remove from favorites - need library_item_id
|
||||
int? libraryItemId;
|
||||
if (track.provider == 'library') {
|
||||
libraryItemId = int.tryParse(track.itemId);
|
||||
} else if (track.providerMappings != null) {
|
||||
final libraryMapping = track.providerMappings!.firstWhere(
|
||||
(m) => m.providerInstance == 'library',
|
||||
orElse: () => track.providerMappings!.first,
|
||||
);
|
||||
if (libraryMapping.providerInstance == 'library') {
|
||||
libraryItemId = int.tryParse(libraryMapping.itemId);
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId != null) {
|
||||
await maProvider.api!.removeFromFavorites('track', libraryItemId);
|
||||
}
|
||||
} else {
|
||||
// Add to favorites
|
||||
String actualProvider = track.provider;
|
||||
String actualItemId = track.itemId;
|
||||
|
||||
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
|
||||
final mapping = track.providerMappings!.firstWhere(
|
||||
(m) => m.available && m.providerInstance != 'library',
|
||||
orElse: () => track.providerMappings!.firstWhere(
|
||||
(m) => m.available,
|
||||
orElse: () => track.providerMappings!.first,
|
||||
),
|
||||
);
|
||||
actualProvider = mapping.providerInstance;
|
||||
actualItemId = mapping.itemId;
|
||||
}
|
||||
|
||||
await maProvider.api!.addToFavorites('track', actualItemId, actualProvider);
|
||||
}
|
||||
|
||||
// Update local state - create new track with toggled favorite
|
||||
setState(() {
|
||||
_tracks[trackIndex] = Track(
|
||||
itemId: track.itemId,
|
||||
provider: track.provider,
|
||||
name: track.name,
|
||||
uri: track.uri,
|
||||
favorite: !currentFavorite,
|
||||
artists: track.artists,
|
||||
album: track.album,
|
||||
duration: track.duration,
|
||||
providerMappings: track.providerMappings,
|
||||
);
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
!currentFavorite ? 'Added to favorites' : 'Removed from favorites',
|
||||
),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling track favorite: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to update favorite: $e'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,34 +338,84 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addAlbumToQueue() async {
|
||||
void _addAlbumToQueue() {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final players = maProvider.availablePlayers;
|
||||
|
||||
try {
|
||||
final player = maProvider.selectedPlayer;
|
||||
if (player == null) {
|
||||
_showError('No player selected');
|
||||
return;
|
||||
}
|
||||
// Slide mini player down out of the way
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
_logger.log('Adding album to queue on ${player.name}');
|
||||
|
||||
// Add all tracks to queue without clearing
|
||||
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0, clearQueue: false);
|
||||
_logger.log('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) {
|
||||
_logger.log('Error adding album to queue: $e');
|
||||
_showError('Failed to add album to queue: $e');
|
||||
}
|
||||
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 album 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 {
|
||||
_logger.log('Adding album to queue on ${player.name}');
|
||||
await maProvider.playTracks(
|
||||
player.playerId,
|
||||
_tracks,
|
||||
startIndex: 0,
|
||||
clearQueue: false,
|
||||
);
|
||||
_logger.log('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) {
|
||||
_logger.log('Error adding album to queue: $e');
|
||||
_showError('Failed to add album to queue: $e');
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
).whenComplete(() {
|
||||
// Show mini player again when sheet closes
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
}
|
||||
|
||||
void _addTrackToQueue(BuildContext context, int index) {
|
||||
@@ -318,18 +541,32 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
final themeProvider = context.watch<ThemeProvider>();
|
||||
final imageUrl = maProvider.getImageUrl(widget.album, size: 512);
|
||||
|
||||
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
|
||||
// Only rebuild when specific properties change, not on every provider update
|
||||
final imageUrl = context.select<MusicAssistantProvider, String?>(
|
||||
(provider) => provider.getImageUrl(widget.album, size: 512),
|
||||
);
|
||||
final adaptiveTheme = context.select<ThemeProvider, bool>(
|
||||
(provider) => provider.adaptiveTheme,
|
||||
);
|
||||
final adaptiveLightScheme = context.select<ThemeProvider, ColorScheme?>(
|
||||
(provider) => provider.adaptiveLightScheme,
|
||||
);
|
||||
final adaptiveDarkScheme = context.select<ThemeProvider, ColorScheme?>(
|
||||
(provider) => provider.adaptiveDarkScheme,
|
||||
);
|
||||
|
||||
// Determine if we should use adaptive theme colors
|
||||
final useAdaptiveTheme = themeProvider.adaptiveTheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
// Get the color scheme to use
|
||||
// Get the color scheme to use - prefer local state over provider
|
||||
// Local state (_darkColorScheme/_lightColorScheme) is set by _extractColors()
|
||||
ColorScheme? adaptiveScheme;
|
||||
if (useAdaptiveTheme) {
|
||||
adaptiveScheme = isDark ? _darkColorScheme : _lightColorScheme;
|
||||
if (adaptiveTheme) {
|
||||
// Use local state first (from _extractColors), fallback to provider
|
||||
adaptiveScheme = isDark
|
||||
? (_darkColorScheme ?? adaptiveDarkScheme)
|
||||
: (_lightColorScheme ?? adaptiveLightScheme);
|
||||
}
|
||||
|
||||
// Use adaptive scheme if available, otherwise use global theme
|
||||
@@ -523,18 +760,22 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Favorite Button
|
||||
Container(
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: _isFavorite ? colorScheme.errorContainer : colorScheme.surfaceVariant,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _toggleFavorite,
|
||||
icon: Icon(
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(25), // Circular
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
_isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
color: _isFavorite ? colorScheme.error : colorScheme.onSurfaceVariant,
|
||||
color: _isFavorite
|
||||
? colorScheme.error
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -645,39 +886,60 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
curve: Curves.easeInOut,
|
||||
child: isExpanded
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
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),
|
||||
),
|
||||
// Radio button
|
||||
SizedBox(
|
||||
height: 44,
|
||||
width: 44,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _showPlayRadioMenu(context, index),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text('Start radio'),
|
||||
),
|
||||
child: const Icon(Icons.radio, size: 20),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Add to queue button
|
||||
SizedBox(
|
||||
height: 44,
|
||||
width: 44,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _addTrackToQueue(context, index),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text('Add to queue'),
|
||||
),
|
||||
child: const Icon(Icons.playlist_add, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Favorite button
|
||||
SizedBox(
|
||||
height: 44,
|
||||
width: 44,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _toggleTrackFavorite(index),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
track.favorite == true ? Icons.favorite : Icons.favorite_border,
|
||||
size: 20,
|
||||
color: track.favorite == true
|
||||
? colorScheme.error
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -31,27 +31,295 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
List<Album> _albums = [];
|
||||
List<Album> _providerAlbums = [];
|
||||
bool _isLoading = true;
|
||||
bool _isFavorite = false;
|
||||
ColorScheme? _lightColorScheme;
|
||||
ColorScheme? _darkColorScheme;
|
||||
bool _isDescriptionExpanded = false;
|
||||
String? _artistDescription;
|
||||
String? _artistImageUrl;
|
||||
|
||||
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isFavorite = widget.artist.favorite ?? false;
|
||||
_loadArtistAlbums();
|
||||
_extractColors();
|
||||
_loadArtistDescription();
|
||||
_refreshFavoriteStatus();
|
||||
|
||||
// CRITICAL FIX: Defer image loading and color extraction until after transition
|
||||
// This prevents expensive operations during hero animation
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Future.delayed(const Duration(milliseconds: 350), () {
|
||||
if (mounted) {
|
||||
_loadArtistImage();
|
||||
// Note: _extractColors is called by _loadArtistImage after image loads
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _extractColors() async {
|
||||
Future<void> _refreshFavoriteStatus() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.getImageUrl(widget.artist, size: 512);
|
||||
if (maProvider.api == null) return;
|
||||
|
||||
if (imageUrl == null) return;
|
||||
final artistUri = widget.artist.uri;
|
||||
if (artistUri == null || artistUri.isEmpty) {
|
||||
_logger.log('Cannot refresh favorite status: artist has no URI');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final freshArtist = await maProvider.api!.getArtistByUri(artistUri);
|
||||
if (freshArtist != null && mounted) {
|
||||
setState(() {
|
||||
_isFavorite = freshArtist.favorite ?? false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error refreshing artist favorite status: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
if (maProvider.api == null) return;
|
||||
|
||||
try {
|
||||
final newState = !_isFavorite;
|
||||
|
||||
if (newState) {
|
||||
String actualProvider = widget.artist.provider;
|
||||
String actualItemId = widget.artist.itemId;
|
||||
|
||||
if (widget.artist.providerMappings != null && widget.artist.providerMappings!.isNotEmpty) {
|
||||
final mapping = widget.artist.providerMappings!.firstWhere(
|
||||
(m) => m.available && m.providerInstance != 'library',
|
||||
orElse: () => widget.artist.providerMappings!.firstWhere(
|
||||
(m) => m.available,
|
||||
orElse: () => widget.artist.providerMappings!.first,
|
||||
),
|
||||
);
|
||||
actualProvider = mapping.providerInstance;
|
||||
actualItemId = mapping.itemId;
|
||||
}
|
||||
|
||||
_logger.log('Adding artist to favorites: provider=$actualProvider, itemId=$actualItemId');
|
||||
await maProvider.api!.addToFavorites('artist', actualItemId, actualProvider);
|
||||
} else {
|
||||
int? libraryItemId;
|
||||
|
||||
if (widget.artist.provider == 'library') {
|
||||
libraryItemId = int.tryParse(widget.artist.itemId);
|
||||
} else if (widget.artist.providerMappings != null) {
|
||||
final libraryMapping = widget.artist.providerMappings!.firstWhere(
|
||||
(m) => m.providerInstance == 'library',
|
||||
orElse: () => widget.artist.providerMappings!.first,
|
||||
);
|
||||
if (libraryMapping.providerInstance == 'library') {
|
||||
libraryItemId = int.tryParse(libraryMapping.itemId);
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId == null) {
|
||||
_logger.log('Error: Could not determine library_item_id for removal');
|
||||
throw Exception('Could not determine library ID for this artist');
|
||||
}
|
||||
|
||||
_logger.log('Removing artist from favorites: libraryItemId=$libraryItemId');
|
||||
await maProvider.api!.removeFromFavorites('artist', libraryItemId);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isFavorite = newState;
|
||||
});
|
||||
|
||||
maProvider.invalidateHomeCache();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_isFavorite ? 'Added to favorites' : 'Removed from favorites',
|
||||
),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling artist favorite: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to update favorite: $e'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startArtistRadio() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
|
||||
if (selectedPlayer == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No player selected')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await maProvider.playArtistRadio(selectedPlayer.playerId, widget.artist);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Starting ${widget.artist.name} radio'),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error starting artist radio: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to start radio: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showPlayOnMenu(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final players = maProvider.availablePlayers;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) => Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Start ${widget.artist.name} radio 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: colorScheme.onSurface,
|
||||
),
|
||||
title: Text(player.name),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
try {
|
||||
await maProvider.playArtistRadio(player.playerId, widget.artist);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Starting ${widget.artist.name} radio on ${player.name}'),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error starting artist radio on player: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to start radio: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addArtistRadioToQueue() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
|
||||
if (selectedPlayer == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No player selected')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// For adding to queue with radio mode, we use radio_mode but with 'add' option
|
||||
await maProvider.api?.playArtistRadioToQueue(selectedPlayer.playerId, widget.artist);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Added ${widget.artist.name} radio to queue'),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error adding artist radio to queue: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to add to queue: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadArtistImage() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// Get image URL with fallback to Deezer/Fanart.tv
|
||||
final imageUrl = await maProvider.getArtistImageUrlWithFallback(widget.artist, size: 512);
|
||||
|
||||
if (mounted && imageUrl != null) {
|
||||
setState(() {
|
||||
_artistImageUrl = imageUrl;
|
||||
});
|
||||
// Extract colors after we have the image
|
||||
_extractColors(imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _extractColors(String imageUrl) async {
|
||||
try {
|
||||
final colorSchemes = await PaletteHelper.extractColorSchemes(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
@@ -102,15 +370,30 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
final themeProvider = context.watch<ThemeProvider>();
|
||||
final imageUrl = maProvider.getImageUrl(widget.artist, size: 512);
|
||||
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
|
||||
// Only rebuild when specific properties change, not on every provider update
|
||||
final adaptiveTheme = context.select<ThemeProvider, bool>(
|
||||
(provider) => provider.adaptiveTheme,
|
||||
);
|
||||
final adaptiveLightScheme = context.select<ThemeProvider, ColorScheme?>(
|
||||
(provider) => provider.adaptiveLightScheme,
|
||||
);
|
||||
final adaptiveDarkScheme = context.select<ThemeProvider, ColorScheme?>(
|
||||
(provider) => provider.adaptiveDarkScheme,
|
||||
);
|
||||
|
||||
// Use the loaded image URL (with fallback) instead of directly from MA
|
||||
final imageUrl = _artistImageUrl;
|
||||
|
||||
final useAdaptiveTheme = themeProvider.adaptiveTheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
// Get the color scheme to use - prefer local state over provider
|
||||
ColorScheme? adaptiveScheme;
|
||||
if (useAdaptiveTheme) {
|
||||
adaptiveScheme = isDark ? _darkColorScheme : _lightColorScheme;
|
||||
if (adaptiveTheme) {
|
||||
// Use local state first (from _extractColors), fallback to provider
|
||||
adaptiveScheme = isDark
|
||||
? (_darkColorScheme ?? adaptiveDarkScheme)
|
||||
: (_lightColorScheme ?? adaptiveLightScheme);
|
||||
}
|
||||
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
@@ -214,6 +497,89 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
// Action Buttons Row
|
||||
Row(
|
||||
children: [
|
||||
// Main Radio Button
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _startArtistRadio,
|
||||
icon: const Icon(Icons.radio),
|
||||
label: const Text('Radio'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// "Play on..." Button (Square)
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _showPlayOnMenu(context),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.speaker_group_outlined),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// "Add to Queue" Button (Square)
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _addArtistRadioToQueue,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.playlist_add),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Favorite Button
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _toggleFavorite,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
_isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
color: _isFavorite
|
||||
? colorScheme.error
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -263,7 +629,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final album = _albums[index];
|
||||
return _buildAlbumCard(album, maProvider);
|
||||
return _buildAlbumCard(album);
|
||||
},
|
||||
childCount: _albums.length,
|
||||
),
|
||||
@@ -297,7 +663,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final album = _providerAlbums[index];
|
||||
return _buildAlbumCard(album, maProvider);
|
||||
return _buildAlbumCard(album);
|
||||
},
|
||||
childCount: _providerAlbums.length,
|
||||
),
|
||||
@@ -312,8 +678,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumCard(Album album, MusicAssistantProvider provider) {
|
||||
final imageUrl = provider.getImageUrl(album, size: 256);
|
||||
Widget _buildAlbumCard(Album album) {
|
||||
// Use read instead of passing provider to avoid rebuild dependencies
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.getImageUrl(album, size: 256);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
const String heroSuffix = 'artist_albums';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
@@ -15,6 +16,8 @@ class DebugLogScreen extends StatefulWidget {
|
||||
class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||
final _logger = DebugLogger();
|
||||
final _scrollController = ScrollController();
|
||||
LogLevel _filterLevel = LogLevel.debug; // Show all by default
|
||||
bool _isGeneratingReport = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -33,17 +36,37 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<LogEntry> get _filteredEntries =>
|
||||
_logger.entries.where((e) => e.level.index >= _filterLevel.index).toList();
|
||||
|
||||
void _copyLogs() {
|
||||
Clipboard.setData(ClipboardData(text: _logger.getAllLogs()));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Logs copied to clipboard!'),
|
||||
content: Text('Logs copied to clipboard'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _shareBugReport() async {
|
||||
setState(() => _isGeneratingReport = true);
|
||||
|
||||
try {
|
||||
final report = await _logger.generateBugReport();
|
||||
|
||||
await Share.share(
|
||||
report,
|
||||
subject: 'Ensemble Bug Report',
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isGeneratingReport = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _clearLogs() {
|
||||
setState(() {
|
||||
_logger.clear();
|
||||
@@ -216,16 +239,39 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
// Share bug report button
|
||||
_isGeneratingReport
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.share_rounded),
|
||||
onPressed: _shareBugReport,
|
||||
color: Colors.white,
|
||||
tooltip: 'Share bug report',
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert, color: Colors.white),
|
||||
tooltip: 'Player tools',
|
||||
tooltip: 'More options',
|
||||
onSelected: (value) async {
|
||||
switch (value) {
|
||||
case 'show_players':
|
||||
_showAllPlayers();
|
||||
break;
|
||||
// NOTE: Ghost cleanup and repair options removed - MA APIs don't work
|
||||
// reliably and caused corrupt entries. See PLAYER_LIFECYCLE_GUIDE.md
|
||||
case 'copy_logs':
|
||||
_copyLogs();
|
||||
break;
|
||||
case 'clear_logs':
|
||||
_clearLogs();
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
@@ -237,49 +283,61 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||
dense: true,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'copy_logs',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.copy_rounded),
|
||||
title: Text('Copy Logs'),
|
||||
dense: true,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'clear_logs',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.delete_rounded),
|
||||
title: Text('Clear Logs'),
|
||||
dense: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy_rounded),
|
||||
onPressed: _copyLogs,
|
||||
color: Colors.white,
|
||||
tooltip: 'Copy logs',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_rounded),
|
||||
onPressed: _clearLogs,
|
||||
color: Colors.white,
|
||||
tooltip: 'Clear logs',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Filter chips and stats
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
color: Colors.white.withOpacity(0.05),
|
||||
child: Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: Colors.white70,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Last ${_logger.logs.length} log entries',
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
// Level filter chips
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildFilterChip('All', LogLevel.debug),
|
||||
const SizedBox(width: 8),
|
||||
_buildFilterChip('Info+', LogLevel.info),
|
||||
const SizedBox(width: 8),
|
||||
_buildFilterChip('Warnings', LogLevel.warning),
|
||||
const SizedBox(width: 8),
|
||||
_buildFilterChip('Errors', LogLevel.error),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Stats
|
||||
Text(
|
||||
'Showing ${_filteredEntries.length} of ${_logger.entries.length} entries',
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _logger.logs.isEmpty
|
||||
child: _filteredEntries.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'No logs yet',
|
||||
@@ -291,43 +349,11 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: EdgeInsets.fromLTRB(8, 8, 8, BottomSpacing.navBarOnly + 56), // Extra space for FAB
|
||||
itemCount: _logger.logs.length,
|
||||
padding: EdgeInsets.fromLTRB(8, 8, 8, BottomSpacing.navBarOnly + 56),
|
||||
itemCount: _filteredEntries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final log = _logger.logs[index];
|
||||
final isError = log.contains('Error') ||
|
||||
log.contains('error') ||
|
||||
log.contains('ERROR') ||
|
||||
log.contains('failed') ||
|
||||
log.contains('Failed');
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isError
|
||||
? Colors.red.withOpacity(0.1)
|
||||
: Colors.white.withOpacity(0.03),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: isError
|
||||
? Colors.red.withOpacity(0.3)
|
||||
: Colors.white.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
log,
|
||||
style: TextStyle(
|
||||
color: isError ? Colors.red[300] : Colors.white70,
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
);
|
||||
final entry = _filteredEntries[index];
|
||||
return _buildLogEntry(entry);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -353,4 +379,68 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterChip(String label, LogLevel level) {
|
||||
final isSelected = _filterLevel == level;
|
||||
return FilterChip(
|
||||
label: Text(label),
|
||||
selected: isSelected,
|
||||
showCheckmark: false,
|
||||
onSelected: (_) {
|
||||
setState(() {
|
||||
_filterLevel = level;
|
||||
});
|
||||
},
|
||||
backgroundColor: Colors.white.withOpacity(0.1),
|
||||
selectedColor: _getLevelColor(level).withOpacity(0.3),
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected ? _getLevelColor(level) : Colors.white70,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
fontSize: 12,
|
||||
),
|
||||
side: BorderSide(
|
||||
color: isSelected ? _getLevelColor(level) : Colors.transparent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLogEntry(LogEntry entry) {
|
||||
final color = _getLevelColor(entry.level);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
),
|
||||
child: Text(
|
||||
entry.formatted,
|
||||
style: TextStyle(
|
||||
color: entry.level == LogLevel.error
|
||||
? Colors.red[300]
|
||||
: entry.level == LogLevel.warning
|
||||
? Colors.orange[300]
|
||||
: Colors.white70,
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getLevelColor(LogLevel level) {
|
||||
switch (level) {
|
||||
case LogLevel.debug:
|
||||
return Colors.grey;
|
||||
case LogLevel.info:
|
||||
return Colors.blue;
|
||||
case LogLevel.warning:
|
||||
return Colors.orange;
|
||||
case LogLevel.error:
|
||||
return Colors.red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../widgets/common/empty_state.dart';
|
||||
import '../widgets/artist_avatar.dart';
|
||||
import 'artist_details_screen.dart';
|
||||
|
||||
class LibraryArtistsScreen extends StatelessWidget {
|
||||
@@ -88,8 +88,6 @@ class LibraryArtistsScreen extends StatelessWidget {
|
||||
Artist artist, {
|
||||
Key? key,
|
||||
}) {
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = provider.getImageUrl(artist, size: 128);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
@@ -97,14 +95,11 @@ class LibraryArtistsScreen extends StatelessWidget {
|
||||
return RepaintBoundary(
|
||||
child: ListTile(
|
||||
key: key,
|
||||
leading: CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
backgroundImage: imageUrl != null ? CachedNetworkImageProvider(imageUrl) : null,
|
||||
child: imageUrl == null
|
||||
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
),
|
||||
leading: ArtistAvatar(
|
||||
artist: artist,
|
||||
radius: 24,
|
||||
imageSize: 128,
|
||||
),
|
||||
title: Text(
|
||||
artist.name,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
|
||||
@@ -106,7 +106,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Recently played albums (with caching)
|
||||
// Stable keys prevent widget recreation on parent rebuilds
|
||||
AlbumRow(
|
||||
key: const ValueKey('recent-albums'),
|
||||
title: 'Recently Played',
|
||||
loadAlbums: () => provider.getRecentAlbumsWithCache(),
|
||||
),
|
||||
@@ -114,6 +116,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
|
||||
// Discover Artists (with caching)
|
||||
ArtistRow(
|
||||
key: const ValueKey('discover-artists'),
|
||||
title: 'Discover Artists',
|
||||
loadArtists: () => provider.getDiscoverArtistsWithCache(),
|
||||
),
|
||||
@@ -121,6 +124,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
|
||||
// Discover Albums (with caching)
|
||||
AlbumRow(
|
||||
key: const ValueKey('discover-albums'),
|
||||
title: 'Discover Albums',
|
||||
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../models/media_item.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/player_selector.dart';
|
||||
import '../widgets/album_card.dart';
|
||||
import '../widgets/artist_avatar.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
@@ -26,11 +27,16 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
with SingleTickerProviderStateMixin, RestorationMixin {
|
||||
late TabController _tabController;
|
||||
List<Playlist> _playlists = [];
|
||||
List<Track> _favoriteTracks = [];
|
||||
bool _isLoadingPlaylists = true;
|
||||
bool _isLoadingTracks = false;
|
||||
bool _showFavoritesOnly = false;
|
||||
|
||||
// Restoration: Remember selected tab across app restarts
|
||||
final RestorableInt _selectedTabIndex = RestorableInt(0);
|
||||
|
||||
int get _tabCount => _showFavoritesOnly ? 4 : 3;
|
||||
|
||||
@override
|
||||
String? get restorationId => 'new_library_screen';
|
||||
|
||||
@@ -38,7 +44,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
|
||||
registerForRestoration(_selectedTabIndex, 'selected_tab_index');
|
||||
// Apply restored tab index after TabController is created
|
||||
if (_tabController.index != _selectedTabIndex.value) {
|
||||
if (_tabController.index != _selectedTabIndex.value &&
|
||||
_selectedTabIndex.value < _tabController.length) {
|
||||
_tabController.index = _selectedTabIndex.value;
|
||||
}
|
||||
}
|
||||
@@ -46,12 +53,24 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
_tabController = TabController(length: _tabCount, vsync: this);
|
||||
// Listen to tab changes to persist selection
|
||||
_tabController.addListener(_onTabChanged);
|
||||
_loadPlaylists();
|
||||
}
|
||||
|
||||
void _recreateTabController() {
|
||||
final oldIndex = _tabController.index;
|
||||
_tabController.removeListener(_onTabChanged);
|
||||
_tabController.dispose();
|
||||
_tabController = TabController(length: _tabCount, vsync: this);
|
||||
_tabController.addListener(_onTabChanged);
|
||||
// Restore to previous index if valid, otherwise default to 0
|
||||
if (oldIndex < _tabCount) {
|
||||
_tabController.index = oldIndex;
|
||||
}
|
||||
}
|
||||
|
||||
void _onTabChanged() {
|
||||
if (!_tabController.indexIsChanging) {
|
||||
_selectedTabIndex.value = _tabController.index;
|
||||
@@ -66,10 +85,13 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadPlaylists() async {
|
||||
Future<void> _loadPlaylists({bool? favoriteOnly}) async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
if (maProvider.api != null) {
|
||||
final playlists = await maProvider.api!.getPlaylists(limit: 100);
|
||||
final playlists = await maProvider.api!.getPlaylists(
|
||||
limit: 100,
|
||||
favoriteOnly: favoriteOnly,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_playlists = playlists;
|
||||
@@ -85,6 +107,47 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadFavoriteTracks() async {
|
||||
if (_isLoadingTracks) return;
|
||||
|
||||
setState(() {
|
||||
_isLoadingTracks = true;
|
||||
});
|
||||
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
if (maProvider.api != null) {
|
||||
final tracks = await maProvider.api!.getTracks(
|
||||
limit: 500,
|
||||
favoriteOnly: true,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_favoriteTracks = tracks;
|
||||
_isLoadingTracks = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingTracks = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleFavoritesMode(bool value) {
|
||||
setState(() {
|
||||
_showFavoritesOnly = value;
|
||||
_recreateTabController();
|
||||
});
|
||||
if (value) {
|
||||
_loadPlaylists(favoriteOnly: true);
|
||||
_loadFavoriteTracks();
|
||||
} else {
|
||||
_loadPlaylists();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Use Selector for targeted rebuilds - only rebuild when connection state changes
|
||||
@@ -124,15 +187,40 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
'Library',
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
titleSpacing: 16,
|
||||
title: Row(
|
||||
children: [
|
||||
// Favorites toggle with label
|
||||
GestureDetector(
|
||||
onTap: () => _toggleFavoritesMode(!_showFavoritesOnly),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Switch(
|
||||
value: _showFavoritesOnly,
|
||||
onChanged: _toggleFavoritesMode,
|
||||
activeColor: colorScheme.primary,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Favourites',
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: _showFavoritesOnly
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontWeight: _showFavoritesOnly ? FontWeight.w500 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: const [PlayerSelector()],
|
||||
centerTitle: false,
|
||||
actions: const [
|
||||
PlayerSelector(),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: colorScheme.primary,
|
||||
@@ -141,10 +229,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
indicatorWeight: 3,
|
||||
labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
|
||||
unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w400, fontSize: 14),
|
||||
tabs: const [
|
||||
Tab(text: 'Artists'),
|
||||
Tab(text: 'Albums'),
|
||||
Tab(text: 'Playlists'),
|
||||
tabs: [
|
||||
const Tab(text: 'Artists'),
|
||||
const Tab(text: 'Albums'),
|
||||
if (_showFavoritesOnly) const Tab(text: 'Tracks'),
|
||||
const Tab(text: 'Playlists'),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -153,6 +242,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
children: [
|
||||
_buildArtistsTab(context),
|
||||
_buildAlbumsTab(context),
|
||||
if (_showFavoritesOnly) _buildTracksTab(context),
|
||||
_buildPlaylistsTab(context),
|
||||
],
|
||||
),
|
||||
@@ -167,14 +257,26 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
return Selector<MusicAssistantProvider, (List<Artist>, bool)>(
|
||||
selector: (_, provider) => (provider.artists, provider.isLoading),
|
||||
builder: (context, data, _) {
|
||||
final (artists, isLoading) = data;
|
||||
final (allArtists, isLoading) = data;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (isLoading) {
|
||||
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
|
||||
}
|
||||
|
||||
// Filter by favorites if enabled
|
||||
final artists = _showFavoritesOnly
|
||||
? allArtists.where((a) => a.favorite == true).toList()
|
||||
: allArtists;
|
||||
|
||||
if (artists.isEmpty) {
|
||||
if (_showFavoritesOnly) {
|
||||
return EmptyState.custom(
|
||||
icon: Icons.favorite_border,
|
||||
title: 'No favorite artists',
|
||||
subtitle: 'Tap the heart on an artist to add them to favorites',
|
||||
);
|
||||
}
|
||||
return EmptyState.artists(
|
||||
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||
);
|
||||
@@ -185,7 +287,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
backgroundColor: colorScheme.surface,
|
||||
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||
child: ListView.builder(
|
||||
key: const PageStorageKey<String>('library_artists_list'),
|
||||
key: PageStorageKey<String>('library_artists_list_${_showFavoritesOnly ? 'fav' : 'all'}'),
|
||||
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
||||
itemCount: artists.length,
|
||||
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
|
||||
@@ -208,8 +310,6 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
Artist artist, {
|
||||
Key? key,
|
||||
}) {
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = provider.getImageUrl(artist, size: 128);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final suffix = '_library';
|
||||
@@ -217,17 +317,15 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
return RepaintBoundary(
|
||||
child: ListTile(
|
||||
key: key,
|
||||
leading: Hero(
|
||||
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
|
||||
child: CircleAvatar(
|
||||
leading: ArtistAvatar(
|
||||
artist: artist,
|
||||
radius: 24,
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
backgroundImage: imageUrl != null ? CachedNetworkImageProvider(imageUrl) : null,
|
||||
child: imageUrl == null
|
||||
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
imageSize: 128,
|
||||
heroTag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
|
||||
onImageLoaded: (imageUrl) {
|
||||
// Store for adaptive colors on tap
|
||||
},
|
||||
),
|
||||
),
|
||||
title: Hero(
|
||||
tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix,
|
||||
child: Material(
|
||||
@@ -244,8 +342,6 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
// Update adaptive colors immediately on tap
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
@@ -266,14 +362,26 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
return Selector<MusicAssistantProvider, (List<Album>, bool)>(
|
||||
selector: (_, provider) => (provider.albums, provider.isLoading),
|
||||
builder: (context, data, _) {
|
||||
final (albums, isLoading) = data;
|
||||
final (allAlbums, isLoading) = data;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (isLoading) {
|
||||
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
|
||||
}
|
||||
|
||||
// Filter by favorites if enabled
|
||||
final albums = _showFavoritesOnly
|
||||
? allAlbums.where((a) => a.favorite == true).toList()
|
||||
: allAlbums;
|
||||
|
||||
if (albums.isEmpty) {
|
||||
if (_showFavoritesOnly) {
|
||||
return EmptyState.custom(
|
||||
icon: Icons.favorite_border,
|
||||
title: 'No favorite albums',
|
||||
subtitle: 'Tap the heart on an album to add it to favorites',
|
||||
);
|
||||
}
|
||||
return EmptyState.albums(
|
||||
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||
);
|
||||
@@ -284,7 +392,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
backgroundColor: colorScheme.surface,
|
||||
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||
child: GridView.builder(
|
||||
key: const PageStorageKey<String>('library_albums_grid'),
|
||||
key: PageStorageKey<String>('library_albums_grid_${_showFavoritesOnly ? 'fav' : 'all'}'),
|
||||
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
||||
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
@@ -317,15 +425,22 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
}
|
||||
|
||||
if (_playlists.isEmpty) {
|
||||
return EmptyState.playlists(onRefresh: _loadPlaylists);
|
||||
if (_showFavoritesOnly) {
|
||||
return EmptyState.custom(
|
||||
icon: Icons.favorite_border,
|
||||
title: 'No favorite playlists',
|
||||
subtitle: 'Tap the heart on a playlist to add it to favorites',
|
||||
);
|
||||
}
|
||||
return EmptyState.playlists(onRefresh: () => _loadPlaylists());
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surface,
|
||||
onRefresh: _loadPlaylists,
|
||||
onRefresh: () => _loadPlaylists(favoriteOnly: _showFavoritesOnly ? true : null),
|
||||
child: ListView.builder(
|
||||
key: const PageStorageKey<String>('library_playlists_list'),
|
||||
key: PageStorageKey<String>('library_playlists_list_${_showFavoritesOnly ? 'fav' : 'all'}'),
|
||||
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
||||
itemCount: _playlists.length,
|
||||
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
|
||||
@@ -393,4 +508,116 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ TRACKS TAB (favorites only) ============
|
||||
Widget _buildTracksTab(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (_isLoadingTracks) {
|
||||
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
|
||||
}
|
||||
|
||||
if (_favoriteTracks.isEmpty) {
|
||||
return EmptyState.custom(
|
||||
icon: Icons.favorite_border,
|
||||
title: 'No favorite tracks',
|
||||
subtitle: 'Long-press a track and tap the heart to add it to favorites',
|
||||
);
|
||||
}
|
||||
|
||||
// Sort tracks by artist name, then track name
|
||||
final sortedTracks = List<Track>.from(_favoriteTracks)
|
||||
..sort((a, b) {
|
||||
final artistCompare = a.artistsString.compareTo(b.artistsString);
|
||||
if (artistCompare != 0) return artistCompare;
|
||||
return a.name.compareTo(b.name);
|
||||
});
|
||||
|
||||
return RefreshIndicator(
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surface,
|
||||
onRefresh: _loadFavoriteTracks,
|
||||
child: ListView.builder(
|
||||
key: const PageStorageKey<String>('library_tracks_list'),
|
||||
cacheExtent: 500,
|
||||
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
|
||||
itemCount: sortedTracks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final track = sortedTracks[index];
|
||||
return _buildTrackTile(context, track);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackTile(BuildContext context, Track track) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
// Get image URL from track itself
|
||||
final imageUrl = maProvider.api?.getImageUrl(track, size: 128);
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
image: CachedNetworkImageProvider(imageUrl),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: imageUrl == null
|
||||
? Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
track.artistsString,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
track.name,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.favorite,
|
||||
color: Colors.red,
|
||||
size: 20,
|
||||
),
|
||||
onTap: () async {
|
||||
final player = maProvider.selectedPlayer;
|
||||
if (player == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No player selected')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Start radio from this track
|
||||
await maProvider.api?.playRadio(player.playerId, track);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to start radio: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
|
||||
final _playlistLogger = DebugLogger();
|
||||
|
||||
class PlaylistDetailsScreen extends StatefulWidget {
|
||||
final Playlist playlist;
|
||||
@@ -61,11 +64,11 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
print('🎵 Queueing playlist on ${player.name}: ${player.playerId}');
|
||||
_playlistLogger.info('Queueing playlist on ${player.name}', context: 'Playlist');
|
||||
|
||||
// Queue all tracks via Music Assistant
|
||||
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0);
|
||||
print('✓ Playlist queued on ${player.name}');
|
||||
_playlistLogger.info('Playlist queued successfully', context: 'Playlist');
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -76,7 +79,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error playing playlist: $e');
|
||||
_playlistLogger.error('Error playing playlist', context: 'Playlist', error: e);
|
||||
_showError('Error playing playlist: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../services/debug_logger.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/common/empty_state.dart';
|
||||
import '../widgets/common/disconnected_state.dart';
|
||||
import '../widgets/artist_avatar.dart';
|
||||
import 'album_details_screen.dart';
|
||||
import 'artist_details_screen.dart';
|
||||
|
||||
@@ -99,11 +100,11 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
void _onSearchChanged(String query) {
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = Timer(Timings.searchDebounce, () {
|
||||
_performSearch(query);
|
||||
_performSearch(query, keepFocus: true);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
Future<void> _performSearch(String query, {bool keepFocus = false}) async {
|
||||
if (query.isEmpty) {
|
||||
setState(() {
|
||||
_searchResults = {'artists': [], 'albums': [], 'tracks': []};
|
||||
@@ -128,6 +129,10 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
_isSearching = false;
|
||||
_hasSearched = true;
|
||||
});
|
||||
// Keep keyboard open if user is still typing
|
||||
if (keepFocus && _focusNode.hasFocus) {
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Search error: $e');
|
||||
@@ -137,6 +142,9 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
_hasSearched = true;
|
||||
_searchError = 'Search failed. Please check your connection.';
|
||||
});
|
||||
if (keepFocus && _focusNode.hasFocus) {
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,6 +164,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
focusNode: _focusNode,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
cursorColor: colorScheme.primary,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search music...',
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
@@ -165,15 +174,20 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_performSearch('');
|
||||
setState(() {
|
||||
_searchResults = {'artists': [], 'albums': [], 'tracks': []};
|
||||
_hasSearched = false;
|
||||
_searchError = null;
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {}); // Update clear button visibility
|
||||
_onSearchChanged(value);
|
||||
},
|
||||
onSubmitted: _performSearch,
|
||||
onSubmitted: (query) => _performSearch(query),
|
||||
),
|
||||
),
|
||||
body: !maProvider.isConnected
|
||||
@@ -389,18 +403,13 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
}
|
||||
|
||||
Widget _buildArtistTile(Artist artist) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.getImageUrl(artist, size: 128);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
leading: ArtistAvatar(
|
||||
artist: artist,
|
||||
radius: 24,
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
backgroundImage: imageUrl != null ? CachedNetworkImageProvider(imageUrl) : null,
|
||||
child: imageUrl == null
|
||||
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
imageSize: 128,
|
||||
),
|
||||
title: Text(
|
||||
artist.name,
|
||||
|
||||
@@ -317,7 +317,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Add API keys to fetch artist biographies and album descriptions when Music Assistant doesn\'t have them',
|
||||
'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.',
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.6),
|
||||
),
|
||||
@@ -365,7 +365,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
controller: _audioDbApiKeyController,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'TheAudioDB API Key (Premium)',
|
||||
labelText: 'TheAudioDB API Key',
|
||||
hintText: 'Use "2" for free tier or premium key',
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||
filled: true,
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import 'dart:collection';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'debug_logger.dart';
|
||||
|
||||
/// Comprehensive animation debugging utility for diagnosing jank and performance issues.
|
||||
///
|
||||
/// Usage:
|
||||
/// 1. Call AnimationDebugger.startSession('playerExpand') at animation start
|
||||
/// 2. Call AnimationDebugger.recordFrame() in animation listener (every frame)
|
||||
/// 3. Call AnimationDebugger.endSession() when animation completes
|
||||
/// 4. Logs will show frame timing, jank detection, and summary stats
|
||||
class AnimationDebugger {
|
||||
static final _logger = DebugLogger();
|
||||
static bool _enabled = true;
|
||||
|
||||
// Session tracking
|
||||
static String? _currentSession;
|
||||
static int _frameCount = 0;
|
||||
static final List<_FrameData> _frames = [];
|
||||
static DateTime? _sessionStart;
|
||||
static double? _lastAnimationValue;
|
||||
|
||||
// Scheduler binding for frame timing
|
||||
static Duration? _lastFrameTime;
|
||||
|
||||
/// Enable or disable animation debugging
|
||||
static void setEnabled(bool enabled) {
|
||||
_enabled = enabled;
|
||||
}
|
||||
|
||||
/// Start a new animation debugging session
|
||||
static void startSession(String name) {
|
||||
if (!_enabled) return;
|
||||
|
||||
_currentSession = name;
|
||||
_frameCount = 0;
|
||||
_frames.clear();
|
||||
_sessionStart = DateTime.now();
|
||||
_lastFrameTime = null;
|
||||
_lastAnimationValue = null;
|
||||
|
||||
// Listen to frame timing
|
||||
SchedulerBinding.instance.addPostFrameCallback(_onFirstFrame);
|
||||
|
||||
_logger.log('🎬 ANIM[$name] START');
|
||||
}
|
||||
|
||||
static void _onFirstFrame(Duration timestamp) {
|
||||
_lastFrameTime = timestamp;
|
||||
}
|
||||
|
||||
/// Record a frame during animation
|
||||
/// [animationValue] - current animation progress (0.0 to 1.0)
|
||||
/// [context] - optional context string for this frame
|
||||
static void recordFrame(double animationValue, {String? context}) {
|
||||
if (!_enabled || _currentSession == null) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
final frameTime = SchedulerBinding.instance.currentFrameTimeStamp;
|
||||
|
||||
Duration? frameDuration;
|
||||
if (_lastFrameTime != null) {
|
||||
frameDuration = frameTime - _lastFrameTime!;
|
||||
}
|
||||
_lastFrameTime = frameTime;
|
||||
|
||||
// Calculate animation delta
|
||||
double? animationDelta;
|
||||
if (_lastAnimationValue != null) {
|
||||
animationDelta = (animationValue - _lastAnimationValue!).abs();
|
||||
}
|
||||
_lastAnimationValue = animationValue;
|
||||
|
||||
final frame = _FrameData(
|
||||
frameNumber: _frameCount,
|
||||
timestamp: now,
|
||||
frameDuration: frameDuration,
|
||||
animationValue: animationValue,
|
||||
animationDelta: animationDelta,
|
||||
context: context,
|
||||
);
|
||||
|
||||
_frames.add(frame);
|
||||
_frameCount++;
|
||||
|
||||
// Log jank immediately (frames > 16.67ms for 60fps)
|
||||
if (frameDuration != null && frameDuration.inMicroseconds > 16667) {
|
||||
final ms = frameDuration.inMicroseconds / 1000;
|
||||
_logger.log('🔴 ANIM[$_currentSession] JANK frame $_frameCount: ${ms.toStringAsFixed(2)}ms (>${(ms/16.67).toStringAsFixed(1)}x target)');
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a build/layout event during animation
|
||||
static void recordBuild(String widgetName) {
|
||||
if (!_enabled || _currentSession == null) return;
|
||||
_logger.log('🔨 ANIM[$_currentSession] BUILD: $widgetName at frame $_frameCount');
|
||||
}
|
||||
|
||||
/// End the current session and log summary
|
||||
static void endSession() {
|
||||
if (!_enabled || _currentSession == null) return;
|
||||
|
||||
final sessionName = _currentSession!;
|
||||
final totalDuration = DateTime.now().difference(_sessionStart!);
|
||||
|
||||
// Calculate statistics
|
||||
int jankFrames = 0;
|
||||
int severeJankFrames = 0;
|
||||
double maxFrameMs = 0;
|
||||
double totalFrameMs = 0;
|
||||
int measuredFrames = 0;
|
||||
|
||||
for (final frame in _frames) {
|
||||
if (frame.frameDuration != null) {
|
||||
final ms = frame.frameDuration!.inMicroseconds / 1000;
|
||||
totalFrameMs += ms;
|
||||
measuredFrames++;
|
||||
|
||||
if (ms > maxFrameMs) maxFrameMs = ms;
|
||||
if (ms > 16.67) jankFrames++;
|
||||
if (ms > 33.33) severeJankFrames++; // Dropped more than 1 frame
|
||||
}
|
||||
}
|
||||
|
||||
final avgFrameMs = measuredFrames > 0 ? totalFrameMs / measuredFrames : 0;
|
||||
final effectiveFps = avgFrameMs > 0 ? 1000 / avgFrameMs : 0;
|
||||
|
||||
// Build summary
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('');
|
||||
buffer.writeln('═══════════════════════════════════════');
|
||||
buffer.writeln('🎬 ANIMATION SUMMARY: $sessionName');
|
||||
buffer.writeln('═══════════════════════════════════════');
|
||||
buffer.writeln('Duration: ${totalDuration.inMilliseconds}ms');
|
||||
buffer.writeln('Total frames: $_frameCount');
|
||||
buffer.writeln('Measured frames: $measuredFrames');
|
||||
buffer.writeln('Average frame: ${avgFrameMs.toStringAsFixed(2)}ms');
|
||||
buffer.writeln('Max frame: ${maxFrameMs.toStringAsFixed(2)}ms');
|
||||
buffer.writeln('Effective FPS: ${effectiveFps.toStringAsFixed(1)}');
|
||||
buffer.writeln('Jank frames (>16.67ms): $jankFrames (${measuredFrames > 0 ? (jankFrames * 100 / measuredFrames).toStringAsFixed(1) : 0}%)');
|
||||
buffer.writeln('Severe jank (>33.33ms): $severeJankFrames');
|
||||
|
||||
// List worst frames
|
||||
if (_frames.isNotEmpty) {
|
||||
final sortedFrames = List<_FrameData>.from(_frames)
|
||||
..sort((a, b) {
|
||||
final aMs = a.frameDuration?.inMicroseconds ?? 0;
|
||||
final bMs = b.frameDuration?.inMicroseconds ?? 0;
|
||||
return bMs.compareTo(aMs);
|
||||
});
|
||||
|
||||
final worstFrames = sortedFrames.take(5).where((f) => f.frameDuration != null && f.frameDuration!.inMicroseconds > 16667).toList();
|
||||
|
||||
if (worstFrames.isNotEmpty) {
|
||||
buffer.writeln('');
|
||||
buffer.writeln('Worst frames:');
|
||||
for (final frame in worstFrames) {
|
||||
final ms = frame.frameDuration!.inMicroseconds / 1000;
|
||||
buffer.writeln(' Frame ${frame.frameNumber}: ${ms.toStringAsFixed(2)}ms (anim=${frame.animationValue.toStringAsFixed(3)})');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buffer.writeln('═══════════════════════════════════════');
|
||||
|
||||
_logger.log(buffer.toString());
|
||||
|
||||
// Reset
|
||||
_currentSession = null;
|
||||
_frames.clear();
|
||||
_sessionStart = null;
|
||||
_lastAnimationValue = null;
|
||||
}
|
||||
|
||||
/// Log a one-off animation event (for Hero transitions, page transitions, etc.)
|
||||
static void logEvent(String event, {String? details}) {
|
||||
if (!_enabled) return;
|
||||
final detailStr = details != null ? ' - $details' : '';
|
||||
_logger.log('🎬 ANIM_EVENT: $event$detailStr');
|
||||
}
|
||||
|
||||
/// Log frame callback timing (call this from addPostFrameCallback)
|
||||
static void logPostFrame(String context) {
|
||||
if (!_enabled) return;
|
||||
final frameTime = SchedulerBinding.instance.currentFrameTimeStamp;
|
||||
_logger.log('📐 POST_FRAME[$context]: ${frameTime.inMilliseconds}ms');
|
||||
}
|
||||
|
||||
/// Create a HeroFlightShuttleBuilder that logs Hero animation progress
|
||||
static HeroFlightShuttleBuilder createHeroDebugShuttle(String heroTag) {
|
||||
return (
|
||||
BuildContext flightContext,
|
||||
Animation<double> animation,
|
||||
HeroFlightDirection flightDirection,
|
||||
BuildContext fromHeroContext,
|
||||
BuildContext toHeroContext,
|
||||
) {
|
||||
// Log Hero flight start
|
||||
if (animation.value < 0.1) {
|
||||
logEvent('Hero flight START', details: 'tag=$heroTag, direction=${flightDirection.name}');
|
||||
}
|
||||
|
||||
// Add listener for completion (one-shot)
|
||||
void onComplete(AnimationStatus status) {
|
||||
if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) {
|
||||
logEvent('Hero flight END', details: 'tag=$heroTag, status=${status.name}');
|
||||
animation.removeStatusListener(onComplete);
|
||||
}
|
||||
}
|
||||
animation.addStatusListener(onComplete);
|
||||
|
||||
// Return default shuttle (the toHero's child)
|
||||
final Hero toHero = toHeroContext.widget as Hero;
|
||||
return toHero.child;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _FrameData {
|
||||
final int frameNumber;
|
||||
final DateTime timestamp;
|
||||
final Duration? frameDuration;
|
||||
final double animationValue;
|
||||
final double? animationDelta;
|
||||
final String? context;
|
||||
|
||||
_FrameData({
|
||||
required this.frameNumber,
|
||||
required this.timestamp,
|
||||
this.frameDuration,
|
||||
required this.animationValue,
|
||||
this.animationDelta,
|
||||
this.context,
|
||||
});
|
||||
}
|
||||
+165
-12
@@ -1,32 +1,185 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
|
||||
/// Log levels for filtering and display
|
||||
enum LogLevel {
|
||||
debug, // Verbose debugging info (hidden by default)
|
||||
info, // General information
|
||||
warning, // Potential issues
|
||||
error, // Errors and failures
|
||||
}
|
||||
|
||||
class LogEntry {
|
||||
final DateTime timestamp;
|
||||
final LogLevel level;
|
||||
final String message;
|
||||
final String? context;
|
||||
|
||||
LogEntry({
|
||||
required this.timestamp,
|
||||
required this.level,
|
||||
required this.message,
|
||||
this.context,
|
||||
});
|
||||
|
||||
String get formatted {
|
||||
final time = timestamp.toIso8601String().substring(11, 23);
|
||||
final levelStr = level.name.toUpperCase().padRight(5);
|
||||
final contextStr = context != null ? '[$context] ' : '';
|
||||
return '[$time] $levelStr $contextStr$message';
|
||||
}
|
||||
}
|
||||
|
||||
class DebugLogger {
|
||||
static final DebugLogger _instance = DebugLogger._internal();
|
||||
factory DebugLogger() => _instance;
|
||||
DebugLogger._internal();
|
||||
|
||||
final List<String> _logs = [];
|
||||
final _maxLogs = 500;
|
||||
final List<LogEntry> _entries = [];
|
||||
final _maxLogs = 1000;
|
||||
|
||||
List<String> get logs => List.unmodifiable(_logs);
|
||||
// Minimum level to display in debug console (debug builds show all)
|
||||
LogLevel _minConsoleLevel = kDebugMode ? LogLevel.debug : LogLevel.info;
|
||||
|
||||
void log(String message) {
|
||||
final timestamp = DateTime.now().toIso8601String().substring(11, 23);
|
||||
final logMessage = '[$timestamp] $message';
|
||||
List<LogEntry> get entries => List.unmodifiable(_entries);
|
||||
|
||||
debugPrint(logMessage);
|
||||
/// Legacy getter for backward compatibility
|
||||
List<String> get logs => _entries.map((e) => e.formatted).toList();
|
||||
|
||||
_logs.add(logMessage);
|
||||
if (_logs.length > _maxLogs) {
|
||||
_logs.removeAt(0);
|
||||
void _addEntry(LogLevel level, String message, {String? context}) {
|
||||
final entry = LogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
level: level,
|
||||
message: message,
|
||||
context: context,
|
||||
);
|
||||
|
||||
// Always store in memory
|
||||
_entries.add(entry);
|
||||
if (_entries.length > _maxLogs) {
|
||||
_entries.removeAt(0);
|
||||
}
|
||||
|
||||
// Only print to console if above minimum level
|
||||
if (level.index >= _minConsoleLevel.index) {
|
||||
debugPrint(entry.formatted);
|
||||
}
|
||||
}
|
||||
|
||||
/// Debug level - verbose info for development
|
||||
void debug(String message, {String? context}) {
|
||||
_addEntry(LogLevel.debug, message, context: context);
|
||||
}
|
||||
|
||||
/// Info level - general operational info
|
||||
void info(String message, {String? context}) {
|
||||
_addEntry(LogLevel.info, message, context: context);
|
||||
}
|
||||
|
||||
/// Warning level - potential issues
|
||||
void warning(String message, {String? context}) {
|
||||
_addEntry(LogLevel.warning, message, context: context);
|
||||
}
|
||||
|
||||
/// Error level - failures and errors
|
||||
void error(String message, {String? context, Object? error, StackTrace? stackTrace}) {
|
||||
var fullMessage = message;
|
||||
if (error != null) {
|
||||
fullMessage += ': $error';
|
||||
}
|
||||
_addEntry(LogLevel.error, fullMessage, context: context);
|
||||
if (stackTrace != null && kDebugMode) {
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy method for backward compatibility
|
||||
void log(String message) {
|
||||
_addEntry(LogLevel.info, message);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_logs.clear();
|
||||
_entries.clear();
|
||||
}
|
||||
|
||||
String getAllLogs() {
|
||||
return _logs.join('\n');
|
||||
return _entries.map((e) => e.formatted).join('\n');
|
||||
}
|
||||
|
||||
/// Get logs filtered by minimum level
|
||||
String getLogsFiltered({LogLevel minLevel = LogLevel.info}) {
|
||||
return _entries
|
||||
.where((e) => e.level.index >= minLevel.index)
|
||||
.map((e) => e.formatted)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/// Generate a formatted bug report with device info
|
||||
Future<String> generateBugReport() async {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
buffer.writeln('═══════════════════════════════════════');
|
||||
buffer.writeln('ENSEMBLE BUG REPORT');
|
||||
buffer.writeln('Generated: ${DateTime.now().toIso8601String()}');
|
||||
buffer.writeln('═══════════════════════════════════════');
|
||||
buffer.writeln();
|
||||
|
||||
// App info
|
||||
try {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
buffer.writeln('APP INFO:');
|
||||
buffer.writeln(' Version: ${packageInfo.version} (${packageInfo.buildNumber})');
|
||||
buffer.writeln(' Package: ${packageInfo.packageName}');
|
||||
buffer.writeln();
|
||||
} catch (e) {
|
||||
buffer.writeln('APP INFO: Unable to retrieve');
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
// Device info
|
||||
try {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
if (Platform.isAndroid) {
|
||||
final android = await deviceInfo.androidInfo;
|
||||
buffer.writeln('DEVICE INFO:');
|
||||
buffer.writeln(' Model: ${android.manufacturer} ${android.model}');
|
||||
buffer.writeln(' Android: ${android.version.release} (SDK ${android.version.sdkInt})');
|
||||
buffer.writeln(' Device: ${android.device}');
|
||||
buffer.writeln();
|
||||
} else if (Platform.isIOS) {
|
||||
final ios = await deviceInfo.iosInfo;
|
||||
buffer.writeln('DEVICE INFO:');
|
||||
buffer.writeln(' Model: ${ios.model}');
|
||||
buffer.writeln(' iOS: ${ios.systemVersion}');
|
||||
buffer.writeln(' Name: ${ios.name}');
|
||||
buffer.writeln();
|
||||
}
|
||||
} catch (e) {
|
||||
buffer.writeln('DEVICE INFO: Unable to retrieve');
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
// Log statistics
|
||||
final errorCount = _entries.where((e) => e.level == LogLevel.error).length;
|
||||
final warningCount = _entries.where((e) => e.level == LogLevel.warning).length;
|
||||
buffer.writeln('LOG SUMMARY:');
|
||||
buffer.writeln(' Total entries: ${_entries.length}');
|
||||
buffer.writeln(' Errors: $errorCount');
|
||||
buffer.writeln(' Warnings: $warningCount');
|
||||
buffer.writeln();
|
||||
|
||||
buffer.writeln('═══════════════════════════════════════');
|
||||
buffer.writeln('LOGS (newest first):');
|
||||
buffer.writeln('═══════════════════════════════════════');
|
||||
buffer.writeln();
|
||||
|
||||
// Add logs in reverse order (newest first) for easier reading
|
||||
for (final entry in _entries.reversed) {
|
||||
buffer.writeln(entry.formatted);
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'debug_logger.dart';
|
||||
import 'settings_service.dart';
|
||||
|
||||
class MetadataService {
|
||||
static final _logger = DebugLogger();
|
||||
// Cache to avoid repeated API calls for the same artist/album
|
||||
static final Map<String, String> _cache = {};
|
||||
|
||||
// Cache for artist images
|
||||
static final Map<String, String?> _artistImageCache = {};
|
||||
|
||||
/// Fetches artist biography/description with fallback chain:
|
||||
/// 1. Music Assistant metadata (passed in)
|
||||
/// 2. Last.fm API (if key configured)
|
||||
@@ -149,7 +154,7 @@ class MetadataService {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('⚠️ Last.fm API error: $e');
|
||||
_logger.warning('Last.fm API error: $e', context: 'Metadata');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -182,7 +187,7 @@ class MetadataService {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('⚠️ TheAudioDB API error: $e');
|
||||
_logger.warning('TheAudioDB API error: $e', context: 'Metadata');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -204,8 +209,51 @@ class MetadataService {
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
/// Fetches artist image URL from Deezer (free, no API key required)
|
||||
/// Returns the image URL if found, null otherwise
|
||||
static Future<String?> getArtistImageUrl(String artistName) async {
|
||||
// Check cache first
|
||||
final cacheKey = 'artistImage:$artistName';
|
||||
if (_artistImageCache.containsKey(cacheKey)) {
|
||||
return _artistImageCache[cacheKey];
|
||||
}
|
||||
|
||||
// Use Deezer API (free, no key, excellent coverage)
|
||||
try {
|
||||
final uri = Uri.https(
|
||||
'api.deezer.com',
|
||||
'/search/artist',
|
||||
{
|
||||
'q': artistName,
|
||||
'limit': '1',
|
||||
},
|
||||
);
|
||||
|
||||
final response = await http.get(uri).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
final artists = data['data'] as List?;
|
||||
if (artists != null && artists.isNotEmpty) {
|
||||
final artist = artists[0];
|
||||
// Use picture_xl for high quality, fall back to picture_medium
|
||||
final imageUrl = artist['picture_xl'] ?? artist['picture_medium'] ?? artist['picture'];
|
||||
_artistImageCache[cacheKey] = imageUrl;
|
||||
return imageUrl;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning('Deezer artist image error: $e', context: 'Metadata');
|
||||
}
|
||||
|
||||
// Cache the null result to avoid repeated failed lookups
|
||||
_artistImageCache[cacheKey] = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Clears the metadata cache
|
||||
static void clearCache() {
|
||||
_cache.clear();
|
||||
_artistImageCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,15 +63,24 @@ class MusicAssistantAPI {
|
||||
|
||||
// Guard to prevent multiple simultaneous connection attempts
|
||||
Completer<void>? _connectionInProgress;
|
||||
bool _isDisposed = false;
|
||||
|
||||
Future<void> connect() async {
|
||||
// If already connected, nothing to do
|
||||
if (_currentState == MAConnectionState.connected) {
|
||||
// If disposed, don't try to connect
|
||||
if (_isDisposed) {
|
||||
_logger.log('Connection: API disposed, skipping connect');
|
||||
return;
|
||||
}
|
||||
|
||||
// If already connected or authenticated, nothing to do
|
||||
if (_currentState == MAConnectionState.connected ||
|
||||
_currentState == MAConnectionState.authenticated) {
|
||||
_logger.log('Connection: Already connected, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// If connection is in progress, wait for it instead of starting another
|
||||
if (_connectionInProgress != null) {
|
||||
if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) {
|
||||
_logger.log('Connection already in progress, waiting...');
|
||||
return _connectionInProgress!.future;
|
||||
}
|
||||
@@ -611,13 +620,14 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Album?> getAlbumDetails(String provider, String itemId) async {
|
||||
/// Get album details by URI
|
||||
/// Use the album's uri field (e.g., "library://album/123" or "spotify://album/xyz")
|
||||
Future<Album?> getAlbumByUri(String uri) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/album',
|
||||
'music/item_by_uri',
|
||||
args: {
|
||||
'provider': provider,
|
||||
'item_id': itemId,
|
||||
'uri': uri,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -626,7 +636,27 @@ class MusicAssistantAPI {
|
||||
|
||||
return Album.fromJson(result as Map<String, dynamic>);
|
||||
} catch (e) {
|
||||
_logger.log('Error getting album details: $e');
|
||||
_logger.log('Error getting album by URI: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get artist details by URI
|
||||
Future<Artist?> getArtistByUri(String uri) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/item_by_uri',
|
||||
args: {
|
||||
'uri': uri,
|
||||
},
|
||||
);
|
||||
|
||||
final result = response['result'];
|
||||
if (result == null) return null;
|
||||
|
||||
return Artist.fromJson(result as Map<String, dynamic>);
|
||||
} catch (e) {
|
||||
_logger.log('Error getting artist by URI: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -749,35 +779,18 @@ class MusicAssistantAPI {
|
||||
}
|
||||
|
||||
// Favorites
|
||||
/// Toggle favorite status for any media item
|
||||
Future<bool> toggleFavorite(String mediaType, String itemId, String provider) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/favorites/toggle',
|
||||
args: {
|
||||
'media_type': mediaType,
|
||||
'item_id': itemId,
|
||||
'provider': provider,
|
||||
},
|
||||
);
|
||||
|
||||
final isFavorite = response['result'] as bool? ?? false;
|
||||
return isFavorite;
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling favorite: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark item as favorite
|
||||
/// Mark item as favorite using URI format
|
||||
/// The item parameter should be a URI like "spotify://album/6XyFhbFtcRRR9peJ16em4h"
|
||||
Future<void> addToFavorites(String mediaType, String itemId, String provider) async {
|
||||
try {
|
||||
// Build the URI for the item
|
||||
final uri = '$provider://$mediaType/$itemId';
|
||||
_logger.log('Adding to favorites: $uri');
|
||||
|
||||
await _sendCommand(
|
||||
'music/favorites/add',
|
||||
'music/favorites/add_item',
|
||||
args: {
|
||||
'media_type': mediaType,
|
||||
'item_id': itemId,
|
||||
'provider': provider,
|
||||
'item': uri,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -787,14 +800,16 @@ class MusicAssistantAPI {
|
||||
}
|
||||
|
||||
/// Remove item from favorites
|
||||
Future<void> removeFromFavorites(String mediaType, String itemId, String provider) async {
|
||||
/// Requires the library_item_id (the numeric ID in the MA library)
|
||||
Future<void> removeFromFavorites(String mediaType, int libraryItemId) async {
|
||||
try {
|
||||
_logger.log('Removing from favorites: mediaType=$mediaType, libraryItemId=$libraryItemId');
|
||||
|
||||
await _sendCommand(
|
||||
'music/favorites/remove',
|
||||
'music/favorites/remove_item',
|
||||
args: {
|
||||
'media_type': mediaType,
|
||||
'item_id': itemId,
|
||||
'provider': provider,
|
||||
'library_item_id': libraryItemId,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -1060,12 +1075,14 @@ class MusicAssistantAPI {
|
||||
}
|
||||
|
||||
/// Play radio based on a track (generates similar tracks)
|
||||
/// Prefers streaming provider URIs (Spotify, Tidal, etc.) for better recommendations
|
||||
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);
|
||||
// For radio mode, prefer streaming providers (Spotify, Tidal, etc.)
|
||||
// as they provide better dynamic track recommendations
|
||||
final trackUri = _buildRadioUri(track);
|
||||
_logger.log('Radio: Starting with URI $trackUri');
|
||||
|
||||
final args = {
|
||||
'queue_id': playerId,
|
||||
@@ -1074,7 +1091,6 @@ class MusicAssistantAPI {
|
||||
'radio_mode': true, // Enable radio mode for similar tracks
|
||||
};
|
||||
|
||||
|
||||
await _sendCommand(
|
||||
'player_queues/play_media',
|
||||
args: args,
|
||||
@@ -1084,6 +1100,114 @@ class MusicAssistantAPI {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build URI for radio mode, preferring streaming providers for better recommendations
|
||||
/// Priority: Spotify > Tidal > Deezer > Apple > YTM > Subsonic > any available > library
|
||||
/// Note: Qobuz does NOT support radio mode (no dynamic_tracks API)
|
||||
String _buildRadioUri(Track track) {
|
||||
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
|
||||
// Providers that support dynamic radio recommendations (in priority order)
|
||||
// Qobuz is excluded - it doesn't support similar tracks API
|
||||
const radioProviders = ['spotify', 'tidal', 'deezer', 'apple', 'ytmusic', 'subsonic', 'opensubsonic'];
|
||||
|
||||
// Try to find a streaming provider first
|
||||
for (final providerPrefix in radioProviders) {
|
||||
final mapping = track.providerMappings!.firstWhere(
|
||||
(m) => m.available && m.providerInstance.toLowerCase().startsWith(providerPrefix),
|
||||
orElse: () => ProviderMapping(providerInstance: '', providerDomain: '', itemId: '', available: false),
|
||||
);
|
||||
if (mapping.providerInstance.isNotEmpty) {
|
||||
return '${mapping.providerInstance}://track/${mapping.itemId}';
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to any available provider (but not library)
|
||||
final nonLibraryMapping = track.providerMappings!.firstWhere(
|
||||
(m) => m.available && m.providerInstance != 'library',
|
||||
orElse: () => ProviderMapping(providerInstance: '', providerDomain: '', itemId: '', available: false),
|
||||
);
|
||||
if (nonLibraryMapping.providerInstance.isNotEmpty) {
|
||||
return '${nonLibraryMapping.providerInstance}://track/${nonLibraryMapping.itemId}';
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: use library URI or build from top-level provider
|
||||
return track.uri ?? _buildTrackUri(track);
|
||||
}
|
||||
|
||||
/// Play radio based on an artist (generates tracks from and similar to the artist)
|
||||
/// Prefers streaming provider URIs (Spotify, Tidal, etc.) for better recommendations
|
||||
Future<void> playArtistRadio(String playerId, Artist artist) async {
|
||||
return await RetryHelper.retryCritical(
|
||||
operation: () async {
|
||||
final artistUri = _buildArtistRadioUri(artist);
|
||||
_logger.log('Radio: Starting artist radio with URI $artistUri');
|
||||
|
||||
final args = {
|
||||
'queue_id': playerId,
|
||||
'media': [artistUri],
|
||||
'option': 'replace',
|
||||
'radio_mode': true,
|
||||
};
|
||||
|
||||
await _sendCommand(
|
||||
'player_queues/play_media',
|
||||
args: args,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Add artist radio to queue (instead of replacing)
|
||||
Future<void> playArtistRadioToQueue(String playerId, Artist artist) async {
|
||||
return await RetryHelper.retryCritical(
|
||||
operation: () async {
|
||||
final artistUri = _buildArtistRadioUri(artist);
|
||||
_logger.log('Radio: Adding artist radio to queue with URI $artistUri');
|
||||
|
||||
final args = {
|
||||
'queue_id': playerId,
|
||||
'media': [artistUri],
|
||||
'option': 'add', // Add to queue instead of replace
|
||||
'radio_mode': true,
|
||||
};
|
||||
|
||||
await _sendCommand(
|
||||
'player_queues/play_media',
|
||||
args: args,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build URI for artist radio mode, preferring streaming providers
|
||||
/// Note: Qobuz does NOT support radio mode (no dynamic_tracks API)
|
||||
String _buildArtistRadioUri(Artist artist) {
|
||||
if (artist.providerMappings != null && artist.providerMappings!.isNotEmpty) {
|
||||
// Qobuz is excluded - it doesn't support similar tracks API
|
||||
const radioProviders = ['spotify', 'tidal', 'deezer', 'apple', 'ytmusic', 'subsonic', 'opensubsonic'];
|
||||
|
||||
for (final providerPrefix in radioProviders) {
|
||||
final mapping = artist.providerMappings!.firstWhere(
|
||||
(m) => m.available && m.providerInstance.toLowerCase().startsWith(providerPrefix),
|
||||
orElse: () => ProviderMapping(providerInstance: '', providerDomain: '', itemId: '', available: false),
|
||||
);
|
||||
if (mapping.providerInstance.isNotEmpty) {
|
||||
return '${mapping.providerInstance}://artist/${mapping.itemId}';
|
||||
}
|
||||
}
|
||||
|
||||
final nonLibraryMapping = artist.providerMappings!.firstWhere(
|
||||
(m) => m.available && m.providerInstance != 'library',
|
||||
orElse: () => ProviderMapping(providerInstance: '', providerDomain: '', itemId: '', available: false),
|
||||
);
|
||||
if (nonLibraryMapping.providerInstance.isNotEmpty) {
|
||||
return '${nonLibraryMapping.providerInstance}://artist/${nonLibraryMapping.itemId}';
|
||||
}
|
||||
}
|
||||
|
||||
return artist.uri ?? '${artist.provider}://artist/${artist.itemId}';
|
||||
}
|
||||
|
||||
/// Build track URI from provider mappings
|
||||
String _buildTrackUri(Track track) {
|
||||
// Use provider mappings to get the actual provider instance
|
||||
@@ -1990,13 +2114,39 @@ class MusicAssistantAPI {
|
||||
}
|
||||
|
||||
Future<void> _reconnect() async {
|
||||
// Don't reconnect if disposed
|
||||
if (_isDisposed) {
|
||||
_logger.log('Reconnect: API disposed, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't reconnect if already connected or connecting
|
||||
if (_currentState == MAConnectionState.connected ||
|
||||
_currentState == MAConnectionState.authenticated ||
|
||||
_currentState == MAConnectionState.connecting) {
|
||||
_logger.log('Reconnect: Already connected/connecting, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't reconnect if another connection is in progress
|
||||
if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) {
|
||||
_logger.log('Reconnect: Connection already in progress, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
await Future.delayed(Timings.reconnectDelay);
|
||||
if (_currentState != MAConnectionState.connected) {
|
||||
try {
|
||||
await connect();
|
||||
} catch (e) {
|
||||
_logger.log('Reconnection failed: $e');
|
||||
}
|
||||
|
||||
// Check again after delay
|
||||
if (_isDisposed ||
|
||||
_currentState == MAConnectionState.connected ||
|
||||
_currentState == MAConnectionState.authenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await connect();
|
||||
} catch (e) {
|
||||
_logger.log('Reconnection failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2009,7 +2159,13 @@ class MusicAssistantAPI {
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
_stopHeartbeat();
|
||||
// Complete any pending connection to prevent hanging futures
|
||||
if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) {
|
||||
_connectionInProgress!.completeError(Exception('API disposed'));
|
||||
}
|
||||
_connectionInProgress = null;
|
||||
disconnect();
|
||||
_connectionStateController.close();
|
||||
for (final stream in _eventStreams.values) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
|
||||
final _logger = DebugLogger();
|
||||
|
||||
/// Extracted adaptive colors from album art
|
||||
class AdaptiveColors {
|
||||
@@ -40,7 +43,7 @@ class PaletteHelper {
|
||||
);
|
||||
return palette;
|
||||
} catch (e) {
|
||||
print('⚠️ Failed to extract palette: $e');
|
||||
_logger.warning('Failed to extract palette: $e', context: 'Palette');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
|
||||
final _systemThemeLogger = DebugLogger();
|
||||
|
||||
class SystemThemeHelper {
|
||||
/// Attempts to get system color schemes from the device (Android 12+)
|
||||
@@ -35,7 +38,7 @@ class SystemThemeHelper {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('⚠️ Failed to get system colors: $e');
|
||||
_systemThemeLogger.warning('Failed to get system colors: $e', context: 'Theme');
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import 'palette_helper.dart';
|
||||
|
||||
final _themeLogger = DebugLogger();
|
||||
|
||||
/// Global function to update adaptive colors from an image URL
|
||||
/// This can be called from anywhere in the app (e.g., when tapping an album/artist)
|
||||
Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUrl) async {
|
||||
@@ -17,7 +20,7 @@ Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUr
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently fail - colors will update when track plays
|
||||
print('Failed to extract colors on tap: $e');
|
||||
_themeLogger.debug('Failed to extract colors on tap: $e', context: 'Theme');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
|
||||
/// A CircleAvatar that shows artist image with automatic fallback to Deezer/Fanart.tv
|
||||
class ArtistAvatar extends StatefulWidget {
|
||||
final Artist artist;
|
||||
final double radius;
|
||||
final int imageSize;
|
||||
final String? heroTag;
|
||||
final ValueChanged<String?>? onImageLoaded;
|
||||
|
||||
const ArtistAvatar({
|
||||
super.key,
|
||||
required this.artist,
|
||||
this.radius = 24,
|
||||
this.imageSize = 128,
|
||||
this.heroTag,
|
||||
this.onImageLoaded,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ArtistAvatar> createState() => _ArtistAvatarState();
|
||||
}
|
||||
|
||||
class _ArtistAvatarState extends State<ArtistAvatar> {
|
||||
String? _imageUrl;
|
||||
bool _triedFallback = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadImage();
|
||||
}
|
||||
|
||||
Future<void> _loadImage() async {
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// Try MA first
|
||||
final maUrl = provider.getImageUrl(widget.artist, size: widget.imageSize);
|
||||
if (maUrl != null) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_imageUrl = maUrl;
|
||||
});
|
||||
widget.onImageLoaded?.call(maUrl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to external sources
|
||||
if (!_triedFallback) {
|
||||
_triedFallback = true;
|
||||
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
|
||||
if (fallbackUrl != null && mounted) {
|
||||
setState(() {
|
||||
_imageUrl = fallbackUrl;
|
||||
});
|
||||
widget.onImageLoaded?.call(fallbackUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final avatar = CircleAvatar(
|
||||
radius: widget.radius,
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
backgroundImage: _imageUrl != null ? CachedNetworkImageProvider(_imageUrl!) : null,
|
||||
child: _imageUrl == null
|
||||
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
);
|
||||
|
||||
if (widget.heroTag != null) {
|
||||
return Hero(
|
||||
tag: widget.heroTag!,
|
||||
child: avatar,
|
||||
);
|
||||
}
|
||||
|
||||
return avatar;
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import '../screens/artist_details_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
|
||||
class ArtistCard extends StatelessWidget {
|
||||
class ArtistCard extends StatefulWidget {
|
||||
final Artist artist;
|
||||
final VoidCallback? onTap;
|
||||
final String? heroTagSuffix;
|
||||
@@ -19,26 +21,45 @@ class ArtistCard extends StatelessWidget {
|
||||
this.heroTagSuffix,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ArtistCard> createState() => _ArtistCardState();
|
||||
}
|
||||
|
||||
class _ArtistCardState extends State<ArtistCard> {
|
||||
static final _logger = DebugLogger();
|
||||
String? _fallbackImageUrl;
|
||||
bool _triedFallback = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.api?.getImageUrl(artist, size: 256);
|
||||
final maImageUrl = maProvider.api?.getImageUrl(widget.artist, size: 256);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
|
||||
final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||
|
||||
// Use MA image if available, otherwise try fallback
|
||||
final imageUrl = maImageUrl ?? _fallbackImageUrl;
|
||||
|
||||
// Try to fetch fallback image if MA doesn't have one and we haven't tried yet
|
||||
if (maImageUrl == null && !_triedFallback) {
|
||||
_triedFallback = true;
|
||||
_logger.debug('No MA image for "${widget.artist.name}", trying fallback', context: 'ArtistCard');
|
||||
_fetchFallbackImage();
|
||||
}
|
||||
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: onTap ?? () {
|
||||
onTap: widget.onTap ?? () {
|
||||
// Update adaptive colors immediately on tap
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
child: ArtistDetailsScreen(
|
||||
artist: artist,
|
||||
heroTagSuffix: heroTagSuffix,
|
||||
artist: widget.artist,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -48,7 +69,7 @@ class ArtistCard extends StatelessWidget {
|
||||
children: [
|
||||
// Artist image - circular
|
||||
Hero(
|
||||
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
|
||||
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
|
||||
child: ClipOval(
|
||||
child: Container(
|
||||
width: 110,
|
||||
@@ -73,11 +94,11 @@ class ArtistCard extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
// Artist name
|
||||
Hero(
|
||||
tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix,
|
||||
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
artist.name,
|
||||
widget.artist.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
@@ -93,4 +114,14 @@ class ArtistCard extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _fetchFallbackImage() async {
|
||||
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
|
||||
if (fallbackUrl != null && mounted) {
|
||||
_logger.debug('Found fallback image for "${widget.artist.name}"', context: 'ArtistCard');
|
||||
setState(() {
|
||||
_fallbackImageUrl = fallbackUrl;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,20 @@ class EmptyState extends StatelessWidget {
|
||||
message: 'No results found',
|
||||
);
|
||||
|
||||
/// Creates a custom empty state with title and subtitle
|
||||
factory EmptyState.custom({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
String? subtitle,
|
||||
VoidCallback? onRefresh,
|
||||
}) =>
|
||||
_CustomEmptyState(
|
||||
icon: icon,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
onRefresh: onRefresh,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -106,3 +120,71 @@ class EmptyState extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A custom empty state with title and optional subtitle
|
||||
class _CustomEmptyState extends EmptyState {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final VoidCallback? onRefresh;
|
||||
|
||||
const _CustomEmptyState({
|
||||
required super.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.onRefresh,
|
||||
}) : super(message: title);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: Spacing.paddingAll24,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: iconSize,
|
||||
color: colorScheme.onSurface.withOpacity(0.38),
|
||||
),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
Spacing.vGap8,
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.4),
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (onRefresh != null) ...[
|
||||
Spacing.vGap24,
|
||||
ElevatedButton.icon(
|
||||
onPressed: onRefresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('Refresh'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+816
-238
File diff suppressed because it is too large
Load Diff
@@ -149,19 +149,65 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
navSelectedColor = hsl.withLightness((hsl.lightness - 0.3).clamp(0.2, 1.0)).toColor();
|
||||
}
|
||||
|
||||
// Base background: use adaptive surface color if available, otherwise default surface
|
||||
final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null)
|
||||
? themeProvider.adaptiveSurfaceColor!
|
||||
: colorScheme.surface;
|
||||
|
||||
return ValueListenableBuilder<PlayerExpansionState>(
|
||||
valueListenable: playerExpansionNotifier,
|
||||
builder: (context, expansionState, child) {
|
||||
// Base background: use adaptive surface color if available, otherwise default surface
|
||||
final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null)
|
||||
? themeProvider.adaptiveSurfaceColor!
|
||||
: colorScheme.surface;
|
||||
|
||||
// Lerp between base color and player background when expanded
|
||||
// Pass BottomNavigationBar as child to avoid rebuilding it every frame
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: navigationProvider.selectedIndex,
|
||||
onTap: (index) {
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
}
|
||||
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||
if (index == 3) {
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
} else if (navigationProvider.selectedIndex == 3) {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
}
|
||||
navigationProvider.setSelectedIndex(index);
|
||||
},
|
||||
backgroundColor: Colors.transparent,
|
||||
selectedItemColor: navSelectedColor,
|
||||
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
|
||||
elevation: 0,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
selectedFontSize: 12,
|
||||
unselectedFontSize: 12,
|
||||
items: const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
activeIcon: Icon(Icons.home_rounded),
|
||||
label: 'Home',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.library_music_outlined),
|
||||
activeIcon: Icon(Icons.library_music_rounded),
|
||||
label: 'Library',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.search_rounded),
|
||||
activeIcon: Icon(Icons.search_rounded),
|
||||
label: 'Search',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
activeIcon: Icon(Icons.settings_rounded),
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
),
|
||||
builder: (context, expansionState, navBar) {
|
||||
// Only compute colors during animation - this is the hot path
|
||||
final navBgColor = expansionState.progress > 0 && expansionState.backgroundColor != null
|
||||
? Color.lerp(baseBgColor, expansionState.backgroundColor, expansionState.progress)!
|
||||
: baseBgColor;
|
||||
|
||||
// Use AnimatedContainer for smoother transitions instead of rebuilding
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: navBgColor,
|
||||
@@ -175,56 +221,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: navigationProvider.selectedIndex,
|
||||
onTap: (index) {
|
||||
// Collapse player if expanded
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
}
|
||||
|
||||
// Pop any pushed routes (album/artist details) first
|
||||
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||
|
||||
// Hide mini player on Settings screen, show on other screens
|
||||
if (index == 3) {
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
} else if (navigationProvider.selectedIndex == 3) {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
}
|
||||
|
||||
navigationProvider.setSelectedIndex(index);
|
||||
},
|
||||
backgroundColor: Colors.transparent,
|
||||
selectedItemColor: navSelectedColor,
|
||||
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
|
||||
elevation: 0,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
selectedFontSize: 12,
|
||||
unselectedFontSize: 12,
|
||||
items: const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
activeIcon: Icon(Icons.home_rounded),
|
||||
label: 'Home',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.library_music_outlined),
|
||||
activeIcon: Icon(Icons.library_music_rounded),
|
||||
label: 'Library',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.search_rounded),
|
||||
activeIcon: Icon(Icons.search_rounded),
|
||||
label: 'Search',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
activeIcon: Icon(Icons.settings_rounded),
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
),
|
||||
child: navBar, // Reuse pre-built navigation bar
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -234,17 +231,22 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
),
|
||||
),
|
||||
// Global player overlay - slides down when hidden, renders ON TOP of bottom nav
|
||||
AnimatedBuilder(
|
||||
animation: _slideAnimation,
|
||||
builder: (context, child) {
|
||||
return Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, _) {
|
||||
// Only show player if connected and has a selected player
|
||||
// Mini player is now permanent - shows device selector even without a track
|
||||
if (!maProvider.isConnected ||
|
||||
maProvider.selectedPlayer == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
// Use Selector instead of Consumer to avoid rebuilds during animation
|
||||
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer})>(
|
||||
selector: (_, provider) => (
|
||||
isConnected: provider.isConnected,
|
||||
hasPlayer: provider.selectedPlayer != null,
|
||||
),
|
||||
builder: (context, state, child) {
|
||||
// Only show player if connected and has a selected player
|
||||
if (!state.isConnected || !state.hasPlayer) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return AnimatedBuilder(
|
||||
animation: _slideAnimation,
|
||||
child: ExpandablePlayer(key: globalPlayerKey, slideOffset: 0),
|
||||
builder: (context, staticChild) {
|
||||
// Only slideOffset changes during animation, player widget stays same
|
||||
return ExpandablePlayer(
|
||||
key: globalPlayerKey,
|
||||
slideOffset: _slideAnimation.value,
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../theme/design_tokens.dart';
|
||||
/// A compact device selector bar shown when no track is playing
|
||||
class DeviceSelectorBar extends StatelessWidget {
|
||||
final dynamic selectedPlayer;
|
||||
final dynamic peekPlayer;
|
||||
final bool hasMultiplePlayers;
|
||||
final Color backgroundColor;
|
||||
final Color textColor;
|
||||
@@ -11,11 +12,14 @@ class DeviceSelectorBar extends StatelessWidget {
|
||||
final double height;
|
||||
final double borderRadius;
|
||||
final double slideOffset;
|
||||
final GestureDragStartCallback? onHorizontalDragStart;
|
||||
final GestureDragUpdateCallback? onHorizontalDragUpdate;
|
||||
final GestureDragEndCallback? onHorizontalDragEnd;
|
||||
|
||||
const DeviceSelectorBar({
|
||||
super.key,
|
||||
required this.selectedPlayer,
|
||||
this.peekPlayer,
|
||||
required this.hasMultiplePlayers,
|
||||
required this.backgroundColor,
|
||||
required this.textColor,
|
||||
@@ -23,12 +27,16 @@ class DeviceSelectorBar extends StatelessWidget {
|
||||
required this.height,
|
||||
required this.borderRadius,
|
||||
required this.slideOffset,
|
||||
this.onHorizontalDragStart,
|
||||
this.onHorizontalDragUpdate,
|
||||
this.onHorizontalDragEnd,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onHorizontalDragStart: hasMultiplePlayers ? onHorizontalDragStart : null,
|
||||
onHorizontalDragUpdate: hasMultiplePlayers ? onHorizontalDragUpdate : null,
|
||||
onHorizontalDragEnd: hasMultiplePlayers ? onHorizontalDragEnd : null,
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
@@ -40,47 +48,56 @@ class DeviceSelectorBar extends StatelessWidget {
|
||||
width: width,
|
||||
height: height,
|
||||
child: ClipRect(
|
||||
child: Transform.translate(
|
||||
offset: Offset(slideOffset * width, 0),
|
||||
child: Padding(
|
||||
padding: Spacing.paddingH16,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getPlayerIcon(selectedPlayer.name),
|
||||
color: textColor,
|
||||
size: IconSizes.md,
|
||||
),
|
||||
Spacing.hGap12,
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
selectedPlayer.name,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (hasMultiplePlayers)
|
||||
Text(
|
||||
'Swipe to switch device',
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Peek player content (shows when dragging)
|
||||
if (slideOffset.abs() > 0.01 && peekPlayer != null)
|
||||
_buildPeekContent(),
|
||||
|
||||
// Current player content
|
||||
Transform.translate(
|
||||
offset: Offset(slideOffset * width, 0),
|
||||
child: Padding(
|
||||
padding: Spacing.paddingH16,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getPlayerIcon(selectedPlayer.name),
|
||||
color: textColor,
|
||||
size: IconSizes.md,
|
||||
),
|
||||
Spacing.hGap12,
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
selectedPlayer.name,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasMultiplePlayers)
|
||||
Text(
|
||||
'Swipe to switch device',
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -88,6 +105,60 @@ class DeviceSelectorBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build peek player content that slides in from the edge
|
||||
Widget _buildPeekContent() {
|
||||
final isFromRight = slideOffset < 0;
|
||||
final peekProgress = slideOffset.abs();
|
||||
|
||||
// Calculate peek position - slides in as main content slides out
|
||||
final peekBaseOffset = isFromRight
|
||||
? width * (1 - peekProgress)
|
||||
: -width * (1 - peekProgress);
|
||||
|
||||
return Transform.translate(
|
||||
offset: Offset(peekBaseOffset, 0),
|
||||
child: Padding(
|
||||
padding: Spacing.paddingH16,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getPlayerIcon(peekPlayer.name),
|
||||
color: textColor,
|
||||
size: IconSizes.md,
|
||||
),
|
||||
Spacing.hGap12,
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
peekPlayer.name,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (hasMultiplePlayers)
|
||||
Text(
|
||||
'Swipe to switch device',
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get appropriate icon for player based on name
|
||||
IconData _getPlayerIcon(String playerName) {
|
||||
final nameLower = playerName.toLowerCase();
|
||||
|
||||
@@ -109,17 +109,19 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: SliderTheme(
|
||||
data: SliderThemeData(
|
||||
trackHeight: 2,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
|
||||
activeTrackColor: Colors.white,
|
||||
inactiveTrackColor: Colors.white.withOpacity(0.3),
|
||||
thumbColor: Colors.white,
|
||||
overlayColor: Colors.white.withOpacity(0.2),
|
||||
),
|
||||
child: Slider(
|
||||
child: SizedBox(
|
||||
height: 48, // Increase touch target height
|
||||
child: SliderTheme(
|
||||
data: SliderThemeData(
|
||||
trackHeight: 2,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
|
||||
activeTrackColor: Colors.white,
|
||||
inactiveTrackColor: Colors.white.withOpacity(0.3),
|
||||
thumbColor: Colors.white,
|
||||
overlayColor: Colors.white.withOpacity(0.2),
|
||||
),
|
||||
child: Slider(
|
||||
value: currentVolume.clamp(0.0, 1.0),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
@@ -163,6 +165,7 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
|
||||
+4
-1
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 1.6.1+8
|
||||
version: 1.8.0+9
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@@ -28,6 +28,9 @@ dependencies:
|
||||
|
||||
# Utilities
|
||||
uuid: ^4.2.1
|
||||
package_info_plus: ^8.0.0
|
||||
device_info_plus: ^10.1.0
|
||||
share_plus: ^9.0.0
|
||||
|
||||
# Audio - Local Playback
|
||||
just_audio: ^0.9.36
|
||||
|
||||
Reference in New Issue
Block a user