Compare commits

..
Author SHA1 Message Date
Your NameandClaude Opus 4.5 6c97420f58 chore: enable full debug logging for user bug reports
Disable perf-only mode so all log levels (debug, info, warning, error)
are captured in bug reports, not just performance logs.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 16:23:00 +00:00
Your NameandClaude Opus 4.5 526e0d06b6 perf: apply scroll optimizations to all list views app-wide
Extends FPS optimizations from HomeScreen to all scrollable lists:
- Library screens (artists, albums, tracks, playlists)
- New library screen (all tabs)
- Search screen results
- Queue panel

Changes applied:
- Add cacheExtent: 500 for preloading items off-screen
- Add addAutomaticKeepAlives: false (items manage their own)
- Add addRepaintBoundaries: false (items have RepaintBoundary)
- Wrap list tiles in RepaintBoundary for isolated repaints
- Add ValueKey to items for efficient diffing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 15:39:01 +00:00
Your NameandClaude Opus 4.5 7092c2c08e chore: restore performance logging for user bug reports
Re-adds the scroll frame timing logs so users can report performance
issues with actual metrics. The underlying performance fixes remain.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 15:33:35 +00:00
Your NameandClaude Opus 4.5 c728815f8b perf: optimize HomeScreen scroll FPS for 120Hz displays
Major performance improvements targeting 17-23ms frame times on Pixel 9 Pro Fold:

- ArtistCard: Replace Image.network with CachedNetworkImage for disk caching
- ArtistCard: Move fallback image fetch from build() to initState()
- NewHomeScreen: Remove _loadSettings() call from build method
- NewHomeScreen: Remove NotificationListener frame callbacks (2-3ms overhead)
- AlbumRow/ArtistRow/TrackRow: Add cacheExtent, disable redundant keepAlives
- GlobalPlayerOverlay: Cache HSL color computations to avoid per-frame recalc

Expected improvement: 15-25ms frame time reduction, targeting <8.33ms for 120Hz

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 15:21:43 +00:00
Your Name 19055d1474 Enable perf-only logging mode for FPS diagnosis
All non-perf logs are now filtered out. Only SCROLL START/END,
SLOW FRAME, and BUILD timing logs will appear.
2025-12-11 14:33:06 +00:00
Your Name cac78f7a11 Add perf case to debug log screen color switch 2025-12-11 14:31:52 +00:00
Your Name 1ad5ab3f22 Add performance logging for FPS diagnosis
- Add perf log level to DebugLogger
- Track widget build times (logs if > 4ms)
- Track frame times during scroll (logs if > 16ms)
- Add scroll notification listener to home screen
- Logs SCROLL START/END and slow frames
2025-12-11 14:27:15 +00:00
Your Name 4f7fe9f4a4 Fix syntax errors in artist_row and track_row 2025-12-11 13:58:01 +00:00
Your Name 081f13d59f Add RepaintBoundary to row widgets for better scroll performance 2025-12-11 13:45:50 +00:00
Your Name ea4407f509 Fix text cutoff and scroll issues
1. Calculate card width based on artwork size (content height - text area)
   - Text area is ~44px (8px gap + title + subtitle)
   - Artwork is square, fits remaining height
   - Card width = artwork size

2. Add bottom padding to scroll view for >3 rows
   - Allows scrolling fully to bottom row
2025-12-11 13:13:17 +00:00
Your Name 6ffc98c680 Simplify row sizing: all rows same height = screen / 3
- rowHeight now represents total row height (title + content)
- Each row constrained to exact height with SizedBox
- Simple calculation: available height / 3
- No more ratio math between album/artist rows
- >3 rows enabled: same heights, stack and scroll
2025-12-11 12:43:07 +00:00
Your Name 0fe53c9115 Fix favorites spacing: remove extra 8px spacer before first row 2025-12-11 12:35:15 +00:00
Your Name d0a1e3a85b Fix row sizing: always calculate for 3 rows, stack and scroll if >3 enabled
- Row heights now always based on fitting 3 rows (2 album + 1 artist)
- If >3 rows enabled, they stack and scroll
- If ≤3 rows, fits perfectly without scroll
- Added 4px more bottom padding for proper gap above mini player
2025-12-11 12:20:12 +00:00
Your Name fa8c5bd26e Fix bottom spacing: increase mini player gap by 4px, restore 8px row spacing 2025-12-11 11:53:39 +00:00
Your NameandClaude Opus 4.5 62386b65d3 Fix home screen issues: consistent artist images, proper scaling, auto-reload
Fixes:
1. Artist card: Fixed height for text area (36px) so image size is
   consistent regardless of whether name takes 1 or 2 lines
2. All rows now use same height calculation - favorites rows get the
   same scaled heights as main rows, so 3 favorites rows = same height
   as 3 default rows (no scroll in either case)
3. Reduced spacing between rows from 8px to 4px for tighter fit
4. Settings auto-reload: Home screen reloads settings on each build
   so changes in Settings are reflected immediately without manual refresh

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 11:46:34 +00:00
Your NameandClaude Opus 4.5 82beb0db2c Home screen improvements: optional rows, random favorites, better spacing
Changes:
- All rows now optional in Settings > Home Screen
- Main rows (Recently Played, Discover Artists, Discover Albums) default on
- Favorites rows default off, appear in random order each session
- Added 4px more top padding on row titles for better spacing
- Fixed TrackRow artwork - now tries track image first, then album
- Dynamic height calculation adapts to number of enabled rows

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 11:18:22 +00:00
Your NameandClaude Opus 4.5 2fe78556e5 Fix home screen scroll - remove extra bottom spacing
The 3 main rows are calculated to fit exactly in available space.
Only add bottom spacing when favorites rows are enabled (they scroll).
Also reduced spacing between rows from 21+3 to 8+8.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 11:00:45 +00:00
Your NameandClaude Opus 4.5 82d0bb5c2d Add optional favorites rows to home screen
Settings toggles allow users to show Favorite Albums, Favorite Artists,
and Favorite Tracks rows on the home screen. These appear below the
main scaled section (Recently Played, Discover Artists, Discover Albums)
and use fixed heights, requiring scroll on any screen when enabled.

Changes:
- SettingsService: Added getters/setters for favorite row toggles
- SettingsScreen: Added Home Screen section with 3 toggle switches
- MusicAssistantProvider: Added getFavoriteAlbums/Artists/Tracks methods
- TrackRow: New widget for horizontal track browsing
- NewHomeScreen: Shows optional favorites rows based on settings

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 10:51:13 +00:00
Your NameandClaude Opus 4.5 add137c56c Fix artist card stretched images - ensure proper circles
Use LayoutBuilder to constrain the artist image to the smaller
of width/height, ensuring it's always a perfect circle regardless
of the container's aspect ratio.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 10:48:34 +00:00
Your NameandClaude Opus 4.5 3df1b9c756 Add adaptive home screen scaling for different aspect ratios
Home screen now scales proportionally to fit different screen heights
without requiring scrolling. On taller screens (like Pixel 9 Pro Fold),
rows are larger. On shorter/wider screens, rows scale down to fit.

Changes:
- AlbumRow/ArtistRow: Added rowHeight parameter for dynamic sizing
- ArtistCard: Made image size flexible with Expanded + AspectRatio
- NewHomeScreen: Uses LayoutBuilder to calculate available space and
  distribute it proportionally between the 3 rows

Row heights are clamped to min/max values to ensure usability on
extreme aspect ratios.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 10:38:44 +00:00
18 changed files with 1190 additions and 286 deletions
@@ -834,6 +834,28 @@ class MusicAssistantProvider with ChangeNotifier {
_cacheService.invalidateHomeCache();
}
// ============================================================================
// FAVORITES FOR HOME SCREEN
// ============================================================================
/// Get favorite albums from the library
Future<List<Album>> getFavoriteAlbums() async {
// Filter from loaded library - favorites are already loaded
return _albums.where((a) => a.favorite == true).toList();
}
/// Get favorite artists from the library
Future<List<Artist>> getFavoriteArtists() async {
// Filter from loaded library - favorites are already loaded
return _artists.where((a) => a.favorite == true).toList();
}
/// Get favorite tracks from the library
Future<List<Track>> getFavoriteTracks() async {
// Filter from loaded library - favorites are already loaded
return _tracks.where((t) => t.favorite == true).toList();
}
// ============================================================================
// DETAIL SCREEN CACHING
// ============================================================================
+2
View File
@@ -433,6 +433,8 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
Color _getLevelColor(LogLevel level) {
switch (level) {
case LogLevel.perf:
return Colors.purple;
case LogLevel.debug:
return Colors.grey;
case LogLevel.info:
+2
View File
@@ -67,6 +67,8 @@ class LibraryAlbumsScreen extends StatelessWidget {
child: GridView.builder(
key: const PageStorageKey<String>('library_albums_full_grid'),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Cards have their own keep-alive
addRepaintBoundaries: false, // Cards have RepaintBoundary
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.75,
+2
View File
@@ -69,6 +69,8 @@ class LibraryArtistsScreen extends StatelessWidget {
child: ListView.builder(
key: const PageStorageKey<String>('library_artists_full_list'),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Tiles have their own keep-alive
addRepaintBoundaries: false, // Tiles have RepaintBoundary
itemCount: artists.length,
padding: const EdgeInsets.all(8),
itemBuilder: (context, index) {
+6 -2
View File
@@ -91,6 +91,9 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
child: ListView.builder(
itemCount: _playlists.length,
padding: const EdgeInsets.all(8),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
itemBuilder: (context, index) {
final playlist = _playlists[index];
return _buildPlaylistTile(context, playlist, provider);
@@ -104,8 +107,9 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Builder(
builder: (context) => ListTile(
return RepaintBoundary(
child: ListTile(
key: ValueKey(playlist.itemId),
leading: Container(
width: 48,
height: 48,
+39 -33
View File
@@ -54,6 +54,9 @@ class LibraryTracksScreen extends StatelessWidget {
return ListView.builder(
itemCount: provider.tracks.length,
padding: const EdgeInsets.all(8),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
itemBuilder: (context, index) {
final track = provider.tracks[index];
return _buildTrackTile(context, track, provider, index);
@@ -73,43 +76,46 @@ class LibraryTracksScreen extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return ListTile(
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
image: imageUrl != null
? DecorationImage(
image: CachedNetworkImageProvider(imageUrl),
fit: BoxFit.cover,
)
return RepaintBoundary(
child: ListTile(
key: ValueKey(track.uri ?? track.itemId),
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
image: imageUrl != null
? DecorationImage(
image: CachedNetworkImageProvider(imageUrl),
fit: BoxFit.cover,
)
: null,
),
child: imageUrl == null
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
child: imageUrl == null
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
title: Text(
track.name,
style: textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
track.artistsString,
style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: track.duration != null
? Text(
_formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.54)),
)
: null,
onTap: () => _playTrack(context, maProvider, index),
),
title: Text(
track.name,
style: textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
track.artistsString,
style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: track.duration != null
? Text(
_formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.54)),
)
: null,
onTap: () => _playTrack(context, maProvider, index),
);
}
+177 -34
View File
@@ -1,10 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
import '../providers/music_assistant_provider.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_selector.dart';
import '../widgets/album_row.dart';
import '../widgets/artist_row.dart';
import '../widgets/track_row.dart';
import '../widgets/common/disconnected_state.dart';
import 'settings_screen.dart';
import 'search_screen.dart';
@@ -16,17 +20,73 @@ class NewHomeScreen extends StatefulWidget {
State<NewHomeScreen> createState() => _NewHomeScreenState();
}
class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin {
class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
static final _logger = DebugLogger();
Key _refreshKey = UniqueKey();
// Main rows (default on)
bool _showRecentAlbums = true;
bool _showDiscoverArtists = true;
bool _showDiscoverAlbums = true;
// Favorites rows (default off)
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
bool _showFavoriteTracks = false;
// Random order for favorites (generated once per session)
late List<int> _favoritesOrder;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Generate random order for favorites rows (0, 1, 2 shuffled)
_favoritesOrder = [0, 1, 2]..shuffle();
_loadSettings();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Reload settings when app resumes (coming back from settings)
if (state == AppLifecycleState.resumed) {
_loadSettings();
}
}
Future<void> _loadSettings() async {
final showRecent = await SettingsService.getShowRecentAlbums();
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
_showDiscoverArtists = showDiscArtists;
_showDiscoverAlbums = showDiscAlbums;
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
});
}
}
Future<void> _onRefresh() async {
// Invalidate cache to force fresh data on pull-to-refresh
final provider = context.read<MusicAssistantProvider>();
provider.invalidateHomeCache();
// Reload settings in case they changed
await _loadSettings();
if (mounted) {
setState(() {
_refreshKey = UniqueKey();
@@ -37,6 +97,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
@override
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
// Settings are loaded in initState and didChangeAppLifecycleState - no need to reload on every build
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
@@ -96,44 +157,126 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
Widget _buildConnectedView(
BuildContext context, MusicAssistantProvider provider) {
// Use Android 12+ stretch overscroll effect
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
key: _refreshKey,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Recently played albums (with caching)
// Stable keys prevent widget recreation on parent rebuilds
AlbumRow(
key: const ValueKey('recent-albums'),
title: 'Recently Played',
loadAlbums: () => provider.getRecentAlbumsWithCache(),
),
const SizedBox(height: 21),
// Use LayoutBuilder to get available screen height
return LayoutBuilder(
builder: (context, constraints) {
// Simple calculation: available height divided by 3 rows
// Each row includes its title, artwork, and info
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
final rowHeight = availableHeight / 3;
// Discover Artists (with caching)
ArtistRow(
key: const ValueKey('discover-artists'),
title: 'Discover Artists',
loadArtists: () => provider.getDiscoverArtistsWithCache(),
),
const SizedBox(height: 3),
// Build favorites rows in random order
final favoritesWidgets = _buildFavoritesRows(provider, rowHeight);
// Discover Albums (with caching)
AlbumRow(
key: const ValueKey('discover-albums'),
title: 'Discover Albums',
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
// Use Android 12+ stretch overscroll effect
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollStartNotification) {
_logger.resetFrameStats();
_logger.perf('SCROLL START', context: 'HomeScreen');
} else if (notification is ScrollUpdateNotification) {
_logger.startFrame();
SchedulerBinding.instance.addPostFrameCallback((_) {
_logger.endFrame();
});
} else if (notification is ScrollEndNotification) {
_logger.perf('SCROLL END', context: 'HomeScreen');
}
return false;
},
child: ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.only(bottom: BottomSpacing.withMiniPlayer),
child: Column(
key: _refreshKey,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Recently played albums (optional)
if (_showRecentAlbums)
AlbumRow(
key: const ValueKey('recent-albums'),
title: 'Recently Played',
loadAlbums: () => provider.getRecentAlbumsWithCache(),
rowHeight: rowHeight,
),
// Discover Artists (optional)
if (_showDiscoverArtists)
ArtistRow(
key: const ValueKey('discover-artists'),
title: 'Discover Artists',
loadArtists: () => provider.getDiscoverArtistsWithCache(),
rowHeight: rowHeight,
),
// Discover Albums (optional)
if (_showDiscoverAlbums)
AlbumRow(
key: const ValueKey('discover-albums'),
title: 'Discover Albums',
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
rowHeight: rowHeight,
),
// Favorites rows in random order
...favoritesWidgets,
],
),
),
),
SizedBox(height: BottomSpacing.withMiniPlayer), // Space for bottom nav + mini player
],
),
),
);
},
);
}
/// Build favorites rows in random order (order set once per session)
List<Widget> _buildFavoritesRows(MusicAssistantProvider provider, double rowHeight) {
// Create list of enabled favorites with their order index
final enabledFavorites = <MapEntry<int, Widget>>[];
if (_showFavoriteAlbums) {
enabledFavorites.add(MapEntry(
_favoritesOrder[0],
AlbumRow(
key: const ValueKey('favorite-albums'),
title: 'Favorite Albums',
loadAlbums: () => provider.getFavoriteAlbums(),
rowHeight: rowHeight,
),
));
}
if (_showFavoriteArtists) {
enabledFavorites.add(MapEntry(
_favoritesOrder[1],
ArtistRow(
key: const ValueKey('favorite-artists'),
title: 'Favorite Artists',
loadArtists: () => provider.getFavoriteArtists(),
rowHeight: rowHeight,
),
));
}
if (_showFavoriteTracks) {
enabledFavorites.add(MapEntry(
_favoritesOrder[2],
TrackRow(
key: const ValueKey('favorite-tracks'),
title: 'Favorite Tracks',
loadTracks: () => provider.getFavoriteTracks(),
rowHeight: rowHeight,
),
));
}
// Sort by the random order index
enabledFavorites.sort((a, b) => a.key.compareTo(b.key));
return enabledFavorites.map((e) => e.value).toList();
}
}
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect
+33 -19
View File
@@ -289,6 +289,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: ListView.builder(
key: PageStorageKey<String>('library_artists_list_${_showFavoritesOnly ? 'fav' : 'all'}'),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Tiles have their own keep-alive
addRepaintBoundaries: false, // Tiles have RepaintBoundary
itemCount: artists.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
itemBuilder: (context, index) {
@@ -395,6 +397,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: GridView.builder(
key: PageStorageKey<String>('library_albums_grid_${_showFavoritesOnly ? 'fav' : 'all'}'),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Cards have their own keep-alive
addRepaintBoundaries: false, // Cards have RepaintBoundary
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
@@ -443,6 +447,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: ListView.builder(
key: PageStorageKey<String>('library_playlists_list_${_showFavoritesOnly ? 'fav' : 'all'}'),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
itemCount: _playlists.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
itemBuilder: (context, index) {
@@ -459,8 +465,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return ListTile(
leading: Container(
return RepaintBoundary(
child: ListTile(
key: ValueKey(playlist.itemId),
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
@@ -491,21 +499,22 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: playlist.favorite == true
? const Icon(Icons.favorite, color: Colors.red, size: 20)
: null,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PlaylistDetailsScreen(
playlist: playlist,
provider: playlist.provider,
itemId: playlist.itemId,
trailing: playlist.favorite == true
? const Icon(Icons.favorite, color: Colors.red, size: 20)
: null,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PlaylistDetailsScreen(
playlist: playlist,
provider: playlist.provider,
itemId: playlist.itemId,
),
),
),
);
},
);
},
),
);
}
@@ -540,6 +549,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: ListView.builder(
key: const PageStorageKey<String>('library_tracks_list'),
cacheExtent: 500,
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
itemCount: sortedTracks.length,
itemBuilder: (context, index) {
@@ -558,9 +569,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// 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(
return RepaintBoundary(
child: ListTile(
key: ValueKey(track.uri ?? track.itemId),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
@@ -619,6 +632,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
}
},
),
);
}
}
+42 -30
View File
@@ -299,6 +299,9 @@ class SearchScreenState extends State<SearchScreen> {
final listItems = _buildListItems(artists, albums, tracks);
return ListView.builder(
padding: EdgeInsets.fromLTRB(16, 8, 16, BottomSpacing.navBarOnly),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
itemCount: listItems.length,
itemBuilder: (context, index) {
final item = listItems[index];
@@ -407,7 +410,9 @@ class SearchScreenState extends State<SearchScreen> {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(artist, size: 256);
return ListTile(
return RepaintBoundary(
child: ListTile(
key: ValueKey(artist.uri ?? artist.itemId),
leading: ArtistAvatar(
artist: artist,
radius: 24,
@@ -426,17 +431,18 @@ class SearchScreenState extends State<SearchScreen> {
'Artist',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ArtistDetailsScreen(
artist: artist,
initialImageUrl: imageUrl,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ArtistDetailsScreen(
artist: artist,
initialImageUrl: imageUrl,
),
),
),
);
},
);
},
),
);
}
@@ -445,8 +451,10 @@ class SearchScreenState extends State<SearchScreen> {
final imageUrl = maProvider.getImageUrl(album, size: 128);
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
leading: Container(
return RepaintBoundary(
child: ListTile(
key: ValueKey(album.uri ?? album.itemId),
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
@@ -478,14 +486,15 @@ class SearchScreenState extends State<SearchScreen> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AlbumDetailsScreen(album: album),
),
);
},
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AlbumDetailsScreen(album: album),
),
);
},
),
);
}
@@ -496,8 +505,10 @@ class SearchScreenState extends State<SearchScreen> {
: null;
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
leading: Container(
return RepaintBoundary(
child: ListTile(
key: ValueKey(track.uri ?? track.itemId),
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
@@ -529,13 +540,14 @@ class SearchScreenState extends State<SearchScreen> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: track.duration != null
? Text(
_formatDuration(track.duration!),
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12),
)
: null,
onTap: () => _playTrack(track),
trailing: track.duration != null
? Text(
_formatDuration(track.duration!),
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12),
)
: null,
onTap: () => _playTrack(track),
),
);
}
+187
View File
@@ -18,6 +18,14 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
final _lastFmApiKeyController = TextEditingController();
final _audioDbApiKeyController = TextEditingController();
// Main rows (default on)
bool _showRecentAlbums = true;
bool _showDiscoverArtists = true;
bool _showDiscoverAlbums = true;
// Favorites rows (default off)
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
bool _showFavoriteTracks = false;
@override
void initState() {
@@ -35,6 +43,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (audioDbKey != null) {
_audioDbApiKeyController.text = audioDbKey;
}
// Load home screen settings
final showRecent = await SettingsService.getShowRecentAlbums();
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
_showDiscoverArtists = showDiscArtists;
_showDiscoverAlbums = showDiscAlbums;
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
});
}
}
@override
@@ -308,6 +334,167 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32),
// Home Screen section
Text(
'Home Screen',
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Choose which rows to display on the home screen',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
),
const SizedBox(height: 16),
// Main rows section
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
SwitchListTile(
title: Text(
'Recently Played',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show recently played albums',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showRecentAlbums,
onChanged: (value) {
setState(() => _showRecentAlbums = value);
SettingsService.setShowRecentAlbums(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
SwitchListTile(
title: Text(
'Discover Artists',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show random artists to discover',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showDiscoverArtists,
onChanged: (value) {
setState(() => _showDiscoverArtists = value);
SettingsService.setShowDiscoverArtists(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
SwitchListTile(
title: Text(
'Discover Albums',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show random albums to discover',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showDiscoverAlbums,
onChanged: (value) {
setState(() => _showDiscoverAlbums = value);
SettingsService.setShowDiscoverAlbums(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
],
),
),
const SizedBox(height: 16),
Text(
'Favorites',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
),
const SizedBox(height: 8),
// Favorites rows section
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
SwitchListTile(
title: Text(
'Favorite Albums',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show a row of your favorite albums',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showFavoriteAlbums,
onChanged: (value) {
setState(() => _showFavoriteAlbums = value);
SettingsService.setShowFavoriteAlbums(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
SwitchListTile(
title: Text(
'Favorite Artists',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show a row of your favorite artists',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showFavoriteArtists,
onChanged: (value) {
setState(() => _showFavoriteArtists = value);
SettingsService.setShowFavoriteArtists(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
SwitchListTile(
title: Text(
'Favorite Tracks',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show a row of your favorite tracks',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showFavoriteTracks,
onChanged: (value) {
setState(() => _showFavoriteTracks = value);
SettingsService.setShowFavoriteTracks(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
],
),
),
const SizedBox(height: 32),
Text(
'Metadata APIs (Optional)',
style: textTheme.titleMedium?.copyWith(
+58
View File
@@ -5,6 +5,7 @@ import 'package:device_info_plus/device_info_plus.dart';
/// Log levels for filtering and display
enum LogLevel {
perf, // Performance timing (for FPS diagnosis)
debug, // Verbose debugging info (hidden by default)
info, // General information
warning, // Potential issues
@@ -48,7 +49,16 @@ class DebugLogger {
/// Legacy getter for backward compatibility
List<String> get logs => _entries.map((e) => e.formatted).toList();
// Set to true to only log perf entries (for FPS diagnosis)
// Set to false for normal operation with full logging for user bug reports
static const bool _perfOnlyMode = false;
void _addEntry(LogLevel level, String message, {String? context}) {
// In perf-only mode, skip all non-perf logs
if (_perfOnlyMode && level != LogLevel.perf) {
return;
}
final entry = LogEntry(
timestamp: DateTime.now(),
level: level,
@@ -100,6 +110,54 @@ class DebugLogger {
_addEntry(LogLevel.info, message);
}
/// Performance logging - always prints to console for real-time monitoring
void perf(String message, {String? context}) {
_addEntry(LogLevel.perf, message, context: context);
}
/// Track build time of a widget
final Map<String, Stopwatch> _buildTimers = {};
void startBuild(String widgetName) {
_buildTimers[widgetName] = Stopwatch()..start();
}
void endBuild(String widgetName) {
final sw = _buildTimers.remove(widgetName);
if (sw != null) {
sw.stop();
if (sw.elapsedMilliseconds > 4) { // Only log if > 4ms (slow for 60fps)
perf('BUILD ${sw.elapsedMilliseconds}ms', context: widgetName);
}
}
}
/// Track frame times during scroll
Stopwatch? _frameTimer;
int _slowFrameCount = 0;
int _totalFrameCount = 0;
void startFrame() {
_frameTimer = Stopwatch()..start();
}
void endFrame() {
if (_frameTimer != null) {
_frameTimer!.stop();
_totalFrameCount++;
final ms = _frameTimer!.elapsedMilliseconds;
if (ms > 16) { // > 16ms means dropped frame at 60fps
_slowFrameCount++;
perf('SLOW FRAME ${ms}ms (${_slowFrameCount}/${_totalFrameCount} slow)', context: 'Scroll');
}
}
}
void resetFrameStats() {
_slowFrameCount = 0;
_totalFrameCount = 0;
}
void clear() {
_entries.clear();
}
+68
View File
@@ -21,6 +21,12 @@ class SettingsService {
static const String _keyLocalPlayerName = 'local_player_name';
static const String _keyOwnerName = 'owner_name';
static const String _keyLastSelectedPlayerId = 'last_selected_player_id';
static const String _keyShowRecentAlbums = 'show_recent_albums';
static const String _keyShowDiscoverArtists = 'show_discover_artists';
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
static const String _keyShowFavoriteAlbums = 'show_favorite_albums';
static const String _keyShowFavoriteArtists = 'show_favorite_artists';
static const String _keyShowFavoriteTracks = 'show_favorite_tracks';
static Future<String?> getServerUrl() async {
final prefs = await SharedPreferences.getInstance();
@@ -313,4 +319,66 @@ class SettingsService {
final prefs = await SharedPreferences.getInstance();
await prefs.clear();
}
// Home Screen Row Settings (Main rows - default on)
static Future<bool> getShowRecentAlbums() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowRecentAlbums) ?? true;
}
static Future<void> setShowRecentAlbums(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowRecentAlbums, show);
}
static Future<bool> getShowDiscoverArtists() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowDiscoverArtists) ?? true;
}
static Future<void> setShowDiscoverArtists(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowDiscoverArtists, show);
}
static Future<bool> getShowDiscoverAlbums() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowDiscoverAlbums) ?? true;
}
static Future<void> setShowDiscoverAlbums(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowDiscoverAlbums, show);
}
// Home Screen Favorites Settings (default off)
static Future<bool> getShowFavoriteAlbums() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowFavoriteAlbums) ?? false;
}
static Future<void> setShowFavoriteAlbums(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoriteAlbums, show);
}
static Future<bool> getShowFavoriteArtists() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowFavoriteArtists) ?? false;
}
static Future<void> setShowFavoriteArtists(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoriteArtists, show);
}
static Future<bool> getShowFavoriteTracks() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowFavoriteTracks) ?? false;
}
static Future<void> setShowFavoriteTracks(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoriteTracks, show);
}
}
+84 -57
View File
@@ -2,18 +2,21 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import 'album_card.dart';
class AlbumRow extends StatefulWidget {
final String title;
final Future<List<Album>> Function() loadAlbums;
final String? heroTagSuffix;
final double? rowHeight;
const AlbumRow({
super.key,
required this.title,
required this.loadAlbums,
this.heroTagSuffix,
this.rowHeight,
});
@override
@@ -44,79 +47,103 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
}
}
static final _logger = DebugLogger();
@override
Widget build(BuildContext context) {
_logger.startBuild('AlbumRow:${widget.title}');
super.build(context); // Required for AutomaticKeepAliveClientMixin
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Text(
widget.title,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
// Total row height includes title + content
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
child: SizedBox(
height: totalHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Text(
widget.title,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
),
),
),
),
SizedBox(
height: 193,
child: FutureBuilder<List<Album>>(
future: _albumsFuture,
builder: (context, snapshot) {
// Use cached data immediately if available (prevents flash on rebuild)
final albums = snapshot.data ?? _cachedAlbums;
Expanded(
child: FutureBuilder<List<Album>>(
future: _albumsFuture,
builder: (context, snapshot) {
// Use cached data immediately if available (prevents flash on rebuild)
final albums = snapshot.data ?? _cachedAlbums;
// Only show loading if we have no data at all
if (albums == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
// Only show loading if we have no data at all
if (albums == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && albums == null) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
if (snapshot.hasError && albums == null) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
if (albums == null || albums.isEmpty) {
return Center(
child: Text(
'No albums found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
if (albums == null || albums.isEmpty) {
return Center(
child: Text(
'No albums found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: albums.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final album = albums[index];
return Container(
key: ValueKey(album.uri ?? album.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: AlbumCard(
album: album,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
),
);
}
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: albums.length,
itemExtent: 162, // 150px width + 12px horizontal margins
itemBuilder: (context, index) {
final album = albums[index];
return Container(
key: ValueKey(album.uri ?? album.itemId),
width: 150,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: AlbumCard(
album: album,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
),
);
},
},
),
),
],
),
],
),
);
_logger.endBuild('AlbumRow:${widget.title}');
return result;
}
}
+81 -43
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../screens/artist_details_screen.dart';
@@ -13,12 +14,14 @@ class ArtistCard extends StatefulWidget {
final Artist artist;
final VoidCallback? onTap;
final String? heroTagSuffix;
final double? imageSize;
const ArtistCard({
super.key,
required this.artist,
this.onTap,
this.heroTagSuffix,
this.imageSize,
});
@override
@@ -29,11 +32,36 @@ class _ArtistCardState extends State<ArtistCard> {
static final _logger = DebugLogger();
String? _fallbackImageUrl;
bool _triedFallback = false;
String? _cachedMaImageUrl;
@override
void initState() {
super.initState();
// Fetch fallback image once in initState, not during build
_initFallbackImage();
}
void _initFallbackImage() {
// We'll check if MA has an image after first build, then fetch fallback if needed
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final maProvider = context.read<MusicAssistantProvider>();
final maImageUrl = maProvider.api?.getImageUrl(widget.artist, size: 256);
_cachedMaImageUrl = maImageUrl;
if (maImageUrl == null && !_triedFallback) {
_triedFallback = true;
_logger.debug('No MA image for "${widget.artist.name}", trying fallback', context: 'ArtistCard');
_fetchFallbackImage();
}
});
}
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final maImageUrl = maProvider.api?.getImageUrl(widget.artist, size: 256);
// Use cached URL if available, otherwise get fresh (but don't trigger fetches during build)
final maImageUrl = _cachedMaImageUrl ?? maProvider.api?.getImageUrl(widget.artist, size: 256);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
@@ -42,13 +70,6 @@ class _ArtistCardState extends State<ArtistCard> {
// Use MA image if available, otherwise try fallback
final imageUrl = maImageUrl ?? _fallbackImageUrl;
// Try to fetch fallback image if MA doesn't have one and we haven't tried yet
if (maImageUrl == null && !_triedFallback) {
_triedFallback = true;
_logger.debug('No MA image for "${widget.artist.name}", trying fallback', context: 'ArtistCard');
_fetchFallbackImage();
}
return RepaintBoundary(
child: GestureDetector(
onTap: widget.onTap ?? () {
@@ -68,44 +89,61 @@ class _ArtistCardState extends State<ArtistCard> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Artist image - circular
Hero(
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
child: ClipOval(
child: Container(
width: 110,
height: 110,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? Image.network(
imageUrl,
fit: BoxFit.cover,
cacheWidth: 256,
cacheHeight: 256,
errorBuilder: (_, __, ___) => Icon(
Icons.person_rounded,
size: 60,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(Icons.person_rounded, size: 60, color: colorScheme.onSurfaceVariant),
// Artist image - circular, constrained to be a proper circle
Expanded(
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
// Use the smaller dimension to ensure a circle
final size = constraints.maxWidth < constraints.maxHeight
? constraints.maxWidth
: constraints.maxHeight;
return Hero(
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
child: ClipOval(
child: Container(
width: size,
height: size,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: const Duration(milliseconds: 150),
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Icon(
Icons.person_rounded,
size: size * 0.55,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(Icons.person_rounded, size: size * 0.55, color: colorScheme.onSurfaceVariant),
),
),
);
},
),
),
),
const SizedBox(height: 12),
// Artist name
Hero(
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
widget.artist.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
const SizedBox(height: 8),
// Artist name - fixed height container so image size is consistent
SizedBox(
height: 36, // Fixed height for 2 lines of text
child: Hero(
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
widget.artist.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
),
+84 -57
View File
@@ -2,18 +2,21 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import 'artist_card.dart';
class ArtistRow extends StatefulWidget {
final String title;
final Future<List<Artist>> Function() loadArtists;
final String? heroTagSuffix;
final double? rowHeight;
const ArtistRow({
super.key,
required this.title,
required this.loadArtists,
this.heroTagSuffix,
this.rowHeight,
});
@override
@@ -44,79 +47,103 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
}
}
static final _logger = DebugLogger();
@override
Widget build(BuildContext context) {
_logger.startBuild('ArtistRow:${widget.title}');
super.build(context); // Required for AutomaticKeepAliveClientMixin
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Text(
widget.title,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
// Total row height includes title + content
final totalHeight = widget.rowHeight ?? 207.0; // Default: 44 title + 163 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
child: SizedBox(
height: totalHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Text(
widget.title,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
),
),
),
),
SizedBox(
height: 163,
child: FutureBuilder<List<Artist>>(
future: _artistsFuture,
builder: (context, snapshot) {
// Use cached data immediately if available (prevents flash on rebuild)
final artists = snapshot.data ?? _cachedArtists;
Expanded(
child: FutureBuilder<List<Artist>>(
future: _artistsFuture,
builder: (context, snapshot) {
// Use cached data immediately if available (prevents flash on rebuild)
final artists = snapshot.data ?? _cachedArtists;
// Only show loading if we have no data at all
if (artists == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
// Only show loading if we have no data at all
if (artists == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && artists == null) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
if (snapshot.hasError && artists == null) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
if (artists == null || artists.isEmpty) {
return Center(
child: Text(
'No artists found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
if (artists == null || artists.isEmpty) {
return Center(
child: Text(
'No artists found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: circle image + name below
// Text area: 8px gap + ~36px for 2-line name = ~44px
const textAreaHeight = 44.0;
final imageSize = contentHeight - textAreaHeight;
final cardWidth = imageSize; // Card width = image width (circle)
final itemExtent = cardWidth + 16; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: artists.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final artist = artists[index];
return Container(
key: ValueKey(artist.uri ?? artist.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: ArtistCard(
artist: artist,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
),
);
}
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: artists.length,
itemExtent: 136, // 120px width + 16px horizontal margins
itemBuilder: (context, index) {
final artist = artists[index];
return Container(
key: ValueKey(artist.uri ?? artist.itemId),
width: 120,
margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: ArtistCard(
artist: artist,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
),
);
},
},
),
),
],
),
],
),
);
_logger.endBuild('ArtistRow:${widget.title}');
return result;
}
}
+38 -10
View File
@@ -5,6 +5,40 @@ import '../providers/navigation_provider.dart';
import '../theme/theme_provider.dart';
import 'expandable_player.dart';
/// Cached color with contrast adjustment
/// Avoids expensive HSL conversions during scroll
class _CachedNavColor {
Color? _sourceColor;
bool? _isDark;
Color? _adjustedColor;
Color getAdjustedColor(Color sourceColor, bool isDark) {
// Return cached value if inputs haven't changed
if (_sourceColor == sourceColor && _isDark == isDark && _adjustedColor != null) {
return _adjustedColor!;
}
// Compute new adjusted color
var navSelectedColor = sourceColor;
if (isDark && navSelectedColor.computeLuminance() < 0.2) {
final hsl = HSLColor.fromColor(navSelectedColor);
navSelectedColor = hsl.withLightness((hsl.lightness + 0.3).clamp(0.0, 0.8)).toColor();
} else if (!isDark && navSelectedColor.computeLuminance() > 0.8) {
final hsl = HSLColor.fromColor(navSelectedColor);
navSelectedColor = hsl.withLightness((hsl.lightness - 0.3).clamp(0.2, 1.0)).toColor();
}
// Cache the result
_sourceColor = sourceColor;
_isDark = isDark;
_adjustedColor = navSelectedColor;
return navSelectedColor;
}
}
final _cachedNavColor = _CachedNavColor();
/// A global key to access the player state from anywhere in the app
final globalPlayerKey = GlobalKey<ExpandablePlayerState>();
@@ -23,7 +57,7 @@ class BottomSpacing {
static const double navBarOnly = navBarHeight + 16.0;
/// Space needed when mini player is also visible
static const double withMiniPlayer = navBarHeight + miniPlayerHeight + 14.0;
static const double withMiniPlayer = navBarHeight + miniPlayerHeight + 22.0;
}
/// ValueNotifier for player expansion progress (0.0 to 1.0) and background color
@@ -135,19 +169,13 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
return Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
// Use adaptive primary color for bottom nav when adaptive theme is enabled
var navSelectedColor = themeProvider.adaptiveTheme
final sourceColor = themeProvider.adaptiveTheme
? themeProvider.adaptivePrimaryColor
: colorScheme.primary;
// Ensure nav color has sufficient contrast against the surface background
// Use cached color computation to avoid expensive HSL operations during scroll
final isDark = Theme.of(context).brightness == Brightness.dark;
if (isDark && navSelectedColor.computeLuminance() < 0.2) {
final hsl = HSLColor.fromColor(navSelectedColor);
navSelectedColor = hsl.withLightness((hsl.lightness + 0.3).clamp(0.0, 0.8)).toColor();
} else if (!isDark && navSelectedColor.computeLuminance() > 0.8) {
final hsl = HSLColor.fromColor(navSelectedColor);
navSelectedColor = hsl.withLightness((hsl.lightness - 0.3).clamp(0.2, 1.0)).toColor();
}
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
// Base background: use adaptive surface color if available, otherwise default surface
final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null)
+5 -1
View File
@@ -89,6 +89,8 @@ class QueuePanel extends StatelessWidget {
return ListView.builder(
padding: Spacing.paddingH8,
cacheExtent: 500,
addAutomaticKeepAlives: false, // Items don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
@@ -96,7 +98,8 @@ class QueuePanel extends StatelessWidget {
final isPastItem = index < currentIndex;
final imageUrl = maProvider.api?.getImageUrl(item.track, size: 80);
return Opacity(
return RepaintBoundary(
child: Opacity(
opacity: isPastItem ? 0.5 : 1.0,
child: Container(
margin: EdgeInsets.symmetric(vertical: Spacing.xxs),
@@ -162,6 +165,7 @@ class QueuePanel extends StatelessWidget {
},
),
),
),
);
},
);
+260
View File
@@ -0,0 +1,260 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
class TrackRow extends StatefulWidget {
final String title;
final Future<List<Track>> Function() loadTracks;
final double? rowHeight;
const TrackRow({
super.key,
required this.title,
required this.loadTracks,
this.rowHeight,
});
@override
State<TrackRow> createState() => _TrackRowState();
}
class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin {
late Future<List<Track>> _tracksFuture;
List<Track>? _cachedTracks;
bool _hasLoaded = false;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_loadTracksOnce();
}
void _loadTracksOnce() {
if (!_hasLoaded) {
_tracksFuture = widget.loadTracks().then((tracks) {
_cachedTracks = tracks;
return tracks;
});
_hasLoaded = true;
}
}
static final _logger = DebugLogger();
@override
Widget build(BuildContext context) {
_logger.startBuild('TrackRow:${widget.title}');
super.build(context);
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
// Total row height includes title + content
final totalHeight = widget.rowHeight ?? 224.0; // Default: 44 title + 180 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
child: SizedBox(
height: totalHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Text(
widget.title,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
),
),
),
Expanded(
child: FutureBuilder<List<Track>>(
future: _tracksFuture,
builder: (context, snapshot) {
final tracks = snapshot.data ?? _cachedTracks;
if (tracks == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && tracks == null) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
if (tracks == null || tracks.isEmpty) {
return Center(
child: Text(
'No tracks found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12;
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: tracks.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final track = tracks[index];
return Container(
key: ValueKey(track.uri ?? track.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: _TrackCard(
track: track,
tracks: tracks,
index: index,
),
);
},
),
);
},
),
),
],
),
),
);
_logger.endBuild('TrackRow:${widget.title}');
return result;
}
}
class _TrackCard extends StatelessWidget {
final Track track;
final List<Track> tracks;
final int index;
const _TrackCard({
required this.track,
required this.tracks,
required this.index,
});
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
// Try track image first, then album image
final trackImageUrl = maProvider.getImageUrl(track, size: 256);
final albumImageUrl = track.album != null
? maProvider.getImageUrl(track.album!, size: 256)
: null;
final imageUrl = trackImageUrl ?? albumImageUrl;
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return RepaintBoundary(
child: GestureDetector(
onTap: () => _playTrack(context, maProvider),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Album artwork
AspectRatio(
aspectRatio: 1.0,
child: ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: const Duration(milliseconds: 150),
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Icon(
Icons.music_note_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
)
: Center(
child: Icon(
Icons.music_note_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
const SizedBox(height: 8),
// Track title
Text(
track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
// Artist name
Text(
track.artistsString,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
);
}
Future<void> _playTrack(BuildContext context, MusicAssistantProvider maProvider) async {
if (maProvider.selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No player selected')),
);
return;
}
await maProvider.playTracks(
maProvider.selectedPlayer!.playerId,
tracks,
startIndex: index,
);
}
}
class _StretchScrollBehavior extends ScrollBehavior {
const _StretchScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context, Widget child, ScrollableDetails details) {
return StretchingOverscrollIndicator(
axisDirection: details.direction,
child: child,
);
}
}