Compare commits

..
Author SHA1 Message Date
Your Name aa48c9e6d7 Bump version to 1.8.0 2025-12-10 21:24:52 +00:00
Your NameandClaude Opus 4.5 c3a197ff93 Merge fix/search-keyboard-behavior: Search UX + logging improvements
- Keep keyboard open while typing and searching
- Improved logging system with levels and bug report sharing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 21:24:26 +00:00
Your NameandClaude Opus 4.5 1edb5a73c9 Improve logging system for user bug reports
Upgraded DebugLogger with:
- Log levels (debug, info, warning, error) for better filtering
- Context parameter for categorizing log sources
- generateBugReport() with device info, app version, and log stats
- Increased log retention to 1000 entries

Updated debug log screen:
- Added share button to generate and share bug reports
- Added level filter chips (All, Info+, Warnings, Errors)
- Color-coded log entries by level
- Shows filtered entry count

Replaced raw print() statements with proper logging:
- metadata_service.dart: API errors as warnings
- artist_card.dart: Fallback image lookups as debug
- palette_helper.dart: Palette extraction errors as warnings
- theme_provider.dart: Color extraction as debug
- system_theme_helper.dart: System color errors as warnings
- playlist_details_screen.dart: Playlist operations as info/error

Added dependencies:
- package_info_plus: App version info for reports
- device_info_plus: Device details for reports
- share_plus: Native share functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:45:32 +00:00
Your NameandClaude Opus 4.5 498ed53106 Fix search: keep keyboard open while typing and searching
- Restored debounce-based search for instant feedback while typing
- Added keepFocus parameter to _performSearch
- Re-request focus after search completes to keep keyboard open
- User can still dismiss keyboard manually or press search to submit

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:22:52 +00:00
Your NameandClaude Opus 4.5 6fcd34752f Fix search: only search on submit, not while typing
Changed from debounce-based search to submit-based:
- Removed auto-search after 500ms debounce timer
- Search only triggers when user presses Enter/Search on keyboard
- Added textInputAction: TextInputAction.search for search button on keyboard
- Keyboard stays up while typing, giving time to complete query
- Especially helpful for swipe typing where debounce was too aggressive

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:20:27 +00:00
Your NameandClaude Opus 4.5 710c772f1a Merge fix/back-navigation-clean: Back nav fix + animation optimizations
Includes:
- Fix back navigation for expanded player and detail screens
- Animation debugging utilities
- CPU optimizations for 60fps (TextPainter caching, Selector usage, deferred color extraction)
- GPU optimizations (replace Opacity with FadeTransition/color alpha, fixed BoxShadow)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:15:19 +00:00
Your NameandClaude Opus 4.5 4bfe450e8b GPU optimization: Replace Opacity widgets with efficient alternatives
Eliminates GPU saveLayer operations that cause animation judder:

1. Album art, track title, artist name:
   - Replace Opacity wrapper with conditional rendering (if statement)
   - Elements simply don't render when hidden instead of being rendered at 0 opacity

2. BoxShadow on album art:
   - Fixed blurRadius (20) and offset (0, 8) instead of animated values
   - Only shadow color opacity animates - GPU doesn't recalculate blur kernel

3. Progress bar, volume control:
   - Replace Opacity with FadeTransition (uses GPU-efficient compositing)
   - FadeTransition leverages hardware compositor without saveLayer

4. Album name, player name:
   - Use text color alpha instead of Opacity wrapper
   - Text renders directly at target alpha, no extra layer

5. Collapse, favorite, queue, shuffle, repeat buttons:
   - Use icon/button color alpha instead of Opacity wrapper
   - Removes 6+ Opacity widgets from the render tree

Total: ~12 Opacity widgets removed, replaced with GPU-efficient alternatives

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:03:13 +00:00
Your NameandClaude Opus 4.5 d4b910fae0 Optimize animations for 60fps performance
Critical fixes for player expand animation jank (was 25% jank, 52 FPS):

1. TextPainter layout caching (expandable_player.dart)
   - Cache title height calculation instead of running TextPainter.layout()
     every animation frame
   - Only recalculate when track name or width changes

2. Bottom nav rebuild optimization (global_player_overlay.dart)
   - Pass BottomNavigationBar as child to ValueListenableBuilder
   - Only Container decoration updates during animation, nav bar stays same
   - Move baseBgColor calculation outside the hot path

3. Image cache fix (expandable_player.dart)
   - Fixed memCacheWidth/Height from changing at t=0.5 to fixed 512px
   - Prevents mid-animation cache thrashing

4. RepaintBoundary for album art (expandable_player.dart)
   - Isolate album art repaints during scale animation

5. Consumer placement fix (global_player_overlay.dart)
   - Use Selector instead of Consumer for player visibility check
   - AnimatedBuilder now properly scoped for slide animation

6. Deferred color extraction (album/artist_details_screen.dart)
   - Defer palette extraction until 350ms after transition completes
   - Use context.select() instead of context.watch() to reduce rebuilds

7. Remove page transition log spam (page_transitions.dart)
   - Removed animation logging that was triggering on every rebuild

Expected improvement: ~15-25ms reduction per frame

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 19:55:30 +00:00
Your Name c2fdccc702 Fix: remove debugLabel property that conflicts with TransitionRoute 2025-12-10 19:55:23 +00:00
Your NameandClaude Opus 4.5 43f50dcd02 Add animation debugging for performance analysis
- New AnimationDebugger utility for tracking frame timing and jank
- Records every frame during player expand/collapse animations
- Logs summary with avg/max frame times, jank counts, effective FPS
- Flags jank frames (>16.67ms) and severe jank (>33.33ms) in real-time
- Adds page transition event logging

Usage: Expand/collapse player, then check logs for animation summary

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 19:55:13 +00:00
Your NameandClaude Opus 4.5 9a0c8c2b7e Fix back navigation for expanded player and detail screens
Two fixes:
1. Connect navigatorKey to MaterialApp - enables popUntil in bottom nav
   to properly dismiss album/artist detail screens when switching tabs

2. Add didPopRoute override in _MusicAssistantAppState - intercepts back
   gesture at app level (before Navigator) to collapse expanded player
   first, then allows normal navigation on subsequent presses

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 19:55:05 +00:00
Your NameandClaude Opus 4.5 d55419514d Tracks tab: simple flat list with album art
- No headings, just a clean list of tracks
- Each row: [Album Art] Artist - Track
- Sorted by artist then track name
- Gets image from track metadata directly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 13:45:42 +00:00
Your NameandClaude Opus 4.5 a7f41827cb Tracks tab: group albums under artist heading
- Artist name as main header
- Multiple albums from same artist grouped together
- Album art + name as sub-header under artist
- Tracks indented under their album
- Cleaner hierarchy: Artist > Album > Tracks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 13:18:17 +00:00
Your NameandClaude Opus 4.5 1dbd589ed7 Improve track favorites UX
- Track menu buttons now match artist/album view style (FilledButton.tonal)
- Tracks tab moved between Albums and Playlists
- Tracks grouped by artist, then album with album art
- Tapping a favorite track starts radio (not just play)
- Tracks indented under album headers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 12:59:25 +00:00
Your NameandClaude Opus 4.5 3b824174a4 Fix Track constructor - remove non-existent parameters
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 12:21:52 +00:00
Your NameandClaude Opus 4.5 8eff3eb376 Add favorite buttons and Tracks tab to library
- Add favorite button to expanded player (top right, next to queue)
- Add favorite button to track long-press menu (3 icon buttons: radio, queue, favorite)
- Add Tracks tab to Library (only visible when Favourites toggle is on)
- Favorite tracks grouped by album in the Tracks tab
- Track favorite toggle with API integration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 12:13:38 +00:00
Your NameandClaude Opus 4.5 2cec4f22ad Library: Replace title with favourites toggle switch
- Remove centered "Library" title from AppBar
- Add left-aligned Switch toggle with "Favourites" label
- Toggle filters artists, albums, playlists by favorite status
- Also: Remove Qobuz from radio providers (doesn't support radio mode)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 11:45:39 +00:00
Your NameandClaude Opus 4.5 fc642cd1d6 Fix build errors: add providerDomain, use availablePlayers
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 11:14:58 +00:00
Your NameandClaude Opus 4.5 e13873456d Add artist actions, improve radio, and library favorites filter
Radio improvements:
- Prefer streaming provider URIs (Spotify, Tidal, etc.) for better
  radio recommendations instead of library URIs
- Add playArtistRadio() for artist-based radio
- Add playArtistRadioToQueue() to add radio to existing queue

Artist screen:
- Add action buttons row (Radio, Play on..., Add to queue, Favorite)
- Add favorite toggle with refresh on screen load
- Add getArtistByUri() API method

Library screen:
- Add favorites filter toggle (heart icon in AppBar)
- Filter artists, albums, and playlists by favorite status
- Add EmptyState.custom() for "no favorites" messages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 11:06:34 +00:00
20 changed files with 1930 additions and 342 deletions
+14
View File
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'providers/music_assistant_provider.dart'; import 'providers/music_assistant_provider.dart';
import 'providers/navigation_provider.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
import 'screens/login_screen.dart'; import 'screens/login_screen.dart';
import 'services/settings_service.dart'; import 'services/settings_service.dart';
@@ -85,6 +86,18 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
super.dispose(); 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 @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state); super.didChangeAppLifecycleState(state);
@@ -134,6 +147,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
lightColorScheme: lightColorScheme, lightColorScheme: lightColorScheme,
darkColorScheme: darkColorScheme, darkColorScheme: darkColorScheme,
child: MaterialApp( child: MaterialApp(
navigatorKey: navigationProvider.navigatorKey,
title: 'Ensemble', title: 'Ensemble',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
themeMode: themeProvider.themeMode, themeMode: themeProvider.themeMode,
@@ -1569,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 { Future<String?> getCurrentStreamUrl(String playerId) async {
return await _api?.getCurrentStreamUrl(playerId); return await _api?.getCurrentStreamUrl(playerId);
} }
+167 -35
View File
@@ -43,9 +43,20 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
super.initState(); super.initState();
_isFavorite = widget.album.favorite ?? false; _isFavorite = widget.album.favorite ?? false;
_loadTracks(); _loadTracks();
_extractColors();
_loadAlbumDescription(); _loadAlbumDescription();
_refreshFavoriteStatus(); _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 { Future<void> _refreshFavoriteStatus() async {
@@ -178,6 +189,92 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
} }
} }
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),
),
);
}
}
}
Future<void> _loadTracks() async { Future<void> _loadTracks() async {
final provider = context.read<MusicAssistantProvider>(); final provider = context.read<MusicAssistantProvider>();
final tracks = await provider.getAlbumTracksWithCache( final tracks = await provider.getAlbumTracksWithCache(
@@ -444,18 +541,32 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>(); // CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
final themeProvider = context.watch<ThemeProvider>(); // Only rebuild when specific properties change, not on every provider update
final imageUrl = maProvider.getImageUrl(widget.album, size: 512); 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 // Determine if we should use adaptive theme colors
final useAdaptiveTheme = themeProvider.adaptiveTheme;
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
// Get the color scheme to use // Get the color scheme to use - prefer local state over provider
// Local state (_darkColorScheme/_lightColorScheme) is set by _extractColors()
ColorScheme? adaptiveScheme; ColorScheme? adaptiveScheme;
if (useAdaptiveTheme) { if (adaptiveTheme) {
adaptiveScheme = isDark ? _darkColorScheme : _lightColorScheme; // Use local state first (from _extractColors), fallback to provider
adaptiveScheme = isDark
? (_darkColorScheme ?? adaptiveDarkScheme)
: (_lightColorScheme ?? adaptiveLightScheme);
} }
// Use adaptive scheme if available, otherwise use global theme // Use adaptive scheme if available, otherwise use global theme
@@ -775,39 +886,60 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
curve: Curves.easeInOut, curve: Curves.easeInOut,
child: isExpanded child: isExpanded
? Padding( ? 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( child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
Expanded( // Radio button
child: SizedBox( SizedBox(
height: 48, height: 44,
child: FilledButton.tonal( width: 44,
onPressed: () => _showPlayRadioMenu(context, index), child: FilledButton.tonal(
style: FilledButton.styleFrom( onPressed: () => _showPlayRadioMenu(context, index),
backgroundColor: colorScheme.tertiaryContainer, style: FilledButton.styleFrom(
foregroundColor: colorScheme.onTertiaryContainer, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
),
), ),
child: const Text('Start radio'),
), ),
child: const Icon(Icons.radio, size: 20),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 10),
Expanded( // Add to queue button
child: SizedBox( SizedBox(
height: 48, height: 44,
child: FilledButton.tonal( width: 44,
onPressed: () => _addTrackToQueue(context, index), child: FilledButton.tonal(
style: FilledButton.styleFrom( onPressed: () => _addTrackToQueue(context, index),
backgroundColor: colorScheme.secondaryContainer, style: FilledButton.styleFrom(
foregroundColor: colorScheme.onSecondaryContainer, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), 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,
), ),
), ),
), ),
+366 -10
View File
@@ -31,6 +31,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
List<Album> _albums = []; List<Album> _albums = [];
List<Album> _providerAlbums = []; List<Album> _providerAlbums = [];
bool _isLoading = true; bool _isLoading = true;
bool _isFavorite = false;
ColorScheme? _lightColorScheme; ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme; ColorScheme? _darkColorScheme;
bool _isDescriptionExpanded = false; bool _isDescriptionExpanded = false;
@@ -42,9 +43,265 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_isFavorite = widget.artist.favorite ?? false;
_loadArtistAlbums(); _loadArtistAlbums();
_loadArtistImage();
_loadArtistDescription(); _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> _refreshFavoriteStatus() async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == 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 { Future<void> _loadArtistImage() async {
@@ -113,16 +370,30 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>(); // CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
final themeProvider = context.watch<ThemeProvider>(); // 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 // Use the loaded image URL (with fallback) instead of directly from MA
final imageUrl = _artistImageUrl; final imageUrl = _artistImageUrl;
final useAdaptiveTheme = themeProvider.adaptiveTheme;
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
// Get the color scheme to use - prefer local state over provider
ColorScheme? adaptiveScheme; ColorScheme? adaptiveScheme;
if (useAdaptiveTheme) { if (adaptiveTheme) {
adaptiveScheme = isDark ? _darkColorScheme : _lightColorScheme; // Use local state first (from _extractColors), fallback to provider
adaptiveScheme = isDark
? (_darkColorScheme ?? adaptiveDarkScheme)
: (_lightColorScheme ?? adaptiveLightScheme);
} }
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme; final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
@@ -226,6 +497,89 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
), ),
const SizedBox(height: 8), 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),
], ],
), ),
), ),
@@ -275,7 +629,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
final album = _albums[index]; final album = _albums[index];
return _buildAlbumCard(album, maProvider); return _buildAlbumCard(album);
}, },
childCount: _albums.length, childCount: _albums.length,
), ),
@@ -309,7 +663,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
final album = _providerAlbums[index]; final album = _providerAlbums[index];
return _buildAlbumCard(album, maProvider); return _buildAlbumCard(album);
}, },
childCount: _providerAlbums.length, childCount: _providerAlbums.length,
), ),
@@ -324,8 +678,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
); );
} }
Widget _buildAlbumCard(Album album, MusicAssistantProvider provider) { Widget _buildAlbumCard(Album album) {
final imageUrl = provider.getImageUrl(album, size: 256); // 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 colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
const String heroSuffix = 'artist_albums'; const String heroSuffix = 'artist_albums';
+158 -68
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
@@ -15,6 +16,8 @@ class DebugLogScreen extends StatefulWidget {
class _DebugLogScreenState extends State<DebugLogScreen> { class _DebugLogScreenState extends State<DebugLogScreen> {
final _logger = DebugLogger(); final _logger = DebugLogger();
final _scrollController = ScrollController(); final _scrollController = ScrollController();
LogLevel _filterLevel = LogLevel.debug; // Show all by default
bool _isGeneratingReport = false;
@override @override
void initState() { void initState() {
@@ -33,17 +36,37 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
super.dispose(); super.dispose();
} }
List<LogEntry> get _filteredEntries =>
_logger.entries.where((e) => e.level.index >= _filterLevel.index).toList();
void _copyLogs() { void _copyLogs() {
Clipboard.setData(ClipboardData(text: _logger.getAllLogs())); Clipboard.setData(ClipboardData(text: _logger.getAllLogs()));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Logs copied to clipboard!'), content: Text('Logs copied to clipboard'),
backgroundColor: Colors.green, backgroundColor: Colors.green,
duration: Duration(seconds: 2), 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() { void _clearLogs() {
setState(() { setState(() {
_logger.clear(); _logger.clear();
@@ -216,16 +239,39 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
), ),
centerTitle: true, centerTitle: true,
actions: [ 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>( PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white), icon: const Icon(Icons.more_vert, color: Colors.white),
tooltip: 'Player tools', tooltip: 'More options',
onSelected: (value) async { onSelected: (value) async {
switch (value) { switch (value) {
case 'show_players': case 'show_players':
_showAllPlayers(); _showAllPlayers();
break; break;
// NOTE: Ghost cleanup and repair options removed - MA APIs don't work case 'copy_logs':
// reliably and caused corrupt entries. See PLAYER_LIFECYCLE_GUIDE.md _copyLogs();
break;
case 'clear_logs':
_clearLogs();
break;
} }
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
@@ -237,49 +283,61 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
dense: true, 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( body: Column(
children: [ children: [
// Filter chips and stats
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
color: Colors.white.withOpacity(0.05), color: Colors.white.withOpacity(0.05),
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Icon( // Level filter chips
Icons.info_outline_rounded, SingleChildScrollView(
color: Colors.white70, scrollDirection: Axis.horizontal,
size: 16, child: Row(
), children: [
const SizedBox(width: 8), _buildFilterChip('All', LogLevel.debug),
Expanded( const SizedBox(width: 8),
child: Text( _buildFilterChip('Info+', LogLevel.info),
'Last ${_logger.logs.length} log entries', const SizedBox(width: 8),
style: const TextStyle( _buildFilterChip('Warnings', LogLevel.warning),
color: Colors.white70, const SizedBox(width: 8),
fontSize: 12, _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( Expanded(
child: _logger.logs.isEmpty child: _filteredEntries.isEmpty
? const Center( ? const Center(
child: Text( child: Text(
'No logs yet', 'No logs yet',
@@ -291,43 +349,11 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
) )
: ListView.builder( : ListView.builder(
controller: _scrollController, controller: _scrollController,
padding: EdgeInsets.fromLTRB(8, 8, 8, BottomSpacing.navBarOnly + 56), // Extra space for FAB padding: EdgeInsets.fromLTRB(8, 8, 8, BottomSpacing.navBarOnly + 56),
itemCount: _logger.logs.length, itemCount: _filteredEntries.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final log = _logger.logs[index]; final entry = _filteredEntries[index];
final isError = log.contains('Error') || return _buildLogEntry(entry);
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,
),
),
);
}, },
), ),
), ),
@@ -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;
}
}
} }
+255 -23
View File
@@ -27,11 +27,16 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
with SingleTickerProviderStateMixin, RestorationMixin { with SingleTickerProviderStateMixin, RestorationMixin {
late TabController _tabController; late TabController _tabController;
List<Playlist> _playlists = []; List<Playlist> _playlists = [];
List<Track> _favoriteTracks = [];
bool _isLoadingPlaylists = true; bool _isLoadingPlaylists = true;
bool _isLoadingTracks = false;
bool _showFavoritesOnly = false;
// Restoration: Remember selected tab across app restarts // Restoration: Remember selected tab across app restarts
final RestorableInt _selectedTabIndex = RestorableInt(0); final RestorableInt _selectedTabIndex = RestorableInt(0);
int get _tabCount => _showFavoritesOnly ? 4 : 3;
@override @override
String? get restorationId => 'new_library_screen'; String? get restorationId => 'new_library_screen';
@@ -39,7 +44,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
void restoreState(RestorationBucket? oldBucket, bool initialRestore) { void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
registerForRestoration(_selectedTabIndex, 'selected_tab_index'); registerForRestoration(_selectedTabIndex, 'selected_tab_index');
// Apply restored tab index after TabController is created // 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; _tabController.index = _selectedTabIndex.value;
} }
} }
@@ -47,12 +53,24 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 3, vsync: this); _tabController = TabController(length: _tabCount, vsync: this);
// Listen to tab changes to persist selection // Listen to tab changes to persist selection
_tabController.addListener(_onTabChanged); _tabController.addListener(_onTabChanged);
_loadPlaylists(); _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() { void _onTabChanged() {
if (!_tabController.indexIsChanging) { if (!_tabController.indexIsChanging) {
_selectedTabIndex.value = _tabController.index; _selectedTabIndex.value = _tabController.index;
@@ -67,10 +85,13 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
super.dispose(); super.dispose();
} }
Future<void> _loadPlaylists() async { Future<void> _loadPlaylists({bool? favoriteOnly}) async {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) { if (maProvider.api != null) {
final playlists = await maProvider.api!.getPlaylists(limit: 100); final playlists = await maProvider.api!.getPlaylists(
limit: 100,
favoriteOnly: favoriteOnly,
);
if (mounted) { if (mounted) {
setState(() { setState(() {
_playlists = playlists; _playlists = playlists;
@@ -86,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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Use Selector for targeted rebuilds - only rebuild when connection state changes // Use Selector for targeted rebuilds - only rebuild when connection state changes
@@ -125,15 +187,40 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
title: Text( titleSpacing: 16,
'Library', title: Row(
style: textTheme.titleLarge?.copyWith( children: [
color: colorScheme.onBackground, // Favorites toggle with label
fontWeight: FontWeight.w300, 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, centerTitle: false,
actions: const [PlayerSelector()], actions: const [
PlayerSelector(),
],
bottom: TabBar( bottom: TabBar(
controller: _tabController, controller: _tabController,
labelColor: colorScheme.primary, labelColor: colorScheme.primary,
@@ -142,10 +229,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
indicatorWeight: 3, indicatorWeight: 3,
labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w400, fontSize: 14), unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w400, fontSize: 14),
tabs: const [ tabs: [
Tab(text: 'Artists'), const Tab(text: 'Artists'),
Tab(text: 'Albums'), const Tab(text: 'Albums'),
Tab(text: 'Playlists'), if (_showFavoritesOnly) const Tab(text: 'Tracks'),
const Tab(text: 'Playlists'),
], ],
), ),
), ),
@@ -154,6 +242,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
children: [ children: [
_buildArtistsTab(context), _buildArtistsTab(context),
_buildAlbumsTab(context), _buildAlbumsTab(context),
if (_showFavoritesOnly) _buildTracksTab(context),
_buildPlaylistsTab(context), _buildPlaylistsTab(context),
], ],
), ),
@@ -168,14 +257,26 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
return Selector<MusicAssistantProvider, (List<Artist>, bool)>( return Selector<MusicAssistantProvider, (List<Artist>, bool)>(
selector: (_, provider) => (provider.artists, provider.isLoading), selector: (_, provider) => (provider.artists, provider.isLoading),
builder: (context, data, _) { builder: (context, data, _) {
final (artists, isLoading) = data; final (allArtists, isLoading) = data;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
if (isLoading) { if (isLoading) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary)); 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 (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( return EmptyState.artists(
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(), onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
); );
@@ -186,7 +287,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(), onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
child: ListView.builder( 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 cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
itemCount: artists.length, itemCount: artists.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly), padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
@@ -261,14 +362,26 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
return Selector<MusicAssistantProvider, (List<Album>, bool)>( return Selector<MusicAssistantProvider, (List<Album>, bool)>(
selector: (_, provider) => (provider.albums, provider.isLoading), selector: (_, provider) => (provider.albums, provider.isLoading),
builder: (context, data, _) { builder: (context, data, _) {
final (albums, isLoading) = data; final (allAlbums, isLoading) = data;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
if (isLoading) { if (isLoading) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary)); 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 (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( return EmptyState.albums(
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(), onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
); );
@@ -279,7 +392,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(), onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
child: GridView.builder( 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 cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly), padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
@@ -312,15 +425,22 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
} }
if (_playlists.isEmpty) { 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( return RefreshIndicator(
color: colorScheme.primary, color: colorScheme.primary,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
onRefresh: _loadPlaylists, onRefresh: () => _loadPlaylists(favoriteOnly: _showFavoritesOnly ? true : null),
child: ListView.builder( 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 cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
itemCount: _playlists.length, itemCount: _playlists.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly), padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
@@ -388,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')),
);
}
}
},
);
}
} }
+6 -3
View File
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
final _playlistLogger = DebugLogger();
class PlaylistDetailsScreen extends StatefulWidget { class PlaylistDetailsScreen extends StatefulWidget {
final Playlist playlist; final Playlist playlist;
@@ -61,11 +64,11 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
return; return;
} }
print('🎵 Queueing playlist on ${player.name}: ${player.playerId}'); _playlistLogger.info('Queueing playlist on ${player.name}', context: 'Playlist');
// Queue all tracks via Music Assistant // Queue all tracks via Music Assistant
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0); await maProvider.playTracks(player.playerId, _tracks, startIndex: 0);
print('✓ Playlist queued on ${player.name}'); _playlistLogger.info('Playlist queued successfully', context: 'Playlist');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -76,7 +79,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
); );
} }
} catch (e) { } catch (e) {
print('Error playing playlist: $e'); _playlistLogger.error('Error playing playlist', context: 'Playlist', error: e);
_showError('Error playing playlist: $e'); _showError('Error playing playlist: $e');
} }
} }
+17 -4
View File
@@ -100,11 +100,11 @@ class SearchScreenState extends State<SearchScreen> {
void _onSearchChanged(String query) { void _onSearchChanged(String query) {
_debounceTimer?.cancel(); _debounceTimer?.cancel();
_debounceTimer = Timer(Timings.searchDebounce, () { _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) { if (query.isEmpty) {
setState(() { setState(() {
_searchResults = {'artists': [], 'albums': [], 'tracks': []}; _searchResults = {'artists': [], 'albums': [], 'tracks': []};
@@ -129,6 +129,10 @@ class SearchScreenState extends State<SearchScreen> {
_isSearching = false; _isSearching = false;
_hasSearched = true; _hasSearched = true;
}); });
// Keep keyboard open if user is still typing
if (keepFocus && _focusNode.hasFocus) {
_focusNode.requestFocus();
}
} }
} catch (e) { } catch (e) {
_logger.log('Search error: $e'); _logger.log('Search error: $e');
@@ -138,6 +142,9 @@ class SearchScreenState extends State<SearchScreen> {
_hasSearched = true; _hasSearched = true;
_searchError = 'Search failed. Please check your connection.'; _searchError = 'Search failed. Please check your connection.';
}); });
if (keepFocus && _focusNode.hasFocus) {
_focusNode.requestFocus();
}
} }
} }
} }
@@ -157,6 +164,7 @@ class SearchScreenState extends State<SearchScreen> {
focusNode: _focusNode, focusNode: _focusNode,
style: TextStyle(color: colorScheme.onSurface), style: TextStyle(color: colorScheme.onSurface),
cursorColor: colorScheme.primary, cursorColor: colorScheme.primary,
textInputAction: TextInputAction.search,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search music...', hintText: 'Search music...',
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)), hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
@@ -166,15 +174,20 @@ class SearchScreenState extends State<SearchScreen> {
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)), icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
onPressed: () { onPressed: () {
_searchController.clear(); _searchController.clear();
_performSearch(''); setState(() {
_searchResults = {'artists': [], 'albums': [], 'tracks': []};
_hasSearched = false;
_searchError = null;
});
}, },
) )
: null, : null,
), ),
onChanged: (value) { onChanged: (value) {
setState(() {}); // Update clear button visibility
_onSearchChanged(value); _onSearchChanged(value);
}, },
onSubmitted: _performSearch, onSubmitted: (query) => _performSearch(query),
), ),
), ),
body: !maProvider.isConnected body: !maProvider.isConnected
+236
View File
@@ -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
View File
@@ -1,32 +1,185 @@
import 'dart:io';
import 'package:flutter/foundation.dart'; 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 { class DebugLogger {
static final DebugLogger _instance = DebugLogger._internal(); static final DebugLogger _instance = DebugLogger._internal();
factory DebugLogger() => _instance; factory DebugLogger() => _instance;
DebugLogger._internal(); DebugLogger._internal();
final List<String> _logs = []; final List<LogEntry> _entries = [];
final _maxLogs = 500; 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) { List<LogEntry> get entries => List.unmodifiable(_entries);
final timestamp = DateTime.now().toIso8601String().substring(11, 23);
final logMessage = '[$timestamp] $message';
debugPrint(logMessage); /// Legacy getter for backward compatibility
List<String> get logs => _entries.map((e) => e.formatted).toList();
_logs.add(logMessage); void _addEntry(LogLevel level, String message, {String? context}) {
if (_logs.length > _maxLogs) { final entry = LogEntry(
_logs.removeAt(0); 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() { void clear() {
_logs.clear(); _entries.clear();
} }
String getAllLogs() { 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();
} }
} }
+5 -3
View File
@@ -1,8 +1,10 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'debug_logger.dart';
import 'settings_service.dart'; import 'settings_service.dart';
class MetadataService { class MetadataService {
static final _logger = DebugLogger();
// Cache to avoid repeated API calls for the same artist/album // Cache to avoid repeated API calls for the same artist/album
static final Map<String, String> _cache = {}; static final Map<String, String> _cache = {};
@@ -152,7 +154,7 @@ class MetadataService {
} }
} }
} catch (e) { } catch (e) {
print('⚠️ Last.fm API error: $e'); _logger.warning('Last.fm API error: $e', context: 'Metadata');
} }
return null; return null;
} }
@@ -185,7 +187,7 @@ class MetadataService {
} }
} }
} catch (e) { } catch (e) {
print('⚠️ TheAudioDB API error: $e'); _logger.warning('TheAudioDB API error: $e', context: 'Metadata');
} }
return null; return null;
} }
@@ -241,7 +243,7 @@ class MetadataService {
} }
} }
} catch (e) { } catch (e) {
print('⚠️ Deezer artist image error: $e'); _logger.warning('Deezer artist image error: $e', context: 'Metadata');
} }
// Cache the null result to avoid repeated failed lookups // Cache the null result to avoid repeated failed lookups
+133 -4
View File
@@ -641,6 +641,26 @@ class MusicAssistantAPI {
} }
} }
/// 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;
}
}
Future<List<Track>> getAlbumTracks(String provider, String itemId) async { Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
return await RetryHelper.retryNetwork( return await RetryHelper.retryNetwork(
operation: () async { operation: () async {
@@ -1055,12 +1075,14 @@ class MusicAssistantAPI {
} }
/// Play radio based on a track (generates similar tracks) /// 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 { Future<void> playRadio(String playerId, Track track) async {
return await RetryHelper.retryCritical( return await RetryHelper.retryCritical(
operation: () async { operation: () async {
// Try using library URI if available, otherwise use provider URI // For radio mode, prefer streaming providers (Spotify, Tidal, etc.)
final libraryUri = track.uri; // as they provide better dynamic track recommendations
final trackUri = libraryUri ?? _buildTrackUri(track); final trackUri = _buildRadioUri(track);
_logger.log('Radio: Starting with URI $trackUri');
final args = { final args = {
'queue_id': playerId, 'queue_id': playerId,
@@ -1069,7 +1091,6 @@ class MusicAssistantAPI {
'radio_mode': true, // Enable radio mode for similar tracks 'radio_mode': true, // Enable radio mode for similar tracks
}; };
await _sendCommand( await _sendCommand(
'player_queues/play_media', 'player_queues/play_media',
args: args, args: args,
@@ -1079,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 /// Build track URI from provider mappings
String _buildTrackUri(Track track) { String _buildTrackUri(Track track) {
// Use provider mappings to get the actual provider instance // Use provider mappings to get the actual provider instance
+4 -1
View File
@@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:palette_generator/palette_generator.dart'; import 'package:palette_generator/palette_generator.dart';
import '../services/debug_logger.dart';
final _logger = DebugLogger();
/// Extracted adaptive colors from album art /// Extracted adaptive colors from album art
class AdaptiveColors { class AdaptiveColors {
@@ -40,7 +43,7 @@ class PaletteHelper {
); );
return palette; return palette;
} catch (e) { } catch (e) {
print('⚠️ Failed to extract palette: $e'); _logger.warning('Failed to extract palette: $e', context: 'Palette');
return null; return null;
} }
} }
+4 -1
View File
@@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import '../services/debug_logger.dart';
final _systemThemeLogger = DebugLogger();
class SystemThemeHelper { class SystemThemeHelper {
/// Attempts to get system color schemes from the device (Android 12+) /// Attempts to get system color schemes from the device (Android 12+)
@@ -35,7 +38,7 @@ class SystemThemeHelper {
} }
} }
} catch (e) { } catch (e) {
print('⚠️ Failed to get system colors: $e'); _systemThemeLogger.warning('Failed to get system colors: $e', context: 'Theme');
} }
return null; return null;
+4 -1
View File
@@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import 'palette_helper.dart'; import 'palette_helper.dart';
final _themeLogger = DebugLogger();
/// Global function to update adaptive colors from an image URL /// 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) /// This can be called from anywhere in the app (e.g., when tapping an album/artist)
Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUrl) async { Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUrl) async {
@@ -17,7 +20,7 @@ Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUr
} }
} catch (e) { } catch (e) {
// Silently fail - colors will update when track plays // 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');
} }
} }
+4 -4
View File
@@ -7,6 +7,7 @@ import '../constants/hero_tags.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import '../services/metadata_service.dart'; import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
class ArtistCard extends StatefulWidget { class ArtistCard extends StatefulWidget {
final Artist artist; final Artist artist;
@@ -25,6 +26,7 @@ class ArtistCard extends StatefulWidget {
} }
class _ArtistCardState extends State<ArtistCard> { class _ArtistCardState extends State<ArtistCard> {
static final _logger = DebugLogger();
String? _fallbackImageUrl; String? _fallbackImageUrl;
bool _triedFallback = false; bool _triedFallback = false;
@@ -43,10 +45,8 @@ class _ArtistCardState extends State<ArtistCard> {
// Try to fetch fallback image if MA doesn't have one and we haven't tried yet // Try to fetch fallback image if MA doesn't have one and we haven't tried yet
if (maImageUrl == null && !_triedFallback) { if (maImageUrl == null && !_triedFallback) {
_triedFallback = true; _triedFallback = true;
print('🎨 No MA image for "${widget.artist.name}", trying fallback...'); _logger.debug('No MA image for "${widget.artist.name}", trying fallback', context: 'ArtistCard');
_fetchFallbackImage(); _fetchFallbackImage();
} else if (maImageUrl != null && !_triedFallback) {
print('🎨 MA has image for "${widget.artist.name}": $maImageUrl');
} }
return RepaintBoundary( return RepaintBoundary(
@@ -117,8 +117,8 @@ class _ArtistCardState extends State<ArtistCard> {
Future<void> _fetchFallbackImage() async { Future<void> _fetchFallbackImage() async {
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name); final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
print('🎨 Fallback result for "${widget.artist.name}": $fallbackUrl');
if (fallbackUrl != null && mounted) { if (fallbackUrl != null && mounted) {
_logger.debug('Found fallback image for "${widget.artist.name}"', context: 'ArtistCard');
setState(() { setState(() {
_fallbackImageUrl = fallbackUrl; _fallbackImageUrl = fallbackUrl;
}); });
+82
View File
@@ -64,6 +64,20 @@ class EmptyState extends StatelessWidget {
message: 'No results found', 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; 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,
),
),
],
],
),
),
);
}
}
+224 -104
View File
@@ -7,6 +7,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import '../constants/timings.dart'; import '../constants/timings.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../models/player.dart'; import '../models/player.dart';
import '../services/animation_debugger.dart';
import '../theme/palette_helper.dart'; import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import 'animated_icon_button.dart'; import 'animated_icon_button.dart';
@@ -81,6 +82,15 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// When true, we hide main content and show peek content at center // When true, we hide main content and show peek content at center
bool _inTransition = false; bool _inTransition = false;
// Track favorite state for current track
bool _isCurrentTrackFavorite = false;
String? _lastTrackUri; // Track which track we last checked favorite status for
// Cached title height to avoid TextPainter.layout() every animation frame
double? _cachedExpandedTitleHeight;
String? _lastMeasuredTrackName;
double? _lastMeasuredTitleWidth;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -97,6 +107,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Notify listeners of expansion progress changes // Notify listeners of expansion progress changes
_controller.addListener(_notifyExpansionProgress); _controller.addListener(_notifyExpansionProgress);
// Animation debugging - record every frame
_controller.addListener(_recordAnimationFrame);
// Queue panel animation // Queue panel animation
_queuePanelController = AnimationController( _queuePanelController = AnimationController(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
@@ -164,15 +177,25 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
super.dispose(); super.dispose();
} }
void _recordAnimationFrame() {
AnimationDebugger.recordFrame(_controller.value);
}
void expand() { void expand() {
_controller.forward(); AnimationDebugger.startSession('playerExpand');
_controller.forward().then((_) {
AnimationDebugger.endSession();
});
} }
void collapse() { void collapse() {
AnimationDebugger.startSession('playerCollapse');
// Instantly hide queue panel when collapsing to avoid visual glitches // Instantly hide queue panel when collapsing to avoid visual glitches
// during Android's predictive back gesture // during Android's predictive back gesture
_queuePanelController.value = 0; _queuePanelController.value = 0;
_controller.reverse(); _controller.reverse().then((_) {
AnimationDebugger.endSession();
});
} }
bool get isExpanded => _controller.value > 0.5; bool get isExpanded => _controller.value > 0.5;
@@ -283,6 +306,64 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
bool get isQueuePanelOpen => _queuePanelController.value > 0.5; bool get isQueuePanelOpen => _queuePanelController.value > 0.5;
/// Update favorite status when track changes
void _updateFavoriteStatus(dynamic currentTrack) {
if (currentTrack == null) {
_isCurrentTrackFavorite = false;
_lastTrackUri = null;
return;
}
final trackUri = currentTrack.uri as String?;
if (trackUri != _lastTrackUri) {
_lastTrackUri = trackUri;
_isCurrentTrackFavorite = currentTrack.favorite == true;
}
}
/// Toggle favorite status for current track
Future<void> _toggleCurrentTrackFavorite(dynamic currentTrack) async {
if (currentTrack == null) return;
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
try {
if (_isCurrentTrackFavorite) {
// Remove from favorites
final libraryItemId = currentTrack.itemId;
if (libraryItemId is int) {
await maProvider.api!.removeFromFavorites('track', libraryItemId);
} else if (libraryItemId is String) {
final parsed = int.tryParse(libraryItemId);
if (parsed != null) {
await maProvider.api!.removeFromFavorites('track', parsed);
}
}
} else {
// Add to favorites
final provider = currentTrack.provider as String? ?? 'library';
final itemId = currentTrack.itemId?.toString() ?? '';
await maProvider.api!.addToFavorites('track', itemId, provider);
}
// Toggle local state
setState(() {
_isCurrentTrackFavorite = !_isCurrentTrackFavorite;
});
} catch (e) {
// Show error feedback
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update favorite: $e'),
duration: const Duration(seconds: 2),
),
);
}
}
}
/// Get available players sorted alphabetically (consistent with device selector) /// Get available players sorted alphabetically (consistent with device selector)
List<dynamic> _getAvailablePlayersSorted(MusicAssistantProvider maProvider) { List<dynamic> _getAvailablePlayersSorted(MusicAssistantProvider maProvider) {
// Use the provider's already-sorted list, filter for available only // Use the provider's already-sorted list, filter for available only
@@ -612,6 +693,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_extractColors(imageUrl); _extractColors(imageUrl);
} }
// Update favorite status when track changes
_updateFavoriteStatus(currentTrack);
return AnimatedBuilder( return AnimatedBuilder(
animation: Listenable.merge([_expandAnimation, _queuePanelAnimation]), animation: Listenable.merge([_expandAnimation, _queuePanelAnimation]),
builder: (context, _) { builder: (context, _) {
@@ -821,19 +905,27 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final expandedTitleWidth = screenSize.width - (contentPadding * 2); final expandedTitleWidth = screenSize.width - (contentPadding * 2);
final titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t); final titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t);
// Measure actual title height for dynamic layout // Measure actual title height for dynamic layout (CACHED to avoid layout every frame)
final titleStyle = TextStyle( final titleStyle = TextStyle(
fontSize: 24.0, fontSize: 24.0,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.5, letterSpacing: -0.5,
height: 1.2, height: 1.2,
); );
final titlePainter = TextPainter( // Only recalculate if track name or width changed
text: TextSpan(text: currentTrack.name, style: titleStyle), if (_lastMeasuredTrackName != currentTrack.name ||
maxLines: 2, _lastMeasuredTitleWidth != expandedTitleWidth ||
textDirection: TextDirection.ltr, _cachedExpandedTitleHeight == null) {
)..layout(maxWidth: expandedTitleWidth); final titlePainter = TextPainter(
final expandedTitleHeight = titlePainter.height; text: TextSpan(text: currentTrack.name, style: titleStyle),
maxLines: 2,
textDirection: TextDirection.ltr,
)..layout(maxWidth: expandedTitleWidth);
_cachedExpandedTitleHeight = titlePainter.height;
_lastMeasuredTrackName = currentTrack.name;
_lastMeasuredTitleWidth = expandedTitleWidth;
}
final expandedTitleHeight = _cachedExpandedTitleHeight!;
// Calculate track info block height (title + gap + artist + gap + album) // Calculate track info block height (title + gap + artist + gap + album)
final titleToArtistGap = 12.0; final titleToArtistGap = 12.0;
@@ -992,53 +1084,57 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Album art - with slide animation when collapsed // Album art - with slide animation when collapsed
// Hidden during transition to prevent flash (peek content shows instead) // Hidden during transition to prevent flash (peek content shows instead)
Positioned( // GPU PERF: Use conditional instead of Opacity to avoid saveLayer
left: artLeft + miniPlayerSlideOffset, if (!(_inTransition && t < 0.1))
top: artTop, Positioned(
child: Opacity( left: artLeft + miniPlayerSlideOffset,
opacity: _inTransition && t < 0.1 ? 0.0 : 1.0, top: artTop,
child: Container( child: Container(
width: artSize, width: artSize,
height: artSize, height: artSize,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(artBorderRadius), borderRadius: BorderRadius.circular(artBorderRadius),
// GPU PERF: Fixed blur/offset, only animate shadow opacity
boxShadow: t > 0.3 boxShadow: t > 0.3
? [ ? [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.3 * t), color: Colors.black.withOpacity(0.25 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
blurRadius: 24 * t, blurRadius: 20,
offset: Offset(0, 8 * t), offset: const Offset(0, 8),
), ),
] ]
: null, : null,
), ),
child: ClipRRect( // Use RepaintBoundary to isolate art repaints during animation
borderRadius: BorderRadius.circular(artBorderRadius), child: RepaintBoundary(
child: imageUrl != null child: ClipRRect(
? CachedNetworkImage( borderRadius: BorderRadius.circular(artBorderRadius),
imageUrl: imageUrl, child: imageUrl != null
fit: BoxFit.cover, ? CachedNetworkImage(
memCacheWidth: t > 0.5 ? 1024 : 256, imageUrl: imageUrl,
memCacheHeight: t > 0.5 ? 1024 : 256, fit: BoxFit.cover,
fadeInDuration: Duration.zero, // Fixed cache size to avoid mid-animation cache thrashing
fadeOutDuration: Duration.zero, memCacheWidth: 512,
placeholderFadeInDuration: Duration.zero, memCacheHeight: 512,
placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t), fadeInDuration: Duration.zero,
errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t), fadeOutDuration: Duration.zero,
) placeholderFadeInDuration: Duration.zero,
: _buildPlaceholderArt(colorScheme, t), placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t),
errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t),
)
: _buildPlaceholderArt(colorScheme, t),
),
), ),
), ),
), ),
),
// Track title - with slide animation when collapsed // Track title - with slide animation when collapsed
// Hidden during transition to prevent flash // Hidden during transition to prevent flash
Positioned( // GPU PERF: Use conditional instead of Opacity to avoid saveLayer
left: titleLeft + miniPlayerSlideOffset, if (!(_inTransition && t < 0.1))
top: titleTop, Positioned(
child: Opacity( left: titleLeft + miniPlayerSlideOffset,
opacity: _inTransition && t < 0.1 ? 0.0 : 1.0, top: titleTop,
child: SizedBox( child: SizedBox(
width: titleWidth, width: titleWidth,
child: Text( child: Text(
@@ -1056,15 +1152,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
), ),
), ),
), ),
),
// Artist name - with slide animation when collapsed // Artist name - with slide animation when collapsed
// Hidden during transition to prevent flash // Hidden during transition to prevent flash
Positioned( // GPU PERF: Use conditional instead of Opacity to avoid saveLayer
left: titleLeft + miniPlayerSlideOffset, if (!(_inTransition && t < 0.1))
top: artistTop, Positioned(
child: Opacity( left: titleLeft + miniPlayerSlideOffset,
opacity: _inTransition && t < 0.1 ? 0.0 : 1.0, top: artistTop,
child: SizedBox( child: SizedBox(
width: titleWidth, width: titleWidth,
child: Text( child: Text(
@@ -1080,38 +1175,40 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
), ),
), ),
), ),
),
// Album name (expanded only) // Album name (expanded only)
// GPU PERF: Use color alpha instead of Opacity widget
if (currentTrack.album != null && t > 0.3) if (currentTrack.album != null && t > 0.3)
Positioned( Positioned(
left: contentPadding, left: contentPadding,
right: contentPadding, right: contentPadding,
top: _lerpDouble(artistTop + 24, expandedAlbumTop, t), top: _lerpDouble(artistTop + 24, expandedAlbumTop, t),
child: Opacity( child: Text(
opacity: ((t - 0.3) / 0.7).clamp(0.0, 1.0), currentTrack.album!.name,
child: Text( style: TextStyle(
currentTrack.album!.name, color: textColor.withOpacity(0.45 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
style: TextStyle( fontSize: 15,
color: textColor.withOpacity(0.45), fontWeight: FontWeight.w300,
fontSize: 15, // Increased from 13 to 15
fontWeight: FontWeight.w300,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
), ),
// Progress bar (expanded only) // Progress bar (expanded only)
// GPU PERF: Use FadeTransition instead of Opacity
if (t > 0.5 && currentTrack.duration != null) if (t > 0.5 && currentTrack.duration != null)
Positioned( Positioned(
left: contentPadding, left: contentPadding,
right: contentPadding, right: contentPadding,
top: expandedProgressTop, top: expandedProgressTop,
child: Opacity( child: FadeTransition(
opacity: expandedElementsOpacity, opacity: _expandAnimation.drive(
Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: const Interval(0.5, 1.0, curve: Curves.easeIn)),
),
),
child: ValueListenableBuilder<int>( child: ValueListenableBuilder<int>(
valueListenable: _progressNotifier, valueListenable: _progressNotifier,
builder: (context, elapsedTime, child) { builder: (context, elapsedTime, child) {
@@ -1202,14 +1299,13 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end, mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end,
children: [ children: [
// Shuffle (expanded only) // Shuffle (expanded only)
if (t > 0.5) // GPU PERF: Use color alpha instead of Opacity
Opacity( if (t > 0.5 && expandedElementsOpacity > 0.1)
opacity: expandedElementsOpacity, _buildSecondaryButton(
child: _buildSecondaryButton( icon: Icons.shuffle_rounded,
icon: Icons.shuffle_rounded, color: (_queue?.shuffle == true ? primaryColor : textColor.withOpacity(0.5))
color: _queue?.shuffle == true ? primaryColor : textColor.withOpacity(0.5), .withOpacity(expandedElementsOpacity),
onPressed: _isLoadingQueue ? null : _toggleShuffle, onPressed: _isLoadingQueue ? null : _toggleShuffle,
),
), ),
if (t > 0.5) SizedBox(width: _lerpDouble(0, 20, t)), if (t > 0.5) SizedBox(width: _lerpDouble(0, 20, t)),
@@ -1247,85 +1343,109 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
), ),
// Repeat (expanded only) // Repeat (expanded only)
// GPU PERF: Use color alpha instead of Opacity
if (t > 0.5) SizedBox(width: _lerpDouble(0, 20, t)), if (t > 0.5) SizedBox(width: _lerpDouble(0, 20, t)),
if (t > 0.5) if (t > 0.5 && expandedElementsOpacity > 0.1)
Opacity( _buildSecondaryButton(
opacity: expandedElementsOpacity, icon: _queue?.repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
child: _buildSecondaryButton( color: (_queue?.repeatMode != null && _queue!.repeatMode != 'off'
icon: _queue?.repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded, ? primaryColor
color: _queue?.repeatMode != null && _queue!.repeatMode != 'off' : textColor.withOpacity(0.5))
? primaryColor .withOpacity(expandedElementsOpacity),
: textColor.withOpacity(0.5), onPressed: _isLoadingQueue ? null : _cycleRepeat,
onPressed: _isLoadingQueue ? null : _cycleRepeat,
),
), ),
], ],
), ),
), ),
// Volume control (expanded only) // Volume control (expanded only)
// GPU PERF: Use FadeTransition instead of Opacity
if (t > 0.5) if (t > 0.5)
Positioned( Positioned(
left: 48, left: 48,
right: 48, right: 48,
top: volumeTop, top: volumeTop,
child: Opacity( child: FadeTransition(
opacity: expandedElementsOpacity, opacity: _expandAnimation.drive(
Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: const Interval(0.5, 1.0, curve: Curves.easeIn)),
),
),
child: const VolumeControl(compact: false), child: const VolumeControl(compact: false),
), ),
), ),
// Collapse button (expanded only) // Collapse button (expanded only)
// GPU PERF: Use icon color alpha instead of Opacity
if (t > 0.3) if (t > 0.3)
Positioned( Positioned(
top: topPadding + 4, top: topPadding + 4,
left: 4, left: 4,
child: Opacity( child: IconButton(
opacity: ((t - 0.3) / 0.7).clamp(0.0, 1.0), icon: Icon(
child: IconButton( Icons.keyboard_arrow_down_rounded,
icon: Icon(Icons.keyboard_arrow_down_rounded, color: textColor, size: 28), color: textColor.withOpacity(((t - 0.3) / 0.7).clamp(0.0, 1.0)),
onPressed: collapse, size: 28,
padding: const EdgeInsets.all(12),
), ),
onPressed: collapse,
padding: const EdgeInsets.all(12),
),
),
// Favorite button (expanded only) - hide when queue panel is open
// GPU PERF: Use icon color alpha instead of Opacity
if (t > 0.3 && queueT < 0.5)
Positioned(
top: topPadding + 4,
right: 52,
child: IconButton(
icon: Icon(
_isCurrentTrackFavorite ? Icons.favorite : Icons.favorite_border,
color: (_isCurrentTrackFavorite ? Colors.red : textColor)
.withOpacity(((t - 0.3) / 0.7).clamp(0.0, 1.0) * (1 - queueT * 2).clamp(0.0, 1.0)),
size: 24,
),
onPressed: () => _toggleCurrentTrackFavorite(currentTrack),
padding: const EdgeInsets.all(12),
), ),
), ),
// Queue button (expanded only) - hide when queue panel is open // Queue button (expanded only) - hide when queue panel is open
// GPU PERF: Use icon color alpha instead of Opacity
if (t > 0.3 && queueT < 0.5) if (t > 0.3 && queueT < 0.5)
Positioned( Positioned(
top: topPadding + 4, top: topPadding + 4,
right: 4, right: 4,
child: Opacity( child: IconButton(
opacity: ((t - 0.3) / 0.7).clamp(0.0, 1.0) * (1 - queueT * 2).clamp(0.0, 1.0), icon: Icon(
child: IconButton( Icons.queue_music_rounded,
icon: Icon(Icons.queue_music_rounded, color: textColor, size: 24), color: textColor.withOpacity(((t - 0.3) / 0.7).clamp(0.0, 1.0) * (1 - queueT * 2).clamp(0.0, 1.0)),
onPressed: _toggleQueuePanel, size: 24,
padding: const EdgeInsets.all(12),
), ),
onPressed: _toggleQueuePanel,
padding: const EdgeInsets.all(12),
), ),
), ),
// Player name (expanded only) // Player name (expanded only)
// GPU PERF: Use text color alpha instead of Opacity
if (t > 0.5) if (t > 0.5)
Positioned( Positioned(
top: topPadding + 12, top: topPadding + 12,
left: 56, left: 56,
right: 56, right: 56,
child: IgnorePointer( child: IgnorePointer(
child: Opacity( child: Text(
opacity: ((t - 0.5) / 0.5).clamp(0.0, 1.0), selectedPlayer.name,
child: Text( style: TextStyle(
selectedPlayer.name, color: textColor.withOpacity(0.6 * ((t - 0.5) / 0.5).clamp(0.0, 1.0)),
style: TextStyle( fontSize: 15,
color: textColor.withOpacity(0.6), fontWeight: FontWeight.w500,
fontSize: 15, // Increased from 13 to 15 letterSpacing: 0.2,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
), ),
), ),
+70 -68
View File
@@ -149,19 +149,65 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
navSelectedColor = hsl.withLightness((hsl.lightness - 0.3).clamp(0.2, 1.0)).toColor(); 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>( return ValueListenableBuilder<PlayerExpansionState>(
valueListenable: playerExpansionNotifier, valueListenable: playerExpansionNotifier,
builder: (context, expansionState, child) { // Pass BottomNavigationBar as child to avoid rebuilding it every frame
// Base background: use adaptive surface color if available, otherwise default surface child: BottomNavigationBar(
final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null) currentIndex: navigationProvider.selectedIndex,
? themeProvider.adaptiveSurfaceColor! onTap: (index) {
: colorScheme.surface; if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
// Lerp between base color and player background when expanded }
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 final navBgColor = expansionState.progress > 0 && expansionState.backgroundColor != null
? Color.lerp(baseBgColor, expansionState.backgroundColor, expansionState.progress)! ? Color.lerp(baseBgColor, expansionState.backgroundColor, expansionState.progress)!
: baseBgColor; : baseBgColor;
// Use AnimatedContainer for smoother transitions instead of rebuilding
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: navBgColor, color: navBgColor,
@@ -175,56 +221,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
] ]
: null, : null,
), ),
child: BottomNavigationBar( child: navBar, // Reuse pre-built navigation bar
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',
),
],
),
); );
}, },
); );
@@ -234,17 +231,22 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
), ),
), ),
// Global player overlay - slides down when hidden, renders ON TOP of bottom nav // Global player overlay - slides down when hidden, renders ON TOP of bottom nav
AnimatedBuilder( // Use Selector instead of Consumer to avoid rebuilds during animation
animation: _slideAnimation, Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer})>(
builder: (context, child) { selector: (_, provider) => (
return Consumer<MusicAssistantProvider>( isConnected: provider.isConnected,
builder: (context, maProvider, _) { hasPlayer: provider.selectedPlayer != null,
// Only show player if connected and has a selected player ),
// Mini player is now permanent - shows device selector even without a track builder: (context, state, child) {
if (!maProvider.isConnected || // Only show player if connected and has a selected player
maProvider.selectedPlayer == null) { if (!state.isConnected || !state.hasPlayer) {
return const SizedBox.shrink(); 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( return ExpandablePlayer(
key: globalPlayerKey, key: globalPlayerKey,
slideOffset: _slideAnimation.value, slideOffset: _slideAnimation.value,
+4 -1
View File
@@ -1,7 +1,7 @@
name: ensemble name: ensemble
description: A minimalistic Music Assistant client built with Flutter description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none' publish_to: 'none'
version: 1.6.1+8 version: 1.8.0+9
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
@@ -28,6 +28,9 @@ dependencies:
# Utilities # Utilities
uuid: ^4.2.1 uuid: ^4.2.1
package_info_plus: ^8.0.0
device_info_plus: ^10.1.0
share_plus: ^9.0.0
# Audio - Local Playback # Audio - Local Playback
just_audio: ^0.9.36 just_audio: ^0.9.36