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
Your NameandClaude Opus 4.5 3852e83a3c Fix artist hero animation grey image during transition
The artist image was turning grey during the hero animation because
ArtistDetailsScreen was deferring image loading until after the 350ms
transition completed. This caused the destination hero widget to show
an empty container instead of the image.

Fix: Pass initialImageUrl from source screens to ArtistDetailsScreen
so the hero has the same image content from the start of the animation.
The higher-res image and color extraction still happen after transition.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 08:21:58 +00:00
20 changed files with 1216 additions and 291 deletions
@@ -834,6 +834,28 @@ class MusicAssistantProvider with ChangeNotifier {
_cacheService.invalidateHomeCache(); _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 // DETAIL SCREEN CACHING
// ============================================================================ // ============================================================================
+7 -1
View File
@@ -500,10 +500,16 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
void _navigateToArtist() { void _navigateToArtist() {
// Navigate to the first artist if available // Navigate to the first artist if available
if (widget.album.artists != null && widget.album.artists!.isNotEmpty) { if (widget.album.artists != null && widget.album.artists!.isNotEmpty) {
final artist = widget.album.artists!.first;
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(artist, size: 256);
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => ArtistDetailsScreen(artist: widget.album.artists!.first), builder: (context) => ArtistDetailsScreen(
artist: artist,
initialImageUrl: imageUrl,
),
), ),
); );
} }
+6 -2
View File
@@ -15,11 +15,13 @@ import '../utils/page_transitions.dart';
class ArtistDetailsScreen extends StatefulWidget { class ArtistDetailsScreen extends StatefulWidget {
final Artist artist; final Artist artist;
final String? heroTagSuffix; final String? heroTagSuffix;
final String? initialImageUrl;
const ArtistDetailsScreen({ const ArtistDetailsScreen({
super.key, super.key,
required this.artist, required this.artist,
this.heroTagSuffix, this.heroTagSuffix,
this.initialImageUrl,
}); });
@override @override
@@ -44,12 +46,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_isFavorite = widget.artist.favorite ?? false; _isFavorite = widget.artist.favorite ?? false;
// Use initial image URL immediately for smooth hero animation
_artistImageUrl = widget.initialImageUrl;
_loadArtistAlbums(); _loadArtistAlbums();
_loadArtistDescription(); _loadArtistDescription();
_refreshFavoriteStatus(); _refreshFavoriteStatus();
// CRITICAL FIX: Defer image loading and color extraction until after transition // Defer higher-res image loading and color extraction until after transition
// This prevents expensive operations during hero animation // But hero animation now works because we have initialImageUrl
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () { Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) { if (mounted) {
+2
View File
@@ -433,6 +433,8 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
Color _getLevelColor(LogLevel level) { Color _getLevelColor(LogLevel level) {
switch (level) { switch (level) {
case LogLevel.perf:
return Colors.purple;
case LogLevel.debug: case LogLevel.debug:
return Colors.grey; return Colors.grey;
case LogLevel.info: case LogLevel.info:
+2
View File
@@ -67,6 +67,8 @@ class LibraryAlbumsScreen extends StatelessWidget {
child: GridView.builder( child: GridView.builder(
key: const PageStorageKey<String>('library_albums_full_grid'), key: const PageStorageKey<String>('library_albums_full_grid'),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling 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( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisCount: 2,
childAspectRatio: 0.75, childAspectRatio: 0.75,
+6
View File
@@ -69,6 +69,8 @@ class LibraryArtistsScreen extends StatelessWidget {
child: ListView.builder( child: ListView.builder(
key: const PageStorageKey<String>('library_artists_full_list'), key: const PageStorageKey<String>('library_artists_full_list'),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling 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, itemCount: artists.length,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -90,6 +92,9 @@ class LibraryArtistsScreen extends StatelessWidget {
}) { }) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final maProvider = context.read<MusicAssistantProvider>();
// Get image URL for hero animation
final imageUrl = maProvider.getImageUrl(artist, size: 256);
// RepaintBoundary isolates repaints to individual tiles // RepaintBoundary isolates repaints to individual tiles
return RepaintBoundary( return RepaintBoundary(
@@ -116,6 +121,7 @@ class LibraryArtistsScreen extends StatelessWidget {
child: ArtistDetailsScreen( child: ArtistDetailsScreen(
artist: artist, artist: artist,
heroTagSuffix: 'library', heroTagSuffix: 'library',
initialImageUrl: imageUrl,
), ),
), ),
); );
+6 -2
View File
@@ -91,6 +91,9 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
child: ListView.builder( child: ListView.builder(
itemCount: _playlists.length, itemCount: _playlists.length,
padding: const EdgeInsets.all(8), 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) { itemBuilder: (context, index) {
final playlist = _playlists[index]; final playlist = _playlists[index];
return _buildPlaylistTile(context, playlist, provider); return _buildPlaylistTile(context, playlist, provider);
@@ -104,8 +107,9 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return Builder( return RepaintBoundary(
builder: (context) => ListTile( child: ListTile(
key: ValueKey(playlist.itemId),
leading: Container( leading: Container(
width: 48, width: 48,
height: 48, height: 48,
+7 -1
View File
@@ -54,6 +54,9 @@ class LibraryTracksScreen extends StatelessWidget {
return ListView.builder( return ListView.builder(
itemCount: provider.tracks.length, itemCount: provider.tracks.length,
padding: const EdgeInsets.all(8), 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) { itemBuilder: (context, index) {
final track = provider.tracks[index]; final track = provider.tracks[index];
return _buildTrackTile(context, track, provider, index); return _buildTrackTile(context, track, provider, index);
@@ -73,7 +76,9 @@ class LibraryTracksScreen extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return ListTile( return RepaintBoundary(
child: ListTile(
key: ValueKey(track.uri ?? track.itemId),
leading: Container( leading: Container(
width: 48, width: 48,
height: 48, height: 48,
@@ -110,6 +115,7 @@ class LibraryTracksScreen extends StatelessWidget {
) )
: null, : null,
onTap: () => _playTrack(context, maProvider, index), onTap: () => _playTrack(context, maProvider, index),
),
); );
} }
+152 -9
View File
@@ -1,10 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/music_assistant_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/global_player_overlay.dart';
import '../widgets/player_selector.dart'; import '../widgets/player_selector.dart';
import '../widgets/album_row.dart'; import '../widgets/album_row.dart';
import '../widgets/artist_row.dart'; import '../widgets/artist_row.dart';
import '../widgets/track_row.dart';
import '../widgets/common/disconnected_state.dart'; import '../widgets/common/disconnected_state.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'search_screen.dart'; import 'search_screen.dart';
@@ -16,17 +20,73 @@ class NewHomeScreen extends StatefulWidget {
State<NewHomeScreen> createState() => _NewHomeScreenState(); 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(); 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 @override
bool get wantKeepAlive => true; 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 { Future<void> _onRefresh() async {
// Invalidate cache to force fresh data on pull-to-refresh // Invalidate cache to force fresh data on pull-to-refresh
final provider = context.read<MusicAssistantProvider>(); final provider = context.read<MusicAssistantProvider>();
provider.invalidateHomeCache(); provider.invalidateHomeCache();
// Reload settings in case they changed
await _loadSettings();
if (mounted) { if (mounted) {
setState(() { setState(() {
_refreshKey = UniqueKey(); _refreshKey = UniqueKey();
@@ -37,6 +97,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin 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; final colorScheme = Theme.of(context).colorScheme;
return Scaffold( return Scaffold(
@@ -96,43 +157,125 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
Widget _buildConnectedView( Widget _buildConnectedView(
BuildContext context, MusicAssistantProvider provider) { BuildContext context, MusicAssistantProvider provider) {
// 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;
// Build favorites rows in random order
final favoritesWidgets = _buildFavoritesRows(provider, rowHeight);
// Use Android 12+ stretch overscroll effect // Use Android 12+ stretch overscroll effect
return ScrollConfiguration( 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(), behavior: const _StretchScrollBehavior(),
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.only(bottom: BottomSpacing.withMiniPlayer),
child: Column( child: Column(
key: _refreshKey, key: _refreshKey,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Recently played albums (with caching) // Recently played albums (optional)
// Stable keys prevent widget recreation on parent rebuilds if (_showRecentAlbums)
AlbumRow( AlbumRow(
key: const ValueKey('recent-albums'), key: const ValueKey('recent-albums'),
title: 'Recently Played', title: 'Recently Played',
loadAlbums: () => provider.getRecentAlbumsWithCache(), loadAlbums: () => provider.getRecentAlbumsWithCache(),
rowHeight: rowHeight,
), ),
const SizedBox(height: 21),
// Discover Artists (with caching) // Discover Artists (optional)
if (_showDiscoverArtists)
ArtistRow( ArtistRow(
key: const ValueKey('discover-artists'), key: const ValueKey('discover-artists'),
title: 'Discover Artists', title: 'Discover Artists',
loadArtists: () => provider.getDiscoverArtistsWithCache(), loadArtists: () => provider.getDiscoverArtistsWithCache(),
rowHeight: rowHeight,
), ),
const SizedBox(height: 3),
// Discover Albums (with caching) // Discover Albums (optional)
if (_showDiscoverAlbums)
AlbumRow( AlbumRow(
key: const ValueKey('discover-albums'), key: const ValueKey('discover-albums'),
title: 'Discover Albums', title: 'Discover Albums',
loadAlbums: () => provider.getDiscoverAlbumsWithCache(), loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
rowHeight: rowHeight,
), ),
SizedBox(height: BottomSpacing.withMiniPlayer), // Space for bottom nav + mini player
// Favorites rows in random order
...favoritesWidgets,
], ],
), ),
), ),
),
); );
},
);
}
/// 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();
} }
} }
+20 -5
View File
@@ -289,6 +289,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: ListView.builder( child: ListView.builder(
key: PageStorageKey<String>('library_artists_list_${_showFavoritesOnly ? 'fav' : 'all'}'), 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
addAutomaticKeepAlives: false, // Tiles have their own keep-alive
addRepaintBoundaries: false, // Tiles have RepaintBoundary
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),
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -312,7 +314,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}) { }) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final maProvider = context.read<MusicAssistantProvider>();
final suffix = '_library'; final suffix = '_library';
// Get image URL for hero animation
final imageUrl = maProvider.getImageUrl(artist, size: 256);
return RepaintBoundary( return RepaintBoundary(
child: ListTile( child: ListTile(
@@ -322,9 +327,6 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
radius: 24, radius: 24,
imageSize: 128, imageSize: 128,
heroTag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix, heroTag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
onImageLoaded: (imageUrl) {
// Store for adaptive colors on tap
},
), ),
title: Hero( title: Hero(
tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix, tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix,
@@ -348,6 +350,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: ArtistDetailsScreen( child: ArtistDetailsScreen(
artist: artist, artist: artist,
heroTagSuffix: 'library', heroTagSuffix: 'library',
initialImageUrl: imageUrl,
), ),
), ),
); );
@@ -394,6 +397,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: GridView.builder( child: GridView.builder(
key: PageStorageKey<String>('library_albums_grid_${_showFavoritesOnly ? 'fav' : 'all'}'), 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
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), padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisCount: 2,
@@ -442,6 +447,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: ListView.builder( child: ListView.builder(
key: PageStorageKey<String>('library_playlists_list_${_showFavoritesOnly ? 'fav' : 'all'}'), 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
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
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),
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -458,7 +465,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return ListTile( return RepaintBoundary(
child: ListTile(
key: ValueKey(playlist.itemId),
leading: Container( leading: Container(
width: 48, width: 48,
height: 48, height: 48,
@@ -505,6 +514,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
), ),
); );
}, },
),
); );
} }
@@ -539,6 +549,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: ListView.builder( child: ListView.builder(
key: const PageStorageKey<String>('library_tracks_list'), key: const PageStorageKey<String>('library_tracks_list'),
cacheExtent: 500, 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), padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
itemCount: sortedTracks.length, itemCount: sortedTracks.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -557,7 +569,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// Get image URL from track itself // Get image URL from track itself
final imageUrl = maProvider.api?.getImageUrl(track, size: 128); final imageUrl = maProvider.api?.getImageUrl(track, size: 128);
return ListTile( return RepaintBoundary(
child: ListTile(
key: ValueKey(track.uri ?? track.itemId),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Container( leading: Container(
width: 48, width: 48,
@@ -618,6 +632,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
} }
} }
}, },
),
); );
} }
} }
+21 -4
View File
@@ -299,6 +299,9 @@ class SearchScreenState extends State<SearchScreen> {
final listItems = _buildListItems(artists, albums, tracks); final listItems = _buildListItems(artists, albums, tracks);
return ListView.builder( return ListView.builder(
padding: EdgeInsets.fromLTRB(16, 8, 16, BottomSpacing.navBarOnly), 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, itemCount: listItems.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = listItems[index]; final item = listItems[index];
@@ -404,8 +407,12 @@ class SearchScreenState extends State<SearchScreen> {
Widget _buildArtistTile(Artist artist) { Widget _buildArtistTile(Artist artist) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
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( leading: ArtistAvatar(
artist: artist, artist: artist,
radius: 24, radius: 24,
@@ -428,10 +435,14 @@ class SearchScreenState extends State<SearchScreen> {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => ArtistDetailsScreen(artist: artist), builder: (context) => ArtistDetailsScreen(
artist: artist,
initialImageUrl: imageUrl,
),
), ),
); );
}, },
),
); );
} }
@@ -440,7 +451,9 @@ class SearchScreenState extends State<SearchScreen> {
final imageUrl = maProvider.getImageUrl(album, size: 128); final imageUrl = maProvider.getImageUrl(album, size: 128);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return ListTile( return RepaintBoundary(
child: ListTile(
key: ValueKey(album.uri ?? album.itemId),
leading: Container( leading: Container(
width: 48, width: 48,
height: 48, height: 48,
@@ -481,6 +494,7 @@ class SearchScreenState extends State<SearchScreen> {
), ),
); );
}, },
),
); );
} }
@@ -491,7 +505,9 @@ class SearchScreenState extends State<SearchScreen> {
: null; : null;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return ListTile( return RepaintBoundary(
child: ListTile(
key: ValueKey(track.uri ?? track.itemId),
leading: Container( leading: Container(
width: 48, width: 48,
height: 48, height: 48,
@@ -531,6 +547,7 @@ class SearchScreenState extends State<SearchScreen> {
) )
: null, : null,
onTap: () => _playTrack(track), onTap: () => _playTrack(track),
),
); );
} }
+187
View File
@@ -18,6 +18,14 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
final _lastFmApiKeyController = TextEditingController(); final _lastFmApiKeyController = TextEditingController();
final _audioDbApiKeyController = 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 @override
void initState() { void initState() {
@@ -35,6 +43,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (audioDbKey != null) { if (audioDbKey != null) {
_audioDbApiKeyController.text = audioDbKey; _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 @override
@@ -308,6 +334,167 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32), 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( Text(
'Metadata APIs (Optional)', 'Metadata APIs (Optional)',
style: textTheme.titleMedium?.copyWith( 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 /// Log levels for filtering and display
enum LogLevel { enum LogLevel {
perf, // Performance timing (for FPS diagnosis)
debug, // Verbose debugging info (hidden by default) debug, // Verbose debugging info (hidden by default)
info, // General information info, // General information
warning, // Potential issues warning, // Potential issues
@@ -48,7 +49,16 @@ class DebugLogger {
/// Legacy getter for backward compatibility /// Legacy getter for backward compatibility
List<String> get logs => _entries.map((e) => e.formatted).toList(); 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}) { 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( final entry = LogEntry(
timestamp: DateTime.now(), timestamp: DateTime.now(),
level: level, level: level,
@@ -100,6 +110,54 @@ class DebugLogger {
_addEntry(LogLevel.info, message); _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() { void clear() {
_entries.clear(); _entries.clear();
} }
+68
View File
@@ -21,6 +21,12 @@ class SettingsService {
static const String _keyLocalPlayerName = 'local_player_name'; static const String _keyLocalPlayerName = 'local_player_name';
static const String _keyOwnerName = 'owner_name'; static const String _keyOwnerName = 'owner_name';
static const String _keyLastSelectedPlayerId = 'last_selected_player_id'; 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 { static Future<String?> getServerUrl() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -313,4 +319,66 @@ class SettingsService {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.clear(); 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);
}
} }
+33 -6
View File
@@ -2,18 +2,21 @@ 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';
import 'album_card.dart'; import 'album_card.dart';
class AlbumRow extends StatefulWidget { class AlbumRow extends StatefulWidget {
final String title; final String title;
final Future<List<Album>> Function() loadAlbums; final Future<List<Album>> Function() loadAlbums;
final String? heroTagSuffix; final String? heroTagSuffix;
final double? rowHeight;
const AlbumRow({ const AlbumRow({
super.key, super.key,
required this.title, required this.title,
required this.loadAlbums, required this.loadAlbums,
this.heroTagSuffix, this.heroTagSuffix,
this.rowHeight,
}); });
@override @override
@@ -44,17 +47,28 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
} }
} }
static final _logger = DebugLogger();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_logger.startBuild('AlbumRow:${widget.title}');
super.build(context); // Required for AutomaticKeepAliveClientMixin super.build(context); // Required for AutomaticKeepAliveClientMixin
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Column( // 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, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Text( child: Text(
widget.title, widget.title,
style: textTheme.titleLarge?.copyWith( style: textTheme.titleLarge?.copyWith(
@@ -63,8 +77,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
), ),
), ),
), ),
SizedBox( Expanded(
height: 193,
child: FutureBuilder<List<Album>>( child: FutureBuilder<List<Album>>(
future: _albumsFuture, future: _albumsFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
@@ -91,18 +104,28 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
); );
} }
// 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( return ScrollConfiguration(
behavior: const _StretchScrollBehavior(), behavior: const _StretchScrollBehavior(),
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0), padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: albums.length, itemCount: albums.length,
itemExtent: 162, // 150px width + 12px horizontal margins 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) { itemBuilder: (context, index) {
final album = albums[index]; final album = albums[index];
return Container( return Container(
key: ValueKey(album.uri ?? album.itemId), key: ValueKey(album.uri ?? album.itemId),
width: 150, width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0), margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: AlbumCard( child: AlbumCard(
album: album, album: album,
@@ -116,7 +139,11 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
), ),
), ),
], ],
),
),
); );
_logger.endBuild('AlbumRow:${widget.title}');
return result;
} }
} }
+61 -22
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; 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 '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../screens/artist_details_screen.dart'; import '../screens/artist_details_screen.dart';
@@ -13,12 +14,14 @@ class ArtistCard extends StatefulWidget {
final Artist artist; final Artist artist;
final VoidCallback? onTap; final VoidCallback? onTap;
final String? heroTagSuffix; final String? heroTagSuffix;
final double? imageSize;
const ArtistCard({ const ArtistCard({
super.key, super.key,
required this.artist, required this.artist,
this.onTap, this.onTap,
this.heroTagSuffix, this.heroTagSuffix,
this.imageSize,
}); });
@override @override
@@ -29,11 +32,36 @@ class _ArtistCardState extends State<ArtistCard> {
static final _logger = DebugLogger(); static final _logger = DebugLogger();
String? _fallbackImageUrl; String? _fallbackImageUrl;
bool _triedFallback = false; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); 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 colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
@@ -42,13 +70,6 @@ class _ArtistCardState extends State<ArtistCard> {
// Use MA image if available, otherwise try fallback // Use MA image if available, otherwise try fallback
final imageUrl = maImageUrl ?? _fallbackImageUrl; 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( return RepaintBoundary(
child: GestureDetector( child: GestureDetector(
onTap: widget.onTap ?? () { onTap: widget.onTap ?? () {
@@ -60,6 +81,7 @@ class _ArtistCardState extends State<ArtistCard> {
child: ArtistDetailsScreen( child: ArtistDetailsScreen(
artist: widget.artist, artist: widget.artist,
heroTagSuffix: widget.heroTagSuffix, heroTagSuffix: widget.heroTagSuffix,
initialImageUrl: imageUrl,
), ),
), ),
); );
@@ -67,33 +89,49 @@ class _ArtistCardState extends State<ArtistCard> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Artist image - circular // Artist image - circular, constrained to be a proper circle
Hero( 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, tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
child: ClipOval( child: ClipOval(
child: Container( child: Container(
width: 110, width: size,
height: 110, height: size,
color: colorScheme.surfaceVariant, color: colorScheme.surfaceVariant,
child: imageUrl != null child: imageUrl != null
? Image.network( ? CachedNetworkImage(
imageUrl, imageUrl: imageUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
cacheWidth: 256, memCacheWidth: 256,
cacheHeight: 256, memCacheHeight: 256,
errorBuilder: (_, __, ___) => Icon( fadeInDuration: const Duration(milliseconds: 150),
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Icon(
Icons.person_rounded, Icons.person_rounded,
size: 60, size: size * 0.55,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
) )
: Icon(Icons.person_rounded, size: 60, color: colorScheme.onSurfaceVariant), : Icon(Icons.person_rounded, size: size * 0.55, color: colorScheme.onSurfaceVariant),
),
),
);
},
), ),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 8),
// Artist name // Artist name - fixed height container so image size is consistent
Hero( SizedBox(
height: 36, // Fixed height for 2 lines of text
child: Hero(
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + suffix, tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + suffix,
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
@@ -109,6 +147,7 @@ class _ArtistCardState extends State<ArtistCard> {
), ),
), ),
), ),
),
], ],
), ),
), ),
+33 -6
View File
@@ -2,18 +2,21 @@ 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';
import 'artist_card.dart'; import 'artist_card.dart';
class ArtistRow extends StatefulWidget { class ArtistRow extends StatefulWidget {
final String title; final String title;
final Future<List<Artist>> Function() loadArtists; final Future<List<Artist>> Function() loadArtists;
final String? heroTagSuffix; final String? heroTagSuffix;
final double? rowHeight;
const ArtistRow({ const ArtistRow({
super.key, super.key,
required this.title, required this.title,
required this.loadArtists, required this.loadArtists,
this.heroTagSuffix, this.heroTagSuffix,
this.rowHeight,
}); });
@override @override
@@ -44,17 +47,28 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
} }
} }
static final _logger = DebugLogger();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_logger.startBuild('ArtistRow:${widget.title}');
super.build(context); // Required for AutomaticKeepAliveClientMixin super.build(context); // Required for AutomaticKeepAliveClientMixin
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Column( // 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, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Text( child: Text(
widget.title, widget.title,
style: textTheme.titleLarge?.copyWith( style: textTheme.titleLarge?.copyWith(
@@ -63,8 +77,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
), ),
), ),
), ),
SizedBox( Expanded(
height: 163,
child: FutureBuilder<List<Artist>>( child: FutureBuilder<List<Artist>>(
future: _artistsFuture, future: _artistsFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
@@ -91,18 +104,28 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
); );
} }
// 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( return ScrollConfiguration(
behavior: const _StretchScrollBehavior(), behavior: const _StretchScrollBehavior(),
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0), padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: artists.length, itemCount: artists.length,
itemExtent: 136, // 120px width + 16px horizontal margins 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) { itemBuilder: (context, index) {
final artist = artists[index]; final artist = artists[index];
return Container( return Container(
key: ValueKey(artist.uri ?? artist.itemId), key: ValueKey(artist.uri ?? artist.itemId),
width: 120, width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 8.0), margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: ArtistCard( child: ArtistCard(
artist: artist, artist: artist,
@@ -116,7 +139,11 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
), ),
), ),
], ],
),
),
); );
_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 '../theme/theme_provider.dart';
import 'expandable_player.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 /// A global key to access the player state from anywhere in the app
final globalPlayerKey = GlobalKey<ExpandablePlayerState>(); final globalPlayerKey = GlobalKey<ExpandablePlayerState>();
@@ -23,7 +57,7 @@ class BottomSpacing {
static const double navBarOnly = navBarHeight + 16.0; static const double navBarOnly = navBarHeight + 16.0;
/// Space needed when mini player is also visible /// 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 /// 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>( return Consumer<ThemeProvider>(
builder: (context, themeProvider, _) { builder: (context, themeProvider, _) {
// Use adaptive primary color for bottom nav when adaptive theme is enabled // Use adaptive primary color for bottom nav when adaptive theme is enabled
var navSelectedColor = themeProvider.adaptiveTheme final sourceColor = themeProvider.adaptiveTheme
? themeProvider.adaptivePrimaryColor ? themeProvider.adaptivePrimaryColor
: colorScheme.primary; : 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; final isDark = Theme.of(context).brightness == Brightness.dark;
if (isDark && navSelectedColor.computeLuminance() < 0.2) { final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
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();
}
// Base background: use adaptive surface color if available, otherwise default surface // Base background: use adaptive surface color if available, otherwise default surface
final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null) final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null)
+5 -1
View File
@@ -89,6 +89,8 @@ class QueuePanel extends StatelessWidget {
return ListView.builder( return ListView.builder(
padding: Spacing.paddingH8, padding: Spacing.paddingH8,
cacheExtent: 500, cacheExtent: 500,
addAutomaticKeepAlives: false, // Items don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually
itemCount: items.length, itemCount: items.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = items[index]; final item = items[index];
@@ -96,7 +98,8 @@ class QueuePanel extends StatelessWidget {
final isPastItem = index < currentIndex; final isPastItem = index < currentIndex;
final imageUrl = maProvider.api?.getImageUrl(item.track, size: 80); final imageUrl = maProvider.api?.getImageUrl(item.track, size: 80);
return Opacity( return RepaintBoundary(
child: Opacity(
opacity: isPastItem ? 0.5 : 1.0, opacity: isPastItem ? 0.5 : 1.0,
child: Container( child: Container(
margin: EdgeInsets.symmetric(vertical: Spacing.xxs), 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,
);
}
}