Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c97420f58 | ||
|
|
526e0d06b6 | ||
|
|
7092c2c08e | ||
|
|
c728815f8b | ||
|
|
19055d1474 | ||
|
|
cac78f7a11 | ||
|
|
1ad5ab3f22 | ||
|
|
4f7fe9f4a4 | ||
|
|
081f13d59f | ||
|
|
ea4407f509 | ||
|
|
6ffc98c680 | ||
|
|
0fe53c9115 | ||
|
|
d0a1e3a85b | ||
|
|
fa8c5bd26e | ||
|
|
62386b65d3 | ||
|
|
82beb0db2c | ||
|
|
2fe78556e5 | ||
|
|
82d0bb5c2d | ||
|
|
add137c56c | ||
|
|
3df1b9c756 | ||
|
|
3852e83a3c |
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -500,10 +500,16 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
void _navigateToArtist() {
|
||||
// Navigate to the first artist if available
|
||||
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(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ArtistDetailsScreen(artist: widget.album.artists!.first),
|
||||
builder: (context) => ArtistDetailsScreen(
|
||||
artist: artist,
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,13 @@ import '../utils/page_transitions.dart';
|
||||
class ArtistDetailsScreen extends StatefulWidget {
|
||||
final Artist artist;
|
||||
final String? heroTagSuffix;
|
||||
final String? initialImageUrl;
|
||||
|
||||
const ArtistDetailsScreen({
|
||||
super.key,
|
||||
super.key,
|
||||
required this.artist,
|
||||
this.heroTagSuffix,
|
||||
this.initialImageUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -44,12 +46,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isFavorite = widget.artist.favorite ?? false;
|
||||
// Use initial image URL immediately for smooth hero animation
|
||||
_artistImageUrl = widget.initialImageUrl;
|
||||
_loadArtistAlbums();
|
||||
_loadArtistDescription();
|
||||
_refreshFavoriteStatus();
|
||||
|
||||
// CRITICAL FIX: Defer image loading and color extraction until after transition
|
||||
// This prevents expensive operations during hero animation
|
||||
// Defer higher-res image loading and color extraction until after transition
|
||||
// But hero animation now works because we have initialImageUrl
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Future.delayed(const Duration(milliseconds: 350), () {
|
||||
if (mounted) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
@@ -90,6 +92,9 @@ class LibraryArtistsScreen extends StatelessWidget {
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
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
|
||||
return RepaintBoundary(
|
||||
@@ -116,6 +121,7 @@ class LibraryArtistsScreen extends StatelessWidget {
|
||||
child: ArtistDetailsScreen(
|
||||
artist: artist,
|
||||
heroTagSuffix: 'library',
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
@@ -312,7 +314,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final suffix = '_library';
|
||||
// Get image URL for hero animation
|
||||
final imageUrl = maProvider.getImageUrl(artist, size: 256);
|
||||
|
||||
return RepaintBoundary(
|
||||
child: ListTile(
|
||||
@@ -322,9 +327,6 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
radius: 24,
|
||||
imageSize: 128,
|
||||
heroTag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
|
||||
onImageLoaded: (imageUrl) {
|
||||
// Store for adaptive colors on tap
|
||||
},
|
||||
),
|
||||
title: Hero(
|
||||
tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix,
|
||||
@@ -348,6 +350,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
child: ArtistDetailsScreen(
|
||||
artist: artist,
|
||||
heroTagSuffix: 'library',
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -394,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,
|
||||
@@ -442,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) {
|
||||
@@ -458,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(
|
||||
@@ -490,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -539,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) {
|
||||
@@ -557,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(
|
||||
@@ -618,6 +632,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
@@ -404,8 +407,12 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
|
||||
Widget _buildArtistTile(Artist artist) {
|
||||
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(
|
||||
artist: artist,
|
||||
radius: 24,
|
||||
@@ -424,14 +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),
|
||||
),
|
||||
);
|
||||
},
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ArtistDetailsScreen(
|
||||
artist: artist,
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -440,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(
|
||||
@@ -473,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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -491,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(
|
||||
@@ -524,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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? () {
|
||||
@@ -60,6 +81,7 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
child: ArtistDetailsScreen(
|
||||
artist: widget.artist,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -67,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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user