Compare commits

...
4 Commits
Author SHA1 Message Date
Chris 6fc24539b1 On audit: Discarded multi-provider fixes - reverting to master 2026-02-07 20:36:50 +00:00
Chris 32f62a4731 index on audit: 9b8f6d3 Fix: Show all available home screen rows in settings 2026-02-07 20:36:50 +00:00
ChrisandClaude Opus 4.6 9b8f6d36c6 Fix: Show all available home screen rows in settings
Commit 0263c75 reduced defaultHomeRowOrder to 3 rows but also
introduced a bug where _filteredRowOrder only showed rows currently
in _homeRowOrder. This made most row options invisible in settings.

Added _allAvailableRows constant (13 rows) and modified _filteredRowOrder
to show all available static rows regardless of their current enabled state.

Users can now see and toggle all 13 home screen rows:
- 3 enabled by default (recent albums, discover artists, discover albums)
- 10 disabled by default (all other rows)

Fixes regression from commit 0263c75 (Feb 5, 2025)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 17:15:29 +00:00
ChrisandClaude Opus 4.6 a3e414dc4b Fix: Add Overlay wrapper and checks to prevent red screen error
- Wrap GlobalPlayerOverlay with Overlay in main.dart to provide Overlay ancestor
- Add Overlay.maybeOf check in HiResBadge before using Tooltip
- Add Overlay.maybeOf check in search_screen library-only toggle before using Tooltip

This fixes "No Overlay widget found" error that caused red screen in debug builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 17:15:20 +00:00
30 changed files with 1018 additions and 750 deletions
+14 -5
View File
@@ -236,9 +236,9 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
// Set the queue manager after creation (breaks circular dependency)
_playerProvider.queueManager = queueManager;
// Initialize main provider (will act as facade)
// For now, don't pass providers - MusicAssistantProvider doesn't accept them yet
// It will delegate to the new providers gradually
// Initialize main provider (facade mode - sub-providers not yet wired)
// Sub-providers have null API at construction time and no update mechanism,
// so keep facade using its own internal logic until wiring is complete.
_musicProvider = MusicAssistantProvider();
// Wire up player provider callbacks
@@ -450,8 +450,17 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
builder: (context, child) {
// Wrap entire app with global player overlay
return GlobalPlayerOverlay(child: child ?? const SizedBox.shrink());
// Wrap entire app with global player overlay.
// Provide an Overlay ancestor so BottomNavigationBar/Tooltip can render safely.
return Overlay(
initialEntries: [
OverlayEntry(
builder: (context) => GlobalPlayerOverlay(
child: child ?? const SizedBox.shrink(),
),
),
],
);
},
home: const AppStartup(),
),
+138 -55
View File
@@ -36,6 +36,9 @@ import '../utils/player_sort_utility.dart';
import '../utils/queue_controls.dart';
import '../utils/player_sync_state.dart';
import '../main.dart' show audioHandler;
import 'connection_provider.dart';
import 'library_provider.dart';
import 'player_provider.dart';
/// Main provider that coordinates connection, player, and library state.
///
@@ -48,13 +51,19 @@ import '../main.dart' show audioHandler;
/// - Player logic: Player selection, controls, local player
/// - Library logic: Artists, albums, tracks, search
class MusicAssistantProvider with ChangeNotifier {
// Sub-providers (injected for delegation)
final ConnectionProvider? _connectionProvider;
final LibraryProvider? _libraryProvider;
final PlayerProvider? _playerProvider;
MusicAssistantAPI? _api;
final AuthManager _authManager = AuthManager();
final DebugLogger _logger = DebugLogger();
final CacheService _cacheService = CacheService();
final PositionTracker _positionTracker = PositionTracker();
MAConnectionState _connectionState = MAConnectionState.disconnected;
// Local state (used when _connectionProvider is null, for backward compatibility)
MAConnectionState? _connectionState;
String? _serverUrl;
String? _error;
@@ -183,13 +192,23 @@ class MusicAssistantProvider with ChangeNotifier {
// GETTERS
// ============================================================================
MAConnectionState get connectionState => _connectionState;
String? get serverUrl => _serverUrl;
String? get error => _error;
bool get isConnected => _connectionState == MAConnectionState.connected ||
_connectionState == MAConnectionState.authenticated;
// Connection getters - use facade's own state (sub-providers not yet wired)
MAConnectionState get connectionState => _connectionState ?? MAConnectionState.disconnected;
// Library getters with provider filtering applied
String? get serverUrl => _serverUrl;
String? get error => _error;
bool get isConnected => _connectionState == MAConnectionState.connected;
MusicAssistantAPI? get api => _api;
List<String> get providerFilter => _providerFilter;
List<String> get playerFilter => _playerFilter;
// Library getters - delegate to LibraryProvider when available
// TEMP: Disable delegation to debug context.select error
List<Artist> get artists => filterByProvider(_artists);
List<Album> get albums => filterByProvider(_albums);
List<Track> get tracks => filterByProvider(_tracks);
@@ -201,13 +220,12 @@ class MusicAssistantProvider with ChangeNotifier {
List<Album> get albumsUnfiltered => _albums;
List<MediaItem> get podcastsUnfiltered => _podcasts;
List<MediaItem> get radioStationsUnfiltered => _radioStations;
// Library loading states - for now keep local state as LibraryProvider doesn't expose these
bool get isLoading => _isLoading;
bool get isLoadingRadio => _isLoadingRadio;
bool get isLoadingPodcasts => _isLoadingPodcasts;
/// Provider filter from MA user settings (empty = all providers allowed)
List<String> get providerFilter => _providerFilter;
/// Whether provider filtering is active
bool get hasProviderFilter => _providerFilterService.hasProviderFilter;
@@ -230,9 +248,6 @@ class MusicAssistantProvider with ChangeNotifier {
return _providerFilterService.filterSearchResults(results);
}
/// Player filter from MA user settings (empty = all players allowed)
List<String> get playerFilter => _playerFilter;
/// Whether player filtering is active
bool get hasPlayerFilter => _providerFilterService.hasPlayerFilter;
@@ -496,12 +511,7 @@ class MusicAssistantProvider with ChangeNotifier {
SyncStatus get syncStatus => SyncService.instance.status;
/// Selected player - loads from cache if not yet set
Player? get selectedPlayer {
if (_selectedPlayer == null && _cacheService.getCachedSelectedPlayer() != null) {
_selectedPlayer = _cacheService.getCachedSelectedPlayer();
}
return _selectedPlayer;
}
Player? get selectedPlayer => _selectedPlayer;
/// Available players - loads from cache for instant UI display
/// Filtered by player_filter if active
@@ -525,6 +535,8 @@ class MusicAssistantProvider with ChangeNotifier {
}
Track? get currentTrack => _currentTrack;
Audiobook? get currentAudiobook => _currentAudiobook;
Map<String, String> get castToSendspinIdMap => _castToSendspinIdMap;
// Sleep timer getters
bool get sleepTimerActive => _sleepTimerMinutes != null;
@@ -541,11 +553,8 @@ class MusicAssistantProvider with ChangeNotifier {
/// Home refresh counter - increments when home rows should refresh their data
int get homeRefreshCounter => _homeRefreshCounter;
/// Currently playing audiobook context (with chapters) - set when playing an audiobook
Audiobook? get currentAudiobook => _currentAudiobook;
/// Whether we're currently playing an audiobook
bool get isPlayingAudiobook => _currentAudiobook != null;
bool get isPlayingAudiobook => currentAudiobook != null;
/// Whether we're currently playing a podcast episode
/// Detected by checking if the current track's URI contains podcast_episode
@@ -621,7 +630,6 @@ class MusicAssistantProvider with ChangeNotifier {
String get lastSearchQuery => _lastSearchQuery;
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
MusicAssistantAPI? get api => _api;
AuthManager get authManager => _authManager;
/// Position tracker for playback progress - single source of truth
@@ -757,12 +765,25 @@ class MusicAssistantProvider with ChangeNotifier {
// INITIALIZATION
// ============================================================================
MusicAssistantProvider() {
MusicAssistantProvider({
ConnectionProvider? connectionProvider,
LibraryProvider? libraryProvider,
PlayerProvider? playerProvider,
}) : _connectionProvider = connectionProvider,
_libraryProvider = libraryProvider,
_playerProvider = playerProvider {
_localPlayer = LocalPlayerService(_authManager);
_initialize();
}
Future<void> _initialize() async {
// Set up ConnectionProvider callbacks before connecting
if (_connectionProvider != null) {
_connectionProvider!.onConnected = _onConnectionProviderConnected;
_connectionProvider!.onAuthenticated = _onConnectionProviderAuthenticated;
_connectionProvider!.onDisconnected = _onConnectionProviderDisconnected;
}
_serverUrl = await SettingsService.getServerUrl();
// Load cached players from database for instant display (before connecting)
@@ -991,11 +1012,89 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
// ============================================================================
// CONNECTION PROVIDER CALLBACKS
// ============================================================================
/// Called by ConnectionProvider when WebSocket connects (before auth)
Future<void> _onConnectionProviderConnected() async {
_logger.log('🔗 ConnectionProvider: WebSocket connected');
// No-op for now - initialization happens after authentication
}
/// Called by ConnectionProvider when authentication succeeds
Future<void> _onConnectionProviderAuthenticated() async {
_logger.log('✅ ConnectionProvider: Authenticated');
await _initializeAfterConnection();
}
/// Called by ConnectionProvider when disconnected
void _onConnectionProviderDisconnected() {
_logger.log('📡 ConnectionProvider: Disconnected - keeping cached data');
}
/// Set up event subscriptions that are specific to the facade
void _setupEventSubscriptions() {
if (_api == null) return;
// Subscribe to player events
_localPlayerEventSubscription?.cancel();
_localPlayerEventSubscription = _api!.builtinPlayerEvents.listen(
_handleLocalPlayerEvent,
onError: (error) => _logger.log('Builtin player event stream error: $error'),
);
_playerUpdatedEventSubscription?.cancel();
_playerUpdatedEventSubscription = _api!.playerUpdatedEvents.listen(
_handlePlayerUpdatedEvent,
onError: (error) => _logger.log('Player updated event stream error: $error'),
);
_playerAddedEventSubscription?.cancel();
_playerAddedEventSubscription = _api!.playerAddedEvents.listen(
_handlePlayerAddedEvent,
onError: (error) => _logger.log('Player added event stream error: $error'),
);
_mediaItemAddedEventSubscription?.cancel();
_mediaItemAddedEventSubscription = _api!.mediaItemAddedEvents.listen(
_handleMediaItemAddedEvent,
onError: (error) => _logger.log('Media item added event stream error: $error'),
);
_mediaItemDeletedEventSubscription?.cancel();
_mediaItemDeletedEventSubscription = _api!.mediaItemDeletedEvents.listen(
_handleMediaItemDeletedEvent,
onError: (error) => _logger.log('Media item deleted event stream error: $error'),
);
}
// ============================================================================
// CONNECTION
// ============================================================================
Future<void> connectToServer(String serverUrl) async {
if (_connectionProvider != null) {
// Delegate to ConnectionProvider
await _connectionProvider!.connect(serverUrl);
// After ConnectionProvider connects, get the API reference and initialize facade-specific stuff
_api = _connectionProvider!.api;
_imageHelper = ImageHelperService(
api: _api,
getCachedTrackForPlayer: (playerId) => _cacheService.getCachedTrackForPlayer(playerId),
);
// Set up facade-specific event subscriptions
_setupEventSubscriptions();
} else {
// Fallback to old behavior when ConnectionProvider is not available
await _connectToServerLegacy(serverUrl);
}
}
/// Legacy connection method (used when ConnectionProvider is not provided)
Future<void> _connectToServerLegacy(String serverUrl) async {
try {
_error = null;
_serverUrl = serverUrl;
@@ -1054,36 +1153,8 @@ class MusicAssistantProvider with ChangeNotifier {
},
);
_localPlayerEventSubscription?.cancel();
_localPlayerEventSubscription = _api!.builtinPlayerEvents.listen(
_handleLocalPlayerEvent,
onError: (error) => _logger.log('Builtin player event stream error: $error'),
);
_playerUpdatedEventSubscription?.cancel();
_playerUpdatedEventSubscription = _api!.playerUpdatedEvents.listen(
_handlePlayerUpdatedEvent,
onError: (error) => _logger.log('Player updated event stream error: $error'),
);
_playerAddedEventSubscription?.cancel();
_playerAddedEventSubscription = _api!.playerAddedEvents.listen(
_handlePlayerAddedEvent,
onError: (error) => _logger.log('Player added event stream error: $error'),
);
// Subscribe to library change events for instant UI updates
_mediaItemAddedEventSubscription?.cancel();
_mediaItemAddedEventSubscription = _api!.mediaItemAddedEvents.listen(
_handleMediaItemAddedEvent,
onError: (error) => _logger.log('Media item added event stream error: $error'),
);
_mediaItemDeletedEventSubscription?.cancel();
_mediaItemDeletedEventSubscription = _api!.mediaItemDeletedEvents.listen(
_handleMediaItemDeletedEvent,
onError: (error) => _logger.log('Media item deleted event stream error: $error'),
);
// Set up event subscriptions using shared method
_setupEventSubscriptions();
await _api!.connect();
notifyListeners();
@@ -4906,6 +4977,18 @@ class MusicAssistantProvider with ChangeNotifier {
// ============================================================================
Future<void> loadLibrary() async {
if (_libraryProvider != null) {
// Delegate to LibraryProvider
await _libraryProvider!.loadLibrary();
notifyListeners();
} else {
// Fallback to legacy behavior
await _loadLibraryLegacy();
}
}
/// Legacy library loading method (used when LibraryProvider is not provided)
Future<void> _loadLibraryLegacy() async {
if (!isConnected) return;
try {
+2 -2
View File
@@ -67,10 +67,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.album.inLibrary) {
service.setLibraryStatus(key, true);
service.initLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
service.setFavoriteStatus(key, true);
service.initFavoriteStatus(key, true);
}
_loadTracks();
_loadAlbumDescription();
+2 -2
View File
@@ -69,10 +69,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && _checkIfInLibrary(widget.artist)) {
service.setLibraryStatus(key, true);
service.initLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
service.setFavoriteStatus(key, true);
service.initFavoriteStatus(key, true);
}
// Use initial image URL immediately for smooth hero animation
_artistImageUrl = widget.initialImageUrl;
+43 -10
View File
@@ -6,12 +6,52 @@ import '../widgets/album_card.dart';
import '../widgets/common/empty_state.dart';
import '../l10n/app_localizations.dart';
class LibraryAlbumsScreen extends StatelessWidget {
class LibraryAlbumsScreen extends StatefulWidget {
const LibraryAlbumsScreen({super.key});
@override
State<LibraryAlbumsScreen> createState() => _LibraryAlbumsScreenState();
}
class _LibraryAlbumsScreenState extends State<LibraryAlbumsScreen> {
List<Album> _albums = [];
bool _isLoading = false;
@override
void initState() {
super.initState();
final provider = context.read<MusicAssistantProvider>();
_albums = provider.albums;
_isLoading = provider.isLoading;
provider.addListener(_onProviderChanged);
// Auto-load library if empty
if (_albums.isEmpty && !_isLoading) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
provider.loadLibrary();
}
});
}
}
@override
void dispose() {
context.read<MusicAssistantProvider>().removeListener(_onProviderChanged);
super.dispose();
}
void _onProviderChanged() {
if (!mounted) return;
final provider = context.read<MusicAssistantProvider>();
setState(() {
_albums = provider.albums;
_isLoading = provider.isLoading;
});
}
@override
Widget build(BuildContext context) {
// Use Selector for targeted rebuilds - only rebuild when albums or loading state changes
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
@@ -34,13 +74,7 @@ class LibraryAlbumsScreen extends StatelessWidget {
),
centerTitle: true,
),
body: Selector<MusicAssistantProvider, (List<Album>, bool)>(
selector: (_, provider) => (provider.albums, provider.isLoading),
builder: (context, data, _) {
final (albums, isLoading) = data;
return _buildAlbumsList(context, albums, isLoading);
},
),
body: _buildAlbumsList(context, _albums, _isLoading),
);
}
@@ -93,4 +127,3 @@ class LibraryAlbumsScreen extends StatelessWidget {
);
}
}
+43 -9
View File
@@ -8,12 +8,52 @@ import '../widgets/artist_avatar.dart';
import 'artist_details_screen.dart';
import '../l10n/app_localizations.dart';
class LibraryArtistsScreen extends StatelessWidget {
class LibraryArtistsScreen extends StatefulWidget {
const LibraryArtistsScreen({super.key});
@override
State<LibraryArtistsScreen> createState() => _LibraryArtistsScreenState();
}
class _LibraryArtistsScreenState extends State<LibraryArtistsScreen> {
List<Artist> _artists = [];
bool _isLoading = false;
@override
void initState() {
super.initState();
final provider = context.read<MusicAssistantProvider>();
_artists = provider.artists;
_isLoading = provider.isLoading;
provider.addListener(_onProviderChanged);
// Auto-load library if empty
if (_artists.isEmpty && !_isLoading) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
provider.loadLibrary();
}
});
}
}
@override
void dispose() {
context.read<MusicAssistantProvider>().removeListener(_onProviderChanged);
super.dispose();
}
void _onProviderChanged() {
if (!mounted) return;
final provider = context.read<MusicAssistantProvider>();
setState(() {
_artists = provider.artists;
_isLoading = provider.isLoading;
});
}
@override
Widget build(BuildContext context) {
// Use Selector for targeted rebuilds - only rebuild when artists or loading state changes
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
@@ -36,13 +76,7 @@ class LibraryArtistsScreen extends StatelessWidget {
),
centerTitle: true,
),
body: Selector<MusicAssistantProvider, (List<Artist>, bool)>(
selector: (_, provider) => (provider.artists, provider.isLoading),
builder: (context, data, _) {
final (artists, isLoading) = data;
return _buildArtistsList(context, artists, isLoading);
},
),
body: _buildArtistsList(context, _artists, _isLoading),
);
}
File diff suppressed because it is too large Load Diff
+39 -27
View File
@@ -598,35 +598,47 @@ class SearchScreenState extends State<SearchScreen> {
),
const SizedBox(width: 8),
// Library-only toggle with circle background
Tooltip(
message: S.of(context)!.libraryOnly,
child: SizedBox(
width: 44,
height: 44,
child: Material(
color: colorScheme.surfaceContainerHighest.withOpacity(0.6),
shape: const CircleBorder(),
child: InkWell(
onTap: () {
setState(() {
_libraryOnly = !_libraryOnly;
});
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
}
},
customBorder: const CircleBorder(),
child: Icon(
Symbols.book_2,
fill: _libraryOnly ? 1 : 0,
size: 22,
color: _libraryOnly
? colorScheme.primary
: colorScheme.onSurface,
LayoutBuilder(
builder: (context, constraints) {
final control = SizedBox(
width: 44,
height: 44,
child: Material(
color: colorScheme.surfaceContainerHighest.withOpacity(0.6),
shape: const CircleBorder(),
child: InkWell(
onTap: () {
setState(() {
_libraryOnly = !_libraryOnly;
});
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
}
},
customBorder: const CircleBorder(),
child: Icon(
Symbols.book_2,
fill: _libraryOnly ? 1 : 0,
size: 22,
color: _libraryOnly
? colorScheme.primary
: colorScheme.onSurface,
),
),
),
),
),
);
// Only use Tooltip if an Overlay ancestor exists
final hasOverlay = Overlay.maybeOf(context) != null;
if (!hasOverlay) {
return control;
}
return Tooltip(
message: S.of(context)!.libraryOnly,
child: control,
);
},
),
],
),
+28 -4
View File
@@ -56,18 +56,42 @@ class _SettingsScreenState extends State<SettingsScreen> {
Map<String, bool> _discoveryRowEnabled = {};
bool _isLoadingDiscoveryFolders = false;
/// Filtered row order for the draggable list (excludes discovery-mixes)
/// Shows rows in home order, plus any discovery rows from provider that aren't in home order yet
/// All available static home screen rows (that can be toggled on/off)
static const List<String> _allAvailableRows = [
'recent-albums',
'discover-artists',
'discover-albums',
'discovery-mixes',
'continue-listening',
'discover-audiobooks',
'discover-series',
'favorite-albums',
'favorite-artists',
'favorite-tracks',
'favorite-playlists',
'favorite-radio-stations',
'favorite-podcasts',
];
/// Filtered row order for the draggable list
/// Shows all available rows (in home order + discovery rows), regardless of enabled state
List<String> get _filteredRowOrder {
final rows = <String>[];
// First, add all rows from home order (excluding discovery-mixes)
// First, add all static rows in their current home order
for (final rowId in _homeRowOrder) {
if (rowId == 'discovery-mixes') continue;
rows.add(rowId);
}
// Then, add any discovery rows from provider that aren't in home order yet
// Then, add any static rows that aren't in home order yet (so users can enable them)
for (final rowId in _allAvailableRows) {
if (!_homeRowOrder.contains(rowId) && rowId != 'discovery-mixes') {
rows.add(rowId);
}
}
// Finally, add any discovery rows from provider that aren't in home order yet
for (final folder in _discoveryFolders) {
final discoveryRowId = 'discovery:${folder.itemId}';
if (!_homeRowOrder.contains(discoveryRowId)) {
+12
View File
@@ -62,6 +62,18 @@ class LibraryStatusService extends ChangeNotifier {
notifyListeners();
}
/// Initialize library status without notifying listeners.
/// Use during initState to sync service from widget data without triggering rebuilds.
void initLibraryStatus(String key, bool inLibrary) {
_libraryStatus[key] ??= inLibrary;
}
/// Initialize favorite status without notifying listeners.
/// Use during initState to sync service from widget data without triggering rebuilds.
void initFavoriteStatus(String key, bool favorite) {
_favoriteStatus[key] ??= favorite;
}
/// Mark operation as pending (for rollback tracking)
void markLibraryPending(String key, bool previousValue) {
_pendingLibraryOps[key] = previousValue;
+19 -15
View File
@@ -63,10 +63,10 @@ class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.album.inLibrary) {
service.setLibraryStatus(key, true);
service.initLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
service.setFavoriteStatus(key, true);
service.initFavoriteStatus(key, true);
}
_initFallbackImage();
}
@@ -410,22 +410,26 @@ class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
const SizedBox(height: 8),
// Album title with year
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
Text(
widget.album.nameWithYear,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
Flexible(
child: Text(
widget.album.nameWithYear,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
// Artist name
Text(
widget.album.artistsString,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
Flexible(
child: Text(
widget.album.artistsString,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
),
],
+2 -2
View File
@@ -113,8 +113,8 @@ 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;
// Text area: 8px gap + ~20px title + ~20px artist = ~48px
const textAreaHeight = 48.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
+2 -2
View File
@@ -63,10 +63,10 @@ class _ArtistCardState extends State<ArtistCard> with LibraryStatusMixin {
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.artist.inLibrary) {
service.setLibraryStatus(key, true);
service.initLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
service.setFavoriteStatus(key, true);
service.initFavoriteStatus(key, true);
}
// Fetch fallback image once in initState, not during build
_initFallbackImage();
+2 -2
View File
@@ -113,8 +113,8 @@ 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;
// Text area: 8px gap + ~40px for 2-line name = ~48px
const textAreaHeight = 48.0;
final imageSize = contentHeight - textAreaHeight;
final cardWidth = imageSize; // Card width = image width (circle)
final itemExtent = cardWidth + 16; // width + horizontal margins
+3 -3
View File
@@ -121,7 +121,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
);
}
const textAreaHeight = 44.0;
const textAreaHeight = 48.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize;
final itemExtent = cardWidth + 12;
@@ -223,10 +223,10 @@ class _AudiobookCardState extends State<_AudiobookCard> with LibraryStatusMixin
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.audiobook.inLibrary) {
service.setLibraryStatus(key, true);
service.initLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.audiobook.favorite ?? false)) {
service.setFavoriteStatus(key, true);
service.initFavoriteStatus(key, true);
}
}
+10 -8
View File
@@ -114,8 +114,8 @@ class _DiscoveryRowState extends State<DiscoveryRow> with AutomaticKeepAliveClie
}
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px subtitle = ~44px
const textAreaHeight = 44.0;
// Text area: 8px gap + ~20px title + ~20px subtitle = ~48px
const textAreaHeight = 48.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
@@ -191,13 +191,15 @@ class _DiscoveryRowState extends State<DiscoveryRow> with AutomaticKeepAliveClie
),
),
const SizedBox(height: 8),
Text(
item.name,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface,
Flexible(
child: Text(
item.name,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
+5 -1
View File
@@ -244,7 +244,11 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Reset expansion notifier to match initial controller state (0.0)
// This fixes a bug where stale progress values from a previous widget instance
// caused the nav bar to be invisible on app restart/widget recreation
playerExpansionNotifier.value = PlayerExpansionState(0.0, null, null);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
playerExpansionNotifier.value = PlayerExpansionState(0.0, null, null);
}
});
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
+6 -4
View File
@@ -478,10 +478,10 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// PERF: Use select() to only rebuild when connection state or player changes
final (isConnected, hasSelectedPlayer) = context.select<MusicAssistantProvider, (bool, bool)>(
(p) => (p.isConnected, p.selectedPlayer != null),
);
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, _) {
final isConnected = maProvider.isConnected;
final hasSelectedPlayer = maProvider.selectedPlayer != null;
// Compute whether this is a first-time user ready for welcome screen.
// This is SYNCHRONOUS - no setState needed - so it works in the same frame.
@@ -849,5 +849,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
],
),
);
},
);
}
}
+43 -35
View File
@@ -69,46 +69,54 @@ class HiResBadge extends StatelessWidget {
final baseColor = primaryColor ?? colorScheme.primary;
final color = Color.lerp(baseColor, Colors.white, 0.5)!;
return Tooltip(
message: tooltip ?? 'Hi-Res Audio',
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
border: Border.all(color: color, width: 1.5),
borderRadius: BorderRadius.circular(4),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Hi-Res',
final content = Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
border: Border.all(color: color, width: 1.5),
borderRadius: BorderRadius.circular(4),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Hi-Res',
style: TextStyle(
color: color,
fontSize: 9,
fontWeight: FontWeight.w500,
height: 1.1,
),
),
Container(
margin: const EdgeInsets.only(top: 1),
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
child: Text(
'AUDIO',
style: TextStyle(
color: color,
fontSize: 9,
fontWeight: FontWeight.w500,
color: colorScheme.surface,
fontSize: 8,
fontWeight: FontWeight.bold,
height: 1.1,
),
),
Container(
margin: const EdgeInsets.only(top: 1),
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
child: Text(
'AUDIO',
style: TextStyle(
color: colorScheme.surface,
fontSize: 8,
fontWeight: FontWeight.bold,
height: 1.1,
),
),
),
],
),
),
],
),
);
// Only use Tooltip if an Overlay ancestor exists
final hasOverlay = Overlay.maybeOf(context) != null;
if (!hasOverlay) {
return content;
}
return Tooltip(
message: tooltip ?? 'Hi-Res Audio',
child: content,
);
}
}
+4 -3
View File
@@ -122,10 +122,11 @@ mixin LibraryStatusMixin<T extends StatefulWidget> on State<T> {
}
void _onLibraryStatusChanged() {
// Trigger rebuild to pick up new status values
if (mounted) {
if (!mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() {});
}
});
}
/// Set library status optimistically and return previous value for rollback
+12 -3
View File
@@ -68,6 +68,9 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
// Hint system
bool _showHints = true;
// Guard to run color preload only once from didChangeDependencies
bool _hasPreloaded = false;
// PERF: Pre-cached static BoxShadow to avoid allocation per frame
static const BoxShadow _cardShadow = BoxShadow(
color: Color(0x33000000), // 20% black
@@ -98,9 +101,6 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
);
// Preload all player track info and extract colors
_preloadColorsForPlayers();
// Start reveal animation
_revealController.duration = const Duration(milliseconds: 200);
_revealController.forward();
@@ -116,6 +116,15 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_hasPreloaded) {
_hasPreloaded = true;
_preloadColorsForPlayers();
}
}
Future<void> _loadHintSettings() async {
final value = await SettingsService.getShowHints();
if (mounted) setState(() => _showHints = value);
+7 -4
View File
@@ -14,12 +14,13 @@ class PlayerSelector extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Use select to only rebuild when player data changes (not on every provider update)
final (selectedPlayer, availablePlayers) = context.select<MusicAssistantProvider, (dynamic, List)>(
(p) => (p.selectedPlayer, p.availablePlayers),
);
final colorScheme = Theme.of(context).colorScheme;
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, _) {
final selectedPlayer = maProvider.selectedPlayer;
final availablePlayers = maProvider.availablePlayers;
// Count players that are currently playing MA content (excluding selected player)
// User already sees if selected player is playing, badge shows "other" activity
// Exclude external sources (Spotify Connect, TV optical, etc.) - they're not playing MA content
@@ -80,6 +81,8 @@ class PlayerSelector extends StatelessWidget {
),
),
);
},
);
}
void _showPlayerSelector(BuildContext context) {
+19 -15
View File
@@ -50,10 +50,10 @@ class _PlaylistCardState extends State<PlaylistCard> with LibraryStatusMixin {
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.playlist.inLibrary) {
service.setLibraryStatus(key, true);
service.initLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.playlist.favorite ?? false)) {
service.setFavoriteStatus(key, true);
service.initFavoriteStatus(key, true);
}
}
@@ -327,22 +327,26 @@ class _PlaylistCardState extends State<PlaylistCard> with LibraryStatusMixin {
const SizedBox(height: 8),
// Playlist title
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
Text(
widget.playlist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
Flexible(
child: Text(
widget.playlist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
// Owner/track count
Text(
widget.playlist.owner ?? (widget.playlist.trackCount != null ? '${widget.playlist.trackCount} tracks' : ''),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
Flexible(
child: Text(
widget.playlist.owner ?? (widget.playlist.trackCount != null ? '${widget.playlist.trackCount} tracks' : ''),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
),
],
+2 -2
View File
@@ -106,8 +106,8 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
}
// Card layout: square artwork + text below (same as AlbumRow)
// Text area: 8px gap + ~18px title + ~18px owner = ~44px
const textAreaHeight = 44.0;
// Text area: 8px gap + ~20px title + ~20px owner = ~48px
const textAreaHeight = 48.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
+19 -15
View File
@@ -53,10 +53,10 @@ class _PodcastCardState extends State<PodcastCard> with LibraryStatusMixin {
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.podcast.inLibrary) {
service.setLibraryStatus(key, true);
service.initLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.podcast.favorite ?? false)) {
service.setFavoriteStatus(key, true);
service.initFavoriteStatus(key, true);
}
}
@@ -333,22 +333,26 @@ class _PodcastCardState extends State<PodcastCard> with LibraryStatusMixin {
const SizedBox(height: 8),
// Podcast title
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
Text(
widget.podcast.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
Flexible(
child: Text(
widget.podcast.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
// Podcast author (if available)
Text(
_getAuthor(widget.podcast),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
Flexible(
child: Text(
_getAuthor(widget.podcast),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
),
],
+2 -2
View File
@@ -114,8 +114,8 @@ class _PodcastRowState extends State<PodcastRow> with AutomaticKeepAliveClientMi
}
// Card layout: square artwork + text below (same as AlbumRow)
// Text area: 8px gap + ~18px title + ~18px author = ~44px
const textAreaHeight = 44.0;
// Text area: 8px gap + ~20px title + ~20px author = ~48px
const textAreaHeight = 48.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
+2 -2
View File
@@ -47,10 +47,10 @@ class _RadioStationCardState extends State<RadioStationCard> with LibraryStatusM
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.radioStation.inLibrary) {
service.setLibraryStatus(key, true);
service.initLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.radioStation.favorite ?? false)) {
service.setFavoriteStatus(key, true);
service.initFavoriteStatus(key, true);
}
}
+2 -2
View File
@@ -106,8 +106,8 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
}
// Card layout: circle image + name below (same as ArtistRow)
// Text area: 8px gap + ~36px for 2-line name = ~44px
const textAreaHeight = 44.0;
// Text area: 8px gap + ~40px for 2-line name = ~48px
const textAreaHeight = 48.0;
final imageSize = contentHeight - textAreaHeight;
final cardWidth = imageSize; // Card width = image width (circle)
final itemExtent = cardWidth + 16; // width + horizontal margins
+1 -1
View File
@@ -179,7 +179,7 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
);
}
const textAreaHeight = 44.0;
const textAreaHeight = 48.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize;
final itemExtent = cardWidth + 12;
+21 -17
View File
@@ -108,8 +108,8 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
}
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0;
// Text area: 8px gap + ~20px title + ~20px artist = ~48px
const textAreaHeight = 48.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12;
@@ -211,10 +211,10 @@ class _TrackCardState extends State<_TrackCard> with LibraryStatusMixin {
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.track.inLibrary) {
service.setLibraryStatus(key, true);
service.initLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.track.favorite ?? false)) {
service.setFavoriteStatus(key, true);
service.initFavoriteStatus(key, true);
}
}
@@ -453,22 +453,26 @@ class _TrackCardState extends State<_TrackCard> with LibraryStatusMixin {
),
const SizedBox(height: 8),
// Track title
Text(
widget.track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
Flexible(
child: Text(
widget.track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
// Artist name
Text(
widget.track.artistsString,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
Flexible(
child: Text(
widget.track.artistsString,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
),
],