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();
|
_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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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