Files
Ensemble/lib/screens/new_library_screen.dart
ChrisandClaude Opus 4.5 1921a20884 Add remote bridge infrastructure and fix Flutter 3.38 compatibility issues
Remote Bridge:
- Add WebRTC-based remote connection infrastructure
- Add signaling client for MA remote access protocol
- Add RemoteBridge local WebSocket server for transparent tunneling
- Add RemoteConnectionManager for high-level remote API

Flutter 3.38 Fixes:
- Replace Material 3 Slider with custom implementation (fixes Overlay error)
- Custom progress bar and volume slider using Stack/Container
- Add seek debouncing to prevent command timeout on rapid seeks
- Fix position reporting in Sendspin client/state payload

Other Fixes:
- Fix tab swiping scrolling multiple tabs (use default PageScrollPhysics)
- Fix copy logs to use filtered entries instead of all logs
- Revert share logs to simple implementation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 13:40:51 +00:00

5309 lines
201 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:palette_generator/palette_generator.dart';
import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart';
import '../models/media_item.dart';
import '../models/provider_instance.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/album_card.dart';
import '../widgets/artist_avatar.dart';
import '../widgets/provider_icon.dart';
import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart';
import '../theme/theme_provider.dart';
import '../widgets/common/empty_state.dart';
import '../widgets/common/disconnected_state.dart';
import '../widgets/letter_scrollbar.dart';
import '../services/settings_service.dart';
import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../services/sync_service.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
import 'album_details_screen.dart';
import 'artist_details_screen.dart';
import 'playlist_details_screen.dart';
import 'settings_screen.dart';
import 'audiobook_author_screen.dart';
import 'audiobook_detail_screen.dart';
import 'audiobook_series_screen.dart';
import 'podcast_detail_screen.dart';
/// Media type for the library
enum LibraryMediaType { music, books, podcasts, radio }
class NewLibraryScreen extends StatefulWidget {
const NewLibraryScreen({super.key});
@override
State<NewLibraryScreen> createState() => _NewLibraryScreenState();
}
class _NewLibraryScreenState extends State<NewLibraryScreen>
with RestorationMixin {
late PageController _pageController;
final _menuButtonKey = GlobalKey();
List<Playlist> _playlists = [];
List<Track> _favoriteTracks = [];
List<Audiobook> _audiobooks = [];
bool _isLoadingPlaylists = true;
bool _isLoadingTracks = false;
bool _isLoadingAudiobooks = false;
bool _showFavoritesOnly = false;
bool _isChangingMediaType = false; // Flag to ignore onPageChanged during media type transitions
bool _showOnlyArtistsWithAlbums = false; // Filter artists tab to only show those with albums
bool _isSyncingArtists = false; // Show progress while re-syncing artists
bool _isSyncingLibraries = false; // Show progress while re-syncing ABS libraries
bool _isSyncingProviders = false; // Show progress while syncing after provider toggle
// All tracks with lazy loading
List<Track> _allTracks = [];
List<Track> _sortedAllTracks = [];
List<String> _trackNames = [];
bool _isLoadingMoreTracks = false;
bool _hasMoreTracks = true;
int _tracksOffset = 0;
static const int _tracksPageSize = 100;
static const int _tracksInitialLoad = 200;
// PERF: Pre-sorted lists - computed once on data load, not on every build
List<Playlist> _sortedPlaylists = [];
List<String> _playlistNames = [];
List<Track> _sortedFavoriteTracks = [];
List<Audiobook> _sortedAudiobooks = [];
List<String> _audiobookNames = [];
List<String> _sortedAuthorNames = [];
Map<String, List<Audiobook>> _groupedAudiobooksByAuthor = {};
List<AudiobookSeries> _sortedSeries = [];
List<String> _seriesNames = [];
// PERF: Cached sorted albums/artists - only re-sort when data or sort order changes
List<Album> _cachedSortedAlbums = [];
List<String> _cachedAlbumNames = [];
String _albumsCacheKey = '';
List<Artist> _cachedSortedArtists = [];
List<String> _cachedArtistNames = [];
String _artistsCacheKey = '';
String _authorsCacheKey = '';
List<String> _cachedTrackSortKeys = [];
String _trackSortKeysCacheKey = '';
// Media type selection (Music, Books, Podcasts)
LibraryMediaType _selectedMediaType = LibraryMediaType.music;
// Track overscroll for switching between media types
double _horizontalOverscroll = 0;
static const double _overscrollThreshold = 80; // Pixels to trigger switch
// Track horizontal drag for single-category types (where PageView doesn't overscroll)
double _horizontalDragStart = 0;
double _horizontalDragDelta = 0;
// View mode settings
String _artistsViewMode = 'list'; // 'grid2', 'grid3', 'list'
String _albumsViewMode = 'grid2'; // 'grid2', 'grid3', 'list'
String _playlistsViewMode = 'list'; // 'grid2', 'grid3', 'list'
String _audiobooksViewMode = 'grid2'; // 'grid2', 'grid3', 'list'
String _authorsViewMode = 'list'; // 'grid2', 'grid3', 'list'
String _seriesViewMode = 'grid2'; // 'grid2', 'grid3'
String _radioViewMode = 'list'; // 'grid2', 'grid3', 'list'
String _podcastsViewMode = 'grid2'; // 'grid2', 'grid3', 'list'
String _audiobooksSortOrder = 'alpha'; // 'alpha', 'year'
// Sort orders for all categories
// MA API sort values: name, name_desc, sort_name, sort_name_desc, timestamp_added, timestamp_added_desc,
// last_played, last_played_desc, play_count, play_count_desc, year, year_desc, artist_name, artist_name_desc,
// duration, duration_desc, timestamp_modified, timestamp_modified_desc
String _artistsSortOrder = 'name';
String _albumsSortOrder = 'name';
String _tracksSortOrder = 'name';
String _playlistsSortOrder = 'name';
String _authorsSortOrder = 'alpha'; // Books use client-side sorting
String _seriesSortOrder = 'alpha'; // Books use client-side sorting
String _radioSortOrder = 'name';
String _podcastsSortOrder = 'name';
// Author image cache
final Map<String, String?> _authorImages = {};
// Series state
List<AudiobookSeries> _series = [];
bool _isLoadingSeries = false;
// Series book covers cache: seriesId -> list of book thumbnail URLs
final Map<String, List<String>> _seriesBookCovers = {};
// ABS library filter state (for Books tab)
List<Map<String, String>> _discoveredAbsLibraries = [];
Map<String, bool> _absLibraryEnabled = {};
final Set<String> _seriesCoversLoading = {};
// Series extracted colors cache: seriesId -> list of colors from book covers
final Map<String, List<Color>> _seriesExtractedColors = {};
// Series book counts cache: seriesId -> number of books
final Map<String, int> _seriesBookCounts = {};
bool _seriesLoaded = false;
// Pre-cache flag for podcast images (smooth hero animations)
bool _hasPrecachedPodcasts = false;
// PERF: Debounce color extraction to avoid blocking UI during scroll
Timer? _colorExtractionDebounce;
final Map<String, List<String>> _pendingColorExtractions = {};
// Restoration: Remember selected tab across app restarts
final RestorableInt _selectedTabIndex = RestorableInt(0);
// PERF: Separate ValueNotifier for efficient UI updates (RestorableInt doesn't implement ValueListenable)
final ValueNotifier<int> _tabIndexNotifier = ValueNotifier<int>(0);
// Scroll-to-hide filter bars
bool _isFilterBarVisible = true;
double _lastScrollOffset = 0;
static const double _scrollThreshold = 10.0;
bool _isLetterScrollbarDragging = false; // Disable scroll-to-hide while dragging
// Options menu overlay - stored to close on navigation
OverlayEntry? _optionsMenuOverlay;
// Per-tab provider filter (session-only, empty = use all providers)
// Key format: "mediaType_tabIndex" e.g. "music_0" for artists, "books_1" for audiobooks
final Map<String, Set<String>> _tabProviderFilters = {};
Timer? _providerFilterDebounce;
// Scroll controllers for letter scrollbar
final ScrollController _artistsScrollController = ScrollController();
final ScrollController _albumsScrollController = ScrollController();
final ScrollController _playlistsScrollController = ScrollController();
final ScrollController _tracksScrollController = ScrollController();
final ScrollController _authorsScrollController = ScrollController();
final ScrollController _audiobooksScrollController = ScrollController();
final ScrollController _seriesScrollController = ScrollController();
final ScrollController _podcastsScrollController = ScrollController();
final ScrollController _radioScrollController = ScrollController();
int get _tabCount => _getTabCountForType(_selectedMediaType);
int _getTabCountForType(LibraryMediaType type) {
switch (type) {
case LibraryMediaType.music:
return 4; // Artists, Albums, Tracks, Playlists (always 4)
case LibraryMediaType.books:
return 3; // Authors, All Books, Series
case LibraryMediaType.podcasts:
return 1; // Coming soon placeholder
case LibraryMediaType.radio:
return 1; // Radio stations
}
}
@override
String? get restorationId => 'new_library_screen';
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
registerForRestoration(_selectedTabIndex, 'selected_tab_index');
// Sync ValueNotifier with restored value
_tabIndexNotifier.value = _selectedTabIndex.value;
// Apply restored tab index after PageController is created
if (_selectedTabIndex.value < _tabCount) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_pageController.hasClients) {
_pageController.jumpToPage(_selectedTabIndex.value);
}
});
}
}
@override
void initState() {
super.initState();
_pageController = PageController();
_loadPlaylists();
_loadViewPreferences();
_loadAllTracks(); // Load tracks for the permanent Tracks tab
// Add scroll listener for lazy loading tracks
_tracksScrollController.addListener(_onTracksScroll);
// Close options menu when navigating away from Library
navigationProvider.addListener(_onNavigationChanged);
// Listen to SyncService for library data updates
SyncService.instance.addListener(_onSyncServiceChanged);
}
void _onSyncServiceChanged() {
// Only rebuild when sync completes, not during sync
// SyncService.isSyncing is already checked in menu button spinner
if (!mounted || SyncService.instance.isSyncing) return;
setState(() {});
}
void _onNavigationChanged() {
// Library is index 1, close menu when navigating to another tab
if (navigationProvider.selectedIndex != 1) {
_closeOptionsMenu();
}
}
/// Close the options menu overlay if open
void _closeOptionsMenu() {
_optionsMenuOverlay?.remove();
_optionsMenuOverlay = null;
}
/// Get the filter key for the current tab (e.g., "music_0" for artists)
String _getCurrentTabFilterKey() {
return '${_selectedMediaType.name}_${_selectedTabIndex.value}';
}
/// Get the category name for the current tab (used for provider content lookup)
String _getCurrentCategoryName() {
switch (_selectedMediaType) {
case LibraryMediaType.music:
switch (_selectedTabIndex.value) {
case 0: return 'artists';
case 1: return 'albums';
case 2: return 'tracks';
case 3: return 'playlists';
default: return 'artists';
}
case LibraryMediaType.books:
switch (_selectedTabIndex.value) {
case 0: return 'audiobooks'; // Authors tab shows audiobooks
case 1: return 'audiobooks';
case 2: return 'audiobooks'; // Series tab shows audiobooks
default: return 'audiobooks';
}
case LibraryMediaType.podcasts:
return 'podcasts';
case LibraryMediaType.radio:
return 'radio';
}
}
/// Get enabled provider IDs for the current tab
Set<String> _getEnabledProvidersForCurrentTab() {
final key = _getCurrentTabFilterKey();
return _tabProviderFilters[key] ?? <String>{};
}
/// Handle provider toggle from the options menu
/// Uses hybrid approach:
/// 1. Instant client-side filtering using SyncService source tracking (immediate UI update)
/// 2. Background server sync to refresh source tracking data (ensures accuracy)
void _handleProviderToggle(String providerId, bool enabled) {
final maProvider = context.read<MusicAssistantProvider>();
// PERF: Set flag without setState - Provider change already triggers rebuild
// This avoids a redundant double rebuild
_isSyncingProviders = true;
// Instant UI update - client-side filtering will use the new enabled set
// The toggle updates MusicAssistantProvider.enabledProviderIds which we read during build
maProvider.toggleProviderEnabled(providerId, enabled);
// Cancel any pending debounce for background sync
_providerFilterDebounce?.cancel();
// Schedule debounced background sync to refresh source tracking data
// Uses 800ms delay to allow multiple rapid toggles before syncing
_providerFilterDebounce = Timer(const Duration(milliseconds: 800), () async {
if (mounted) {
DebugLogger().log('🔄 Background sync to refresh source tracking...');
await maProvider.forceLibrarySync();
if (mounted) {
setState(() {
_isSyncingProviders = false;
});
}
}
});
}
/// Filter a list of items by the current tab's provider filter
List<T> _filterByTabProviders<T>(List<T> items, Set<String> enabledProviders) {
if (enabledProviders.isEmpty) return items; // Empty = all enabled
return items.where((item) {
if (item is MediaItem) {
final mappings = item.providerMappings;
if (mappings == null || mappings.isEmpty) return true; // No mappings = show it
return mappings.any((m) => enabledProviders.contains(m.providerInstance));
}
return true;
}).toList();
}
void _onTracksScroll() {
if (_tracksScrollController.position.pixels >=
_tracksScrollController.position.maxScrollExtent - 500) {
_loadMoreTracks();
}
}
Future<void> _loadViewPreferences() async {
// View modes
final artistsMode = await SettingsService.getLibraryArtistsViewMode();
final albumsMode = await SettingsService.getLibraryAlbumsViewMode();
final playlistsMode = await SettingsService.getLibraryPlaylistsViewMode();
final authorsMode = await SettingsService.getLibraryAuthorsViewMode();
final audiobooksMode = await SettingsService.getLibraryAudiobooksViewMode();
final audiobooksSortOrder = await SettingsService.getLibraryAudiobooksSortOrder();
final seriesMode = await SettingsService.getLibrarySeriesViewMode();
final radioMode = await SettingsService.getLibraryRadioViewMode();
final podcastsMode = await SettingsService.getLibraryPodcastsViewMode();
// Sort orders
final artistsSort = await SettingsService.getLibraryArtistsSortOrder();
final albumsSort = await SettingsService.getLibraryAlbumsSortOrder();
final tracksSort = await SettingsService.getLibraryTracksSortOrder();
final playlistsSort = await SettingsService.getLibraryPlaylistsSortOrder();
final authorsSort = await SettingsService.getLibraryAuthorsSortOrder();
final seriesSort = await SettingsService.getLibrarySeriesSortOrder();
final radioSort = await SettingsService.getLibraryRadioSortOrder();
final podcastsSort = await SettingsService.getLibraryPodcastsSortOrder();
// Artists filter setting
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
// ABS library settings
final discoveredLibraries = await SettingsService.getDiscoveredAbsLibraries() ?? [];
final enabledLibraries = await SettingsService.getEnabledAbsLibraries();
final libraryEnabled = <String, bool>{};
for (final lib in discoveredLibraries) {
final path = lib['path'] ?? '';
// If enabledLibraries is null, all are enabled by default
libraryEnabled[path] = enabledLibraries == null || enabledLibraries.contains(path);
}
if (mounted) {
setState(() {
// View modes
_artistsViewMode = artistsMode;
_albumsViewMode = albumsMode;
_playlistsViewMode = playlistsMode;
_authorsViewMode = authorsMode;
_audiobooksViewMode = audiobooksMode;
_audiobooksSortOrder = audiobooksSortOrder;
_seriesViewMode = seriesMode;
_radioViewMode = radioMode;
_podcastsViewMode = podcastsMode;
// Sort orders (migrate legacy values)
_artistsSortOrder = _migrateSortOrder(artistsSort);
_albumsSortOrder = _migrateSortOrder(albumsSort);
_tracksSortOrder = _migrateSortOrder(tracksSort);
_playlistsSortOrder = _migrateSortOrder(playlistsSort);
_authorsSortOrder = authorsSort; // Books keep legacy sort
_seriesSortOrder = seriesSort; // Books keep legacy sort
_radioSortOrder = _migrateSortOrder(radioSort);
_podcastsSortOrder = _migrateSortOrder(podcastsSort);
// Artists filter
_showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums;
// ABS library filter
_discoveredAbsLibraries = discoveredLibraries;
_absLibraryEnabled = libraryEnabled;
});
}
}
/// Migrate legacy sort values to new MA-compatible values
String _migrateSortOrder(String order) {
switch (order) {
case 'alpha':
return 'name';
case 'alpha_desc':
return 'name_desc';
case 'artist':
return 'artist_name';
case 'album':
// For tracks sorted by album, use timestamp_added as fallback since MA doesn't have album sort
return 'timestamp_added_desc';
default:
return order;
}
}
/// Sort audiobooks based on current sort order
void _sortAudiobooks() {
final sorted = List<Audiobook>.from(_audiobooks);
switch (_audiobooksSortOrder) {
case 'year':
sorted.sort((a, b) {
if (a.year == null && b.year == null) return a.name.compareTo(b.name);
if (a.year == null) return 1;
if (b.year == null) return -1;
return a.year!.compareTo(b.year!);
});
break;
case 'year_desc':
sorted.sort((a, b) {
if (a.year == null && b.year == null) return a.name.compareTo(b.name);
if (a.year == null) return 1;
if (b.year == null) return -1;
return b.year!.compareTo(a.year!);
});
break;
case 'alpha_desc':
sorted.sort((a, b) => b.name.toLowerCase().compareTo(a.name.toLowerCase()));
break;
default: // 'alpha' or any other
sorted.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
}
_sortedAudiobooks = sorted;
_audiobookNames = sorted.map((a) => a.name).toList();
}
/// Sort albums and cache the result - only re-sorts when key changes
/// Returns cached sorted list if data hasn't changed
List<Album> _getSortedAlbums(List<Album> albums) {
final cacheKey = '${albums.length}_${albums.isNotEmpty ? albums.first.itemId : ''}_${albums.isNotEmpty ? albums.last.itemId : ''}_$_albumsSortOrder';
if (cacheKey == _albumsCacheKey && _cachedSortedAlbums.isNotEmpty) {
return _cachedSortedAlbums;
}
final sortedAlbums = List<Album>.from(albums);
switch (_albumsSortOrder) {
case 'name_desc':
sortedAlbums.sort((a, b) => (b.sortName ?? b.name ?? '').toLowerCase().compareTo((a.sortName ?? a.name ?? '').toLowerCase()));
break;
case 'year':
sortedAlbums.sort((a, b) {
if (a.year == null && b.year == null) return (a.name ?? '').compareTo(b.name ?? '');
if (a.year == null) return 1;
if (b.year == null) return -1;
return a.year!.compareTo(b.year!);
});
break;
case 'year_desc':
sortedAlbums.sort((a, b) {
if (a.year == null && b.year == null) return (a.name ?? '').compareTo(b.name ?? '');
if (a.year == null) return 1;
if (b.year == null) return -1;
return b.year!.compareTo(a.year!);
});
break;
case 'artist_name':
sortedAlbums.sort((a, b) => (a.artistsString).toLowerCase().compareTo((b.artistsString).toLowerCase()));
break;
case 'artist_name_desc':
sortedAlbums.sort((a, b) => (b.artistsString).toLowerCase().compareTo((a.artistsString).toLowerCase()));
break;
case 'name':
// Explicit name ascending
sortedAlbums.sort((a, b) => (a.sortName ?? a.name ?? '').toLowerCase().compareTo((b.sortName ?? b.name ?? '').toLowerCase()));
break;
// Server-handled sort orders - preserve server order, don't re-sort
case 'timestamp_added':
case 'timestamp_added_desc':
case 'timestamp_modified':
case 'timestamp_modified_desc':
case 'last_played':
case 'last_played_desc':
case 'play_count':
case 'play_count_desc':
// Server already sorted, keep the order
break;
default:
// Unknown sort order - preserve server order
break;
}
_albumsCacheKey = cacheKey;
_cachedSortedAlbums = sortedAlbums;
_cachedAlbumNames = sortedAlbums.map((a) => a.name ?? '').toList();
return sortedAlbums;
}
/// Sort artists and cache the result - only re-sorts when key changes
List<Artist> _getSortedArtists(List<Artist> artists) {
final cacheKey = '${artists.length}_${artists.isNotEmpty ? artists.first.itemId : ''}_${artists.isNotEmpty ? artists.last.itemId : ''}_$_artistsSortOrder';
if (cacheKey == _artistsCacheKey && _cachedSortedArtists.isNotEmpty) {
return _cachedSortedArtists;
}
final sortedArtists = List<Artist>.from(artists);
switch (_artistsSortOrder) {
case 'name':
sortedArtists.sort((a, b) => (a.sortName ?? a.name ?? '').toLowerCase().compareTo((b.sortName ?? b.name ?? '').toLowerCase()));
break;
case 'name_desc':
sortedArtists.sort((a, b) => (b.sortName ?? b.name ?? '').toLowerCase().compareTo((a.sortName ?? a.name ?? '').toLowerCase()));
break;
// Server-handled sort orders - preserve server order, don't re-sort
case 'timestamp_added':
case 'timestamp_added_desc':
case 'timestamp_modified':
case 'timestamp_modified_desc':
case 'last_played':
case 'last_played_desc':
case 'play_count':
case 'play_count_desc':
// Server already sorted, keep the order
break;
default:
// Unknown sort order - preserve server order
break;
}
_artistsCacheKey = cacheKey;
_cachedSortedArtists = sortedArtists;
_cachedArtistNames = sortedArtists.map((a) => a.name ?? '').toList();
return sortedArtists;
}
/// Sort authors and cache the result - groups audiobooks by author and sorts names
(List<String>, Map<String, List<Audiobook>>) _getSortedAuthors(List<Audiobook> audiobooks) {
final cacheKey = '${audiobooks.length}_${audiobooks.isNotEmpty ? audiobooks.first.itemId : ''}_${audiobooks.isNotEmpty ? audiobooks.last.itemId : ''}_$_authorsSortOrder';
if (cacheKey == _authorsCacheKey && _sortedAuthorNames.isNotEmpty) {
return (_sortedAuthorNames, _groupedAudiobooksByAuthor);
}
// Group audiobooks by author
final groupedByAuthor = <String, List<Audiobook>>{};
for (final book in audiobooks) {
final authorName = book.authorsString;
groupedByAuthor.putIfAbsent(authorName, () => []).add(book);
}
// Sort authors based on current sort order
final sortedNames = groupedByAuthor.keys.toList();
switch (_authorsSortOrder) {
case 'alpha':
sortedNames.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
break;
case 'alpha_desc':
sortedNames.sort((a, b) => b.toLowerCase().compareTo(a.toLowerCase()));
break;
case 'books':
sortedNames.sort((a, b) {
final aCount = groupedByAuthor[a]?.length ?? 0;
final bCount = groupedByAuthor[b]?.length ?? 0;
if (bCount != aCount) return bCount.compareTo(aCount);
return a.toLowerCase().compareTo(b.toLowerCase());
});
break;
}
_authorsCacheKey = cacheKey;
_sortedAuthorNames = sortedNames;
_groupedAudiobooksByAuthor = groupedByAuthor;
return (sortedNames, groupedByAuthor);
}
/// Get track sort keys for LetterScrollbar - cached to avoid recomputation on every build
List<String> _getTrackSortKeys(List<Track> tracks) {
final cacheKey = '${tracks.length}_${tracks.isNotEmpty ? tracks.first.itemId : ''}_${tracks.isNotEmpty ? tracks.last.itemId : ''}_$_tracksSortOrder';
if (cacheKey == _trackSortKeysCacheKey && _cachedTrackSortKeys.isNotEmpty) {
return _cachedTrackSortKeys;
}
final List<String> sortKeys;
switch (_tracksSortOrder) {
case 'artist':
case 'artist_name':
sortKeys = tracks.map((t) => t.artistsString).toList();
break;
case 'album':
sortKeys = tracks.map((t) => t.album?.name ?? t.name).toList();
break;
default:
// For name, duration, timestamp, etc. - use track name for letter scrollbar
sortKeys = tracks.map((t) => t.name).toList();
break;
}
_trackSortKeysCacheKey = cacheKey;
_cachedTrackSortKeys = sortKeys;
return sortKeys;
}
String _getCurrentViewMode() {
// Return the view mode for the currently selected tab
final tabIndex = _selectedTabIndex.value;
// Handle books media type
if (_selectedMediaType == LibraryMediaType.books) {
switch (tabIndex) {
case 0:
return _authorsViewMode;
case 1:
return _audiobooksViewMode;
case 2:
return _seriesViewMode;
default:
return 'list';
}
}
// Handle radio media type
if (_selectedMediaType == LibraryMediaType.radio) {
return _radioViewMode;
}
// Handle podcasts media type
if (_selectedMediaType == LibraryMediaType.podcasts) {
return _podcastsViewMode;
}
// Handle music media type
if (_showFavoritesOnly) {
// Artists, Albums, Tracks, Playlists
switch (tabIndex) {
case 0:
return _artistsViewMode;
case 1:
return _albumsViewMode;
case 2:
return 'list'; // Tracks always list
case 3:
return _playlistsViewMode;
default:
return 'list';
}
} else {
// Artists, Albums, Playlists
switch (tabIndex) {
case 0:
return _artistsViewMode;
case 1:
return _albumsViewMode;
case 2:
return _playlistsViewMode;
default:
return 'list';
}
}
}
void _resetCategoryIndex() {
// Reset to first category when media type changes
if (_selectedTabIndex.value >= _tabCount) {
_selectedTabIndex.value = 0;
_tabIndexNotifier.value = 0;
}
// Jump to the selected category without animation
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_pageController.hasClients) {
_pageController.jumpToPage(_selectedTabIndex.value);
}
});
}
/// Get the color for a media type - muted colors based on primaryContainer tone
Color _getMediaTypeColor(ColorScheme colorScheme, LibraryMediaType type) {
final baseColor = colorScheme.primaryContainer;
final baseHsl = HSLColor.fromColor(baseColor);
double hueShift;
switch (type) {
case LibraryMediaType.music:
hueShift = 0; // Keep primary hue (purple-ish)
case LibraryMediaType.books:
hueShift = 35; // Shift toward orange
case LibraryMediaType.podcasts:
hueShift = 160; // Shift toward teal
case LibraryMediaType.radio:
hueShift = -30; // Shift toward pink
}
return baseHsl.withHue((baseHsl.hue + hueShift) % 360).toColor();
}
void _changeMediaType(LibraryMediaType type, {bool goToLastTab = false}) {
_logger.log('📚 _changeMediaType called: $type (current: $_selectedMediaType, goToLastTab: $goToLastTab)');
if (_selectedMediaType == type) {
_logger.log('📚 Same type, skipping');
return;
}
// Set flag to ignore onPageChanged during transition
_isChangingMediaType = true;
setState(() {
_selectedMediaType = type;
});
// Calculate the target tab index based on direction
final targetIndex = goToLastTab ? _getTabCountForType(type) - 1 : 0;
_selectedTabIndex.value = targetIndex;
_tabIndexNotifier.value = targetIndex;
// Jump to the target page and clear flag after
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_pageController.hasClients) {
_pageController.jumpToPage(targetIndex);
}
// Clear flag after a short delay to ensure all page change events have fired
Future.delayed(const Duration(milliseconds: 50), () {
_isChangingMediaType = false;
});
});
// Load audiobooks when switching to books tab
if (type == LibraryMediaType.books) {
_logger.log('📚 Switched to Books, _audiobooks.isEmpty=${_audiobooks.isEmpty}');
if (_audiobooks.isEmpty) {
_loadAudiobooks(favoriteOnly: _showFavoritesOnly ? true : null);
}
// Load series from Music Assistant
if (!_seriesLoaded) {
_loadSeries();
}
}
// Load radio stations when switching to radio tab
if (type == LibraryMediaType.radio) {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.radioStations.isEmpty) {
maProvider.loadRadioStations();
}
}
// Load podcasts when switching to podcasts tab
if (type == LibraryMediaType.podcasts) {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.podcasts.isEmpty) {
maProvider.loadPodcasts();
}
}
}
void _onPageChanged(int index) {
// Ignore page changes during media type transitions to avoid race conditions
if (_isChangingMediaType) return;
// Update both: RestorableInt for persistence, ValueNotifier for UI
// No setState needed - ValueListenableBuilder will rebuild only the filter chips
_selectedTabIndex.value = index;
_tabIndexNotifier.value = index;
}
/// Handle horizontal overscroll to switch between media types
bool _handleHorizontalOverscroll(ScrollNotification notification) {
if (notification.metrics.axis != Axis.horizontal) {
return false;
}
if (notification is OverscrollNotification) {
_horizontalOverscroll += notification.overscroll;
// Check if we've overscrolled enough to switch
// Positive overscroll = swiping left at the end = go to NEXT type
// Negative overscroll = swiping right at the start = go to PREVIOUS type
if (_horizontalOverscroll > _overscrollThreshold) {
_horizontalOverscroll = 0;
_switchToNextMediaType();
return true;
} else if (_horizontalOverscroll < -_overscrollThreshold) {
_horizontalOverscroll = 0;
_switchToPreviousMediaType();
return true;
}
} else if (notification is ScrollEndNotification) {
// Reset overscroll accumulation when scroll ends
_horizontalOverscroll = 0;
}
return false;
}
void _switchToNextMediaType() {
final types = LibraryMediaType.values;
final currentIndex = types.indexOf(_selectedMediaType);
final nextIndex = (currentIndex + 1) % types.length;
_changeMediaType(types[nextIndex], goToLastTab: false);
}
void _switchToPreviousMediaType() {
final types = LibraryMediaType.values;
final currentIndex = types.indexOf(_selectedMediaType);
final prevIndex = (currentIndex - 1 + types.length) % types.length;
_changeMediaType(types[prevIndex], goToLastTab: true);
}
// Drag handlers for single-category types
void _onHorizontalDragStart(DragStartDetails details) {
_horizontalDragStart = details.globalPosition.dx;
_horizontalDragDelta = 0;
}
void _onHorizontalDragUpdate(DragUpdateDetails details) {
_horizontalDragDelta = details.globalPosition.dx - _horizontalDragStart;
}
void _onHorizontalDragEnd(DragEndDetails details) {
// Swipe right (positive delta) = go to previous type
// Swipe left (negative delta) = go to next type
if (_horizontalDragDelta > _overscrollThreshold) {
_switchToPreviousMediaType();
} else if (_horizontalDragDelta < -_overscrollThreshold) {
_switchToNextMediaType();
}
_horizontalDragDelta = 0;
}
/// Handle scroll notifications to hide/show filter bars
bool _handleScrollNotification(ScrollNotification notification) {
// Don't hide while dragging letter scrollbar
if (_isLetterScrollbarDragging) {
return false;
}
// Only respond to vertical scroll (not horizontal PageView swipe)
if (notification.metrics.axis != Axis.vertical) {
return false;
}
if (notification is ScrollUpdateNotification) {
final currentOffset = notification.metrics.pixels;
final delta = currentOffset - _lastScrollOffset;
if (delta.abs() > _scrollThreshold) {
final shouldShow = delta < 0 || currentOffset <= 0;
if (shouldShow != _isFilterBarVisible) {
setState(() {
_isFilterBarVisible = shouldShow;
});
}
_lastScrollOffset = currentOffset;
}
}
return false;
}
/// Map sort order to scrollbar display mode
ScrollbarDisplayMode _getScrollbarDisplayMode(String sortOrder) {
switch (sortOrder) {
// Alphabetical sorts -> show letter
case 'name':
case 'name_desc':
case 'sort_name':
case 'sort_name_desc':
case 'artist_name':
case 'artist_name_desc':
case 'alpha':
case 'alpha_desc':
case 'books': // Series sorted by book count - show letter as fallback
return ScrollbarDisplayMode.letter;
// Year sorts -> show year
case 'year':
case 'year_desc':
return ScrollbarDisplayMode.year;
// Sorts without displayable values -> hide popup
case 'timestamp_added':
case 'timestamp_added_desc':
case 'timestamp_modified':
case 'timestamp_modified_desc':
case 'last_played':
case 'last_played_desc':
case 'play_count':
case 'play_count_desc':
case 'duration':
case 'duration_desc':
return ScrollbarDisplayMode.none;
default:
return ScrollbarDisplayMode.letter;
}
}
void _onLetterScrollbarDragChanged(bool isDragging) {
setState(() {
_isLetterScrollbarDragging = isDragging;
// Show the filter bar when starting to drag
if (isDragging) {
_isFilterBarVisible = true;
}
});
}
void _animateToCategory(int index) {
if (_pageController.hasClients) {
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
// Update both for persistence and UI
_selectedTabIndex.value = index;
_tabIndexNotifier.value = index;
}
@override
void dispose() {
_colorExtractionDebounce?.cancel();
_providerFilterDebounce?.cancel();
_closeOptionsMenu();
navigationProvider.removeListener(_onNavigationChanged);
_pageController.dispose();
_selectedTabIndex.dispose();
_tabIndexNotifier.dispose();
_artistsScrollController.dispose();
_albumsScrollController.dispose();
_playlistsScrollController.dispose();
_tracksScrollController.removeListener(_onTracksScroll);
_tracksScrollController.dispose();
_authorsScrollController.dispose();
_audiobooksScrollController.dispose();
_seriesScrollController.dispose();
_podcastsScrollController.dispose();
_radioScrollController.dispose();
SyncService.instance.removeListener(_onSyncServiceChanged);
super.dispose();
}
Future<void> _loadPlaylists({bool? favoriteOnly}) async {
final maProvider = context.read<MusicAssistantProvider>();
final syncService = SyncService.instance;
final enabledProviders = maProvider.enabledProviderIds.toSet();
// Fetch playlists with sort order from server
final serverPlaylists = await maProvider.getPlaylists(
favoriteOnly: favoriteOnly,
orderBy: _playlistsSortOrder,
);
// Update SyncService cache with sorted data
if (serverPlaylists.isNotEmpty) {
syncService.updateCachedPlaylists(serverPlaylists);
}
// Apply provider filtering client-side
List<Playlist> playlists;
if (enabledProviders.isNotEmpty && serverPlaylists.isNotEmpty) {
playlists = serverPlaylists.where((p) {
final sources = syncService.playlistSourceProviders[p.itemId];
if (sources == null || sources.isEmpty) return true; // Show if no tracking
return sources.any((s) => enabledProviders.contains(s));
}).toList();
} else if (serverPlaylists.isNotEmpty) {
playlists = serverPlaylists;
} else {
// Fallback to cached data if server returned nothing
playlists = enabledProviders.isNotEmpty
? syncService.getPlaylistsFilteredByProviders(enabledProviders)
: syncService.cachedPlaylists;
if (favoriteOnly == true) {
playlists = playlists.where((p) => p.favorite == true).toList();
}
}
if (mounted) {
setState(() {
_playlists = playlists;
_sortPlaylists();
_isLoadingPlaylists = false;
});
}
}
/// Update sorted playlists list from server-sorted data
/// Server handles: name, name_desc, timestamp_added, timestamp_added_desc,
/// timestamp_modified, timestamp_modified_desc, last_played, last_played_desc, play_count, play_count_desc
void _sortPlaylists() {
// Trust server-side sorting - don't re-sort client-side
_sortedPlaylists = _playlists;
_playlistNames = _playlists.map((p) => p.name ?? '').toList();
}
Future<void> _loadFavoriteTracks() async {
if (_isLoadingTracks) return;
setState(() {
_isLoadingTracks = true;
});
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
final tracks = await maProvider.api!.getTracks(
limit: 500,
favoriteOnly: true,
);
if (mounted) {
// PERF: Pre-sort once on load, not on every build
final sorted = List<Track>.from(tracks)
..sort((a, b) {
final artistCompare = a.artistsString.compareTo(b.artistsString);
if (artistCompare != 0) return artistCompare;
return a.name.compareTo(b.name);
});
setState(() {
_favoriteTracks = tracks;
_sortedFavoriteTracks = sorted;
_isLoadingTracks = false;
});
}
} else {
if (mounted) {
setState(() {
_isLoadingTracks = false;
});
}
}
}
/// Load all tracks with lazy loading (initial batch)
Future<void> _loadAllTracks() async {
if (_isLoadingTracks) return;
setState(() {
_isLoadingTracks = true;
_tracksOffset = 0;
_hasMoreTracks = true;
});
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
final tracks = await maProvider.api!.getTracks(
limit: _tracksInitialLoad,
offset: 0,
orderBy: _tracksSortOrder,
);
if (mounted) {
setState(() {
_allTracks = tracks;
// Server returns pre-sorted data, but keep client sort for fallback
_sortAllTracks();
_tracksOffset = tracks.length;
_hasMoreTracks = tracks.length >= _tracksInitialLoad;
_isLoadingTracks = false;
});
}
} else {
if (mounted) {
setState(() {
_isLoadingTracks = false;
});
}
}
}
/// Load more tracks when scrolling near bottom
Future<void> _loadMoreTracks() async {
if (_isLoadingMoreTracks || !_hasMoreTracks) return;
setState(() {
_isLoadingMoreTracks = true;
});
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
final tracks = await maProvider.api!.getTracks(
limit: _tracksPageSize,
offset: _tracksOffset,
orderBy: _tracksSortOrder,
);
if (mounted) {
setState(() {
_allTracks.addAll(tracks);
// Server returns pre-sorted data, but keep client sort for fallback
_sortAllTracks();
_tracksOffset += tracks.length;
_hasMoreTracks = tracks.length >= _tracksPageSize;
_isLoadingMoreTracks = false;
});
}
} else {
if (mounted) {
setState(() {
_isLoadingMoreTracks = false;
});
}
}
}
/// Update sorted tracks list from server-sorted data
/// Server handles: name, name_desc, duration, duration_desc, timestamp_added, timestamp_added_desc,
/// last_played, last_played_desc, play_count, play_count_desc
void _sortAllTracks() {
// Trust server-side sorting - don't re-sort client-side
_sortedAllTracks = _allTracks;
_trackNames = _allTracks.map((t) => t.name).toList();
}
final _logger = DebugLogger();
Future<void> _loadAudiobooks({bool? favoriteOnly}) async {
_logger.log('📚 _loadAudiobooks called, favoriteOnly=$favoriteOnly');
if (_isLoadingAudiobooks) {
_logger.log('📚 Already loading, skipping');
return;
}
setState(() {
_isLoadingAudiobooks = true;
});
final maProvider = context.read<MusicAssistantProvider>();
final syncService = SyncService.instance;
final enabledProviders = maProvider.enabledProviderIds.toSet();
List<Audiobook> audiobooks;
// Use SyncService's client-side filtering for instant updates with source tracking
if (enabledProviders.isNotEmpty) {
_logger.log('📚 Using SyncService client-side filtering');
audiobooks = syncService.getAudiobooksFilteredByProviders(enabledProviders);
// Apply favorite filter client-side
if (favoriteOnly == true) {
audiobooks = audiobooks.where((b) => b.favorite == true).toList();
}
} else {
// No provider filter active - use cached audiobooks
_logger.log('📚 Using cached audiobooks (no filter)');
audiobooks = syncService.cachedAudiobooks;
// Apply favorite filter client-side
if (favoriteOnly == true) {
audiobooks = audiobooks.where((b) => b.favorite == true).toList();
}
}
_logger.log('📚 Returned ${audiobooks.length} audiobooks');
if (audiobooks.isNotEmpty) {
_logger.log('📚 First audiobook: ${audiobooks.first.name} by ${audiobooks.first.authorsString}');
}
if (mounted) {
// Group audiobooks by author
final authorMap = <String, List<Audiobook>>{};
for (final book in audiobooks) {
final authorName = book.authorsString;
authorMap.putIfAbsent(authorName, () => []).add(book);
}
final sortedAuthors = authorMap.keys.toList()..sort();
setState(() {
_audiobooks = audiobooks;
_groupedAudiobooksByAuthor = authorMap;
_sortedAuthorNames = sortedAuthors;
_isLoadingAudiobooks = false;
// Apply current sort order (respects _audiobooksSortOrder)
_sortAudiobooks();
});
_logger.log('📚 State updated, _audiobooks.length = ${_audiobooks.length}');
// Fetch author images in background
_fetchAuthorImages(audiobooks);
}
}
Future<void> _fetchAuthorImages(List<Audiobook> audiobooks) async {
// Get unique author display strings and their primary author for image lookup
final authorEntries = <String, String>{}; // displayName -> primaryAuthorName
for (final book in audiobooks) {
final displayName = book.authorsString;
if (!authorEntries.containsKey(displayName)) {
// Use first author's name for image lookup (API search works better with single names)
final primaryAuthor = book.authors?.isNotEmpty == true
? book.authors!.first.name
: displayName;
authorEntries[displayName] = primaryAuthor;
}
}
// Fetch images for authors not already cached
for (final entry in authorEntries.entries) {
final displayName = entry.key;
final lookupName = entry.value;
if (!_authorImages.containsKey(displayName)) {
// Mark as loading to avoid duplicate requests
_authorImages[displayName] = null;
// Fetch in background using primary author name
MetadataService.getAuthorImageUrl(lookupName).then((imageUrl) {
if (mounted && imageUrl != null) {
setState(() {
_authorImages[displayName] = imageUrl;
});
}
});
}
}
}
/// Load audiobook series from Music Assistant
Future<void> _loadSeries() async {
if (_isLoadingSeries) return;
setState(() {
_isLoadingSeries = true;
});
try {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
final series = await maProvider.api!.getAudiobookSeries();
_logger.log('📚 Loaded ${series.length} series');
if (mounted) {
// PERF: Pre-sort once on load, not on every build
final sorted = List<AudiobookSeries>.from(series)
..sort((a, b) => a.name.compareTo(b.name));
setState(() {
_series = series;
_sortedSeries = sorted;
_seriesNames = sorted.map((s) => s.name).toList();
_isLoadingSeries = false;
_seriesLoaded = true;
});
}
} else {
if (mounted) {
setState(() {
_isLoadingSeries = false;
});
}
}
} catch (e) {
_logger.log('📚 Error loading series: $e');
if (mounted) {
setState(() {
_isLoadingSeries = false;
});
}
}
}
/// Fetch book covers for a series (for 3x3 grid display)
Future<void> _loadSeriesCovers(String seriesId, MusicAssistantProvider maProvider) async {
// Already cached or loading
if (_seriesBookCovers.containsKey(seriesId) || _seriesCoversLoading.contains(seriesId)) {
return;
}
_seriesCoversLoading.add(seriesId);
try {
if (maProvider.api != null) {
final books = await maProvider.api!.getSeriesAudiobooks(seriesId);
final covers = <String>[];
for (final book in books.take(9)) {
final imageUrl = maProvider.getImageUrl(book);
if (imageUrl != null) {
covers.add(imageUrl);
}
}
if (mounted) {
setState(() {
_seriesBookCovers[seriesId] = covers;
_seriesBookCounts[seriesId] = books.length;
_seriesCoversLoading.remove(seriesId);
});
// Precache images for smooth hero animations
_precacheSeriesCovers(covers);
// PERF: Queue color extraction with debounce to avoid blocking UI during scroll
_queueColorExtraction(seriesId, covers);
}
}
} catch (e) {
_logger.log('📚 Error loading series covers for $seriesId: $e');
_seriesCoversLoading.remove(seriesId);
}
}
/// Precache series cover images for smooth hero animations
void _precacheSeriesCovers(List<String> covers) {
if (!mounted) return;
for (final url in covers) {
precacheImage(
CachedNetworkImageProvider(url),
context,
).catchError((_) => false);
}
}
/// PERF: Queue color extraction requests - processed after scroll settles
void _queueColorExtraction(String seriesId, List<String> coverUrls) {
if (coverUrls.isEmpty) return;
// Add to pending queue
_pendingColorExtractions[seriesId] = coverUrls;
// Cancel existing timer and start a new one
_colorExtractionDebounce?.cancel();
_colorExtractionDebounce = Timer(const Duration(milliseconds: 300), () {
_processQueuedColorExtractions();
});
}
/// PERF: Process all queued color extractions in batch after scroll settles
Future<void> _processQueuedColorExtractions() async {
if (_pendingColorExtractions.isEmpty || !mounted) return;
// Copy and clear the queue to avoid processing new items added during extraction
final toProcess = Map<String, List<String>>.from(_pendingColorExtractions);
_pendingColorExtractions.clear();
// Process each series sequentially to avoid overwhelming the UI thread
for (final entry in toProcess.entries) {
if (!mounted) break;
await _extractSeriesColors(entry.key, entry.value);
// Small yield between extractions to keep UI responsive
await Future.delayed(const Duration(milliseconds: 50));
}
}
/// Extract dominant colors from series book covers for empty cell backgrounds
Future<void> _extractSeriesColors(String seriesId, List<String> coverUrls) async {
if (coverUrls.isEmpty || !mounted) return;
final extractedColors = <Color>[];
// Extract colors from first few covers (limit to avoid too much processing)
for (final url in coverUrls.take(4)) {
if (!mounted) break;
try {
final palette = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(url),
maximumColorCount: 8,
);
// Get dark muted colors for grid squares (matches the aesthetic)
if (palette.darkMutedColor != null) {
extractedColors.add(palette.darkMutedColor!.color);
}
if (palette.mutedColor != null) {
extractedColors.add(palette.mutedColor!.color);
}
if (palette.darkVibrantColor != null) {
extractedColors.add(palette.darkVibrantColor!.color);
}
if (palette.dominantColor != null) {
// Darken the dominant color for better appearance
final hsl = HSLColor.fromColor(palette.dominantColor!.color);
extractedColors.add(hsl.withLightness((hsl.lightness * 0.4).clamp(0.1, 0.3)).toColor());
}
} catch (e) {
_logger.log('📚 Error extracting colors from $url: $e');
}
}
if (extractedColors.isNotEmpty && mounted) {
setState(() {
_seriesExtractedColors[seriesId] = extractedColors;
});
}
}
void _toggleFavoritesMode(bool value) {
setState(() {
_showFavoritesOnly = value;
});
_resetCategoryIndex();
if (value) {
_loadPlaylists(favoriteOnly: true);
_loadFavoriteTracks();
_loadAudiobooks(favoriteOnly: true);
} else {
_loadPlaylists();
_loadAudiobooks();
}
}
@override
Widget build(BuildContext context) {
final l10n = S.of(context)!;
// Use Selector for targeted rebuilds - only rebuild when connection state changes
return Selector<MusicAssistantProvider, bool>(
selector: (_, provider) => provider.isConnected,
builder: (context, isConnected, _) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final syncService = SyncService.instance;
// Show cached data even when not connected (if we have cache)
// Only show disconnected state if we have no cached data at all
if (!isConnected && !syncService.hasCache) {
return Scaffold(
backgroundColor: colorScheme.background,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Text(
l10n.library,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
),
),
centerTitle: true,
),
body: DisconnectedState.withSettingsAction(
context: context,
onSettings: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
),
);
}
return Scaffold(
backgroundColor: colorScheme.background,
body: SafeArea(
child: Column(
children: [
// Two-row filter: Row 1 = Media types (hides on scroll), Row 2 = Sub-categories (always visible)
_buildFilterRows(colorScheme, l10n, showLibraryTypeRow: _isFilterBarVisible),
// Connecting banner when showing cached data
// Hide when we have cached players - UI is functional during background reconnect
if (!isConnected && syncService.hasCache && !context.read<MusicAssistantProvider>().hasCachedPlayers)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: colorScheme.primaryContainer.withOpacity(0.5),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
),
const SizedBox(width: 8),
Text(
l10n.connecting,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onPrimaryContainer,
),
),
],
),
),
Expanded(
child: Stack(
children: [
// Main scrollable content
// For single-category types, wrap with GestureDetector for swipe detection
// (PageView with 1 item doesn't generate overscroll)
_tabCount == 1
? GestureDetector(
onHorizontalDragStart: _onHorizontalDragStart,
onHorizontalDragUpdate: _onHorizontalDragUpdate,
onHorizontalDragEnd: _onHorizontalDragEnd,
child: NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: _buildTabAtIndex(context, l10n, 0),
),
)
: NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
// PERF: Use PageView.builder to only build visible tabs
// Wrapped with horizontal overscroll detection for media type switching
child: NotificationListener<ScrollNotification>(
onNotification: _handleHorizontalOverscroll,
child: PageView.builder(
controller: _pageController,
onPageChanged: _onPageChanged,
itemCount: _tabCount,
// Use default physics - custom physics may cause issues with Flutter 3.38
physics: const PageScrollPhysics(),
itemBuilder: (context, index) => _buildTabAtIndex(context, l10n, index),
),
),
),
// Fade gradient at top - content fades as it scrolls under filter bar
Positioned(
top: 0,
left: 0,
right: 0,
height: 24,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.background,
colorScheme.background.withOpacity(0),
],
),
),
),
),
),
],
),
),
],
),
),
);
},
);
}
// ============ FILTER ROWS ============
// Consistent height for filter rows
static const double _filterRowHeight = 36.0;
Widget _buildFilterRows(ColorScheme colorScheme, S l10n, {required bool showLibraryTypeRow}) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Row 1: Media type chips (hides when scrolling)
AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
height: showLibraryTypeRow ? _filterRowHeight : 0,
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: _buildMediaTypeChips(colorScheme, l10n),
),
),
if (showLibraryTypeRow) const SizedBox(height: 12), // Space between rows
// Row 2: Sub-category chips (left) + action buttons (right) - always visible
SizedBox(
height: _filterRowHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Left: category chips - wrapped in ValueListenableBuilder for efficient rebuilds
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ValueListenableBuilder<int>(
valueListenable: _tabIndexNotifier,
builder: (context, selectedIndex, _) {
return _buildCategoryChips(colorScheme, l10n, selectedIndex);
},
),
),
),
// Right: options menu
_buildLibraryOptionsMenu(colorScheme),
],
),
),
),
],
);
}
Widget _buildMediaTypeChips(ColorScheme colorScheme, S l10n) {
String getMediaTypeLabel(LibraryMediaType type) {
switch (type) {
case LibraryMediaType.music:
return l10n.music;
case LibraryMediaType.books:
return l10n.audiobooks;
case LibraryMediaType.podcasts:
return l10n.podcasts;
case LibraryMediaType.radio:
return l10n.radio;
}
}
IconData getMediaTypeIcon(LibraryMediaType type) {
switch (type) {
case LibraryMediaType.music:
return MdiIcons.musicNote;
case LibraryMediaType.books:
return MdiIcons.bookOpenPageVariant;
case LibraryMediaType.podcasts:
return MdiIcons.podcast;
case LibraryMediaType.radio:
return MdiIcons.radio;
}
}
final types = LibraryMediaType.values;
// Calculate flex values based on label length
// Longer labels get more space to avoid clipping
// Base flex of 10 for icon + padding, plus label length
int getFlexForType(LibraryMediaType type) {
final label = getMediaTypeLabel(type);
// Base space for icon/padding + proportional text space
return 10 + label.length;
}
// Pre-calculate flex values and total
final flexValues = types.map((t) => getFlexForType(t)).toList();
final totalFlex = flexValues.reduce((a, b) => a + b);
final selectedIndex = types.indexOf(_selectedMediaType);
// Horizontal inset for the pill highlight (gap between adjacent tabs)
const double hInset = 2.0;
final isFirstTab = selectedIndex == 0;
final isLastTab = selectedIndex == types.length - 1;
// Animated sliding highlight with pill shape
return LayoutBuilder(
builder: (context, constraints) {
final totalWidth = constraints.maxWidth;
// Calculate left position for a given tab index
double getLeftPosition(int index) {
double left = 0;
for (int i = 0; i < index; i++) {
left += (flexValues[i] / totalFlex) * totalWidth;
}
return left;
}
// Calculate width for a given tab index
double getTabWidth(int index) {
return (flexValues[index] / totalFlex) * totalWidth;
}
// Calculate highlight position and size based on whether it's at the edge
// First tab: flush with left edge, gap on right
// Last tab: gap on left, flush with right edge
// Middle tabs: gap on both sides
final leftInset = isFirstTab ? 0.0 : hInset;
final rightInset = isLastTab ? 0.0 : hInset;
final highlightLeft = getLeftPosition(selectedIndex) + leftInset;
final highlightWidth = getTabWidth(selectedIndex) - leftInset - rightInset;
return Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withOpacity(0.5),
borderRadius: BorderRadius.circular(8),
),
child: Stack(
children: [
// Animated sliding highlight (pill shape with full border radius)
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
left: highlightLeft,
width: highlightWidth,
top: 0,
bottom: 0,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
decoration: BoxDecoration(
color: _getMediaTypeColor(colorScheme, _selectedMediaType),
borderRadius: BorderRadius.circular(8),
),
),
),
// Tab buttons row (transparent, on top of highlight)
Row(
children: types.asMap().entries.map((entry) {
final index = entry.key;
final type = entry.value;
final isSelected = _selectedMediaType == type;
return Expanded(
flex: flexValues[index],
child: GestureDetector(
onTap: () => _changeMediaType(type),
behavior: HitTestBehavior.opaque,
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
getMediaTypeIcon(type),
size: 18,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 6),
Flexible(
child: Text(
getMediaTypeLabel(type),
style: TextStyle(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurface.withOpacity(0.7),
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
fontSize: 14,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
);
}).toList(),
),
],
),
);
},
);
}
// ============ SORT FUNCTIONALITY ============
/// Get current sort order based on media type and tab index
String _getCurrentSortOrder() {
final tabIndex = _selectedTabIndex.value;
switch (_selectedMediaType) {
case LibraryMediaType.music:
switch (tabIndex) {
case 0: return _artistsSortOrder;
case 1: return _albumsSortOrder;
case 2: return _tracksSortOrder;
case 3: return _playlistsSortOrder;
default: return 'alpha';
}
case LibraryMediaType.books:
switch (tabIndex) {
case 0: return _authorsSortOrder;
case 1: return _audiobooksSortOrder;
case 2: return _seriesSortOrder;
default: return 'alpha';
}
case LibraryMediaType.radio:
return _radioSortOrder;
case LibraryMediaType.podcasts:
return _podcastsSortOrder;
}
}
/// Set sort order and persist to settings
Future<void> _setSortOrder(String order) async {
final tabIndex = _selectedTabIndex.value;
final maProvider = context.read<MusicAssistantProvider>();
setState(() {
switch (_selectedMediaType) {
case LibraryMediaType.music:
switch (tabIndex) {
case 0:
_artistsSortOrder = order;
// Artists use server-side sorting - reload will happen below
break;
case 1:
_albumsSortOrder = order;
// Albums use server-side sorting - reload will happen below
break;
case 2:
_tracksSortOrder = order;
// Tracks use server-side sorting - reload will happen below
break;
case 3:
_playlistsSortOrder = order;
// Playlists use server-side sorting - reload will happen below
break;
}
break;
case LibraryMediaType.books:
switch (tabIndex) {
case 0:
_authorsSortOrder = order;
// Authors sorted inline in tab builder - setState triggers rebuild
break;
case 1:
_audiobooksSortOrder = order;
_sortAudiobooks();
break;
case 2:
_seriesSortOrder = order;
// Series sorted inline in tab builder - setState triggers rebuild
break;
}
break;
case LibraryMediaType.radio:
_radioSortOrder = order;
// Radio uses server-side sorting - reload will happen below
break;
case LibraryMediaType.podcasts:
_podcastsSortOrder = order;
// Podcasts use server-side sorting - reload will happen below
break;
}
});
// Trigger server-side reload for sorts that need it
switch (_selectedMediaType) {
case LibraryMediaType.music:
switch (tabIndex) {
case 0: // Artists
maProvider.loadArtists(orderBy: order);
break;
case 1: // Albums
maProvider.loadAlbums(orderBy: order);
break;
case 2: // Tracks
_loadAllTracks();
break;
case 3: // Playlists
_loadPlaylists(favoriteOnly: _showFavoritesOnly ? true : null);
break;
}
break;
case LibraryMediaType.radio:
maProvider.loadRadioStations(orderBy: order);
break;
case LibraryMediaType.podcasts:
maProvider.loadPodcasts(orderBy: order);
break;
case LibraryMediaType.books:
// Books use client-side sorting (no server-side support)
break;
}
// Persist to settings
switch (_selectedMediaType) {
case LibraryMediaType.music:
switch (tabIndex) {
case 0: await SettingsService.setLibraryArtistsSortOrder(order); break;
case 1: await SettingsService.setLibraryAlbumsSortOrder(order); break;
case 2: await SettingsService.setLibraryTracksSortOrder(order); break;
case 3: await SettingsService.setLibraryPlaylistsSortOrder(order); break;
}
break;
case LibraryMediaType.books:
switch (tabIndex) {
case 0: await SettingsService.setLibraryAuthorsSortOrder(order); break;
case 1: await SettingsService.setLibraryAudiobooksSortOrder(order); break;
case 2: await SettingsService.setLibrarySeriesSortOrder(order); break;
}
break;
case LibraryMediaType.radio:
await SettingsService.setLibraryRadioSortOrder(order);
break;
case LibraryMediaType.podcasts:
await SettingsService.setLibraryPodcastsSortOrder(order);
break;
}
}
// Single options menu combining sort, view mode, and favorites filter
Widget _buildLibraryOptionsMenu(ColorScheme colorScheme) {
final fadedCircleColor = colorScheme.surfaceVariant.withOpacity(0.6);
// Touch target meets Material Design 48x48 minimum
const double buttonSize = ButtonSizes.xl;
const double iconSize = ButtonSizes.iconXl;
return SizedBox(
key: _menuButtonKey,
width: buttonSize,
height: buttonSize,
child: Material(
color: _showFavoritesOnly ? StatusColors.favorite : fadedCircleColor,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => _showOptionsMenu(colorScheme),
// Show spinner on button while syncing (artists filter, libraries filter, provider filter, or initial library build)
child: (_isSyncingArtists || _isSyncingLibraries || _isSyncingProviders || SyncService.instance.isSyncing)
? Center(
child: SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: _showFavoritesOnly ? Colors.white : colorScheme.onSurface,
),
),
)
: Icon(
Icons.more_vert,
size: iconSize,
color: _showFavoritesOnly ? Colors.white : colorScheme.onSurface,
),
),
),
);
}
/// Show options menu that stays open on sort selection
void _showOptionsMenu(ColorScheme colorScheme) {
// Close any existing overlay first
_closeOptionsMenu();
final RenderBox? button = _menuButtonKey.currentContext?.findRenderObject() as RenderBox?;
if (button == null) return;
final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject() as RenderBox;
final Offset position = button.localToGlobal(Offset.zero, ancestor: overlay);
// Get providers relevant to the current tab
final maProvider = context.read<MusicAssistantProvider>();
final relevantProviders = maProvider.getRelevantProvidersForCategory(_getCurrentCategoryName());
// Use global provider filter (server-side filtering)
final enabledProviders = maProvider.enabledProviderIds.toSet();
_optionsMenuOverlay = OverlayEntry(
builder: (context) => _OptionsMenuOverlay(
position: position,
buttonSize: button.size,
colorScheme: colorScheme,
currentSort: _getCurrentSortOrder(),
currentViewMode: _getCurrentViewMode(),
hasViewModes: _currentTabHasViewModes(),
showFavoritesOnly: _showFavoritesOnly,
showOnlyArtistsWithAlbums: _showOnlyArtistsWithAlbums,
selectedMediaType: _selectedMediaType,
tabIndex: _selectedTabIndex.value,
sortFields: _getSortFieldsForCurrentTab(),
parseSortOrder: _parseSortOrder,
buildSortOrder: _buildSortOrder,
onSortChanged: (sort) {
_setSortOrder(sort);
},
onViewModeChanged: (mode) {
_setViewMode(mode);
},
onFavoritesToggled: () {
_toggleFavoritesMode(!_showFavoritesOnly);
},
onArtistsFilterToggled: () {
_toggleArtistsWithAlbumsFilter(!_showOnlyArtistsWithAlbums);
},
onDismiss: _closeOptionsMenu,
relevantProviders: relevantProviders,
enabledProviderIds: enabledProviders,
onProviderToggled: _handleProviderToggle,
// ABS library filter (Books tab only)
absLibraries: _discoveredAbsLibraries,
absLibraryEnabled: _absLibraryEnabled,
onAbsLibraryToggled: _toggleAbsLibrary,
),
);
Navigator.of(context).overlay!.insert(_optionsMenuOverlay!);
}
/// Toggle the "show only artists with albums" filter
Future<void> _toggleArtistsWithAlbumsFilter(bool value) async {
setState(() {
_showOnlyArtistsWithAlbums = value;
_isSyncingArtists = true;
});
await SettingsService.setShowOnlyArtistsWithAlbums(value);
// Force sync library to apply the new filter at API level
if (mounted) {
await context.read<MusicAssistantProvider>().forceLibrarySync();
if (mounted) {
setState(() => _isSyncingArtists = false);
}
}
}
/// Toggle an ABS library filter
Future<void> _toggleAbsLibrary(String libraryPath, bool enabled) async {
setState(() {
_absLibraryEnabled[libraryPath] = enabled;
_isSyncingLibraries = true;
});
await SettingsService.toggleAbsLibrary(libraryPath, enabled);
// Force sync library to apply the new filter
if (mounted) {
await context.read<MusicAssistantProvider>().forceLibrarySync();
if (mounted) {
// Reload audiobooks to reflect the new filter
await _loadAudiobooks(favoriteOnly: _showFavoritesOnly ? true : null);
if (mounted) {
setState(() => _isSyncingLibraries = false);
}
}
}
}
/// Set view mode directly (instead of cycling)
Future<void> _setViewMode(String mode) async {
final tabIndex = _selectedTabIndex.value;
setState(() {
switch (_selectedMediaType) {
case LibraryMediaType.music:
switch (tabIndex) {
case 0:
_artistsViewMode = mode;
break;
case 1:
_albumsViewMode = mode;
break;
case 2:
// Tracks - no view mode
break;
case 3:
_playlistsViewMode = mode;
break;
}
break;
case LibraryMediaType.books:
switch (tabIndex) {
case 0:
_authorsViewMode = mode;
break;
case 1:
_audiobooksViewMode = mode;
break;
case 2:
_seriesViewMode = mode;
break;
}
break;
case LibraryMediaType.radio:
_radioViewMode = mode;
break;
case LibraryMediaType.podcasts:
_podcastsViewMode = mode;
break;
}
});
// Persist to settings
switch (_selectedMediaType) {
case LibraryMediaType.music:
switch (tabIndex) {
case 0: await SettingsService.setLibraryArtistsViewMode(mode); break;
case 1: await SettingsService.setLibraryAlbumsViewMode(mode); break;
case 2: break; // Tracks - no view mode
case 3: await SettingsService.setLibraryPlaylistsViewMode(mode); break;
}
break;
case LibraryMediaType.books:
switch (tabIndex) {
case 0: await SettingsService.setLibraryAuthorsViewMode(mode); break;
case 1: await SettingsService.setLibraryAudiobooksViewMode(mode); break;
case 2: await SettingsService.setLibrarySeriesViewMode(mode); break;
}
break;
case LibraryMediaType.radio:
await SettingsService.setLibraryRadioViewMode(mode);
break;
case LibraryMediaType.podcasts:
await SettingsService.setLibraryPodcastsViewMode(mode);
break;
}
}
/// Check if current tab supports multiple view modes
bool _currentTabHasViewModes() {
// Tracks tab (Music index 2) only has list view
if (_selectedMediaType == LibraryMediaType.music && _selectedTabIndex.value == 2) {
return false;
}
return true;
}
/// Parse a sort order into base field and direction
/// e.g., "name_desc" -> ("name", true), "name" -> ("name", false)
(String baseField, bool isDescending) _parseSortOrder(String sortOrder) {
if (sortOrder.endsWith('_desc')) {
return (sortOrder.substring(0, sortOrder.length - 5), true);
}
return (sortOrder, false);
}
/// Build a sort order string from base field and direction
String _buildSortOrder(String baseField, bool descending) {
return descending ? '${baseField}_desc' : baseField;
}
/// Get sort field configurations for the current tab
/// Returns list of (baseField, label, icon, defaultDescending)
List<(String, String, IconData, bool)> _getSortFieldsForCurrentTab() {
final tabIndex = _selectedTabIndex.value;
switch (_selectedMediaType) {
case LibraryMediaType.music:
switch (tabIndex) {
case 0: // Artists
return [
('name', 'Name', Icons.sort_by_alpha, false),
('timestamp_added', 'Date Added', Icons.schedule, true),
('last_played', 'Last Played', Icons.play_circle_outline, true),
('play_count', 'Play Count', Icons.trending_up, true),
];
case 1: // Albums
return [
('name', 'Name', Icons.sort_by_alpha, false),
('year', 'Year', Icons.calendar_today, true),
('artist_name', 'Artist', Icons.person, false),
('timestamp_added', 'Date Added', Icons.schedule, true),
('last_played', 'Last Played', Icons.play_circle_outline, true),
('play_count', 'Play Count', Icons.trending_up, true),
];
case 2: // Tracks
return [
('name', 'Name', Icons.sort_by_alpha, false),
('duration', 'Duration', Icons.timer, false),
('timestamp_added', 'Date Added', Icons.schedule, true),
('last_played', 'Last Played', Icons.play_circle_outline, true),
('play_count', 'Play Count', Icons.trending_up, true),
];
case 3: // Playlists
return [
('name', 'Name', Icons.sort_by_alpha, false),
('timestamp_added', 'Date Added', Icons.schedule, true),
('timestamp_modified', 'Modified', Icons.edit, true),
('last_played', 'Last Played', Icons.play_circle_outline, true),
('play_count', 'Play Count', Icons.trending_up, true),
];
default:
return [];
}
case LibraryMediaType.books:
switch (tabIndex) {
case 0: // Authors
return [
('alpha', 'Name', Icons.sort_by_alpha, false),
('books', 'Book Count', Icons.format_list_numbered, true),
];
case 1: // All Books
return [
('alpha', 'Name', Icons.sort_by_alpha, false),
('year', 'Year', Icons.calendar_today, true),
];
case 2: // Series
return [
('alpha', 'Name', Icons.sort_by_alpha, false),
('books', 'Book Count', Icons.format_list_numbered, true),
];
default:
return [];
}
case LibraryMediaType.radio:
return [
('name', 'Name', Icons.sort_by_alpha, false),
('timestamp_added', 'Date Added', Icons.schedule, true),
('last_played', 'Last Played', Icons.play_circle_outline, true),
('play_count', 'Play Count', Icons.trending_up, true),
];
case LibraryMediaType.podcasts:
return [
('name', 'Name', Icons.sort_by_alpha, false),
('timestamp_added', 'Date Added', Icons.schedule, true),
('last_played', 'Last Played', Icons.play_circle_outline, true),
('play_count', 'Play Count', Icons.trending_up, true),
];
}
}
Widget _buildCategoryChips(ColorScheme colorScheme, S l10n, int selectedIndex) {
final categories = _getCategoryLabels(l10n);
// Fixed horizontal padding around each label (consistent spacing)
const double hPadding = 12.0;
const double hInset = 2.0;
// Measure actual text widths using TextPainter
final textStyle = TextStyle(fontSize: 14, fontWeight: FontWeight.w600);
final labelWidths = categories.map((label) {
final textPainter = TextPainter(
text: TextSpan(text: label, style: textStyle),
textDirection: TextDirection.ltr,
)..layout();
return textPainter.width + (hPadding * 2); // text width + padding on both sides
}).toList();
final totalWidth = labelWidths.reduce((a, b) => a + b);
// Calculate left position for a given index
double getLeftPosition(int index) {
double left = 0;
for (int i = 0; i < index; i++) {
left += labelWidths[i];
}
return left;
}
final isFirstTab = selectedIndex == 0;
final isLastTab = selectedIndex == categories.length - 1;
final leftInset = isFirstTab ? 0.0 : hInset;
final rightInset = isLastTab ? 0.0 : hInset;
final highlightLeft = getLeftPosition(selectedIndex) + leftInset;
final highlightWidth = labelWidths[selectedIndex] - leftInset - rightInset;
return Container(
width: totalWidth,
height: _filterRowHeight,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.6),
borderRadius: BorderRadius.circular(8),
),
child: Stack(
children: [
// Animated sliding highlight
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
left: highlightLeft,
width: highlightWidth,
top: 0,
bottom: 0,
child: Container(
decoration: BoxDecoration(
color: _getMediaTypeColor(colorScheme, _selectedMediaType),
borderRadius: BorderRadius.circular(8),
),
),
),
// Labels row
Row(
children: categories.asMap().entries.map((entry) {
final index = entry.key;
final label = entry.value;
final isSelected = selectedIndex == index;
return SizedBox(
width: labelWidths[index],
child: GestureDetector(
onTap: () => _animateToCategory(index),
behavior: HitTestBehavior.opaque,
child: Container(
alignment: Alignment.center,
child: Text(
label,
style: TextStyle(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant.withOpacity(0.8),
fontSize: 14,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
),
),
),
),
);
}).toList(),
),
],
),
);
}
List<String> _getCategoryLabels(S l10n) {
switch (_selectedMediaType) {
case LibraryMediaType.music:
return [
l10n.artists,
l10n.albums,
l10n.tracks, // Always visible now
l10n.playlists,
];
case LibraryMediaType.books:
return [
l10n.authors,
l10n.books,
l10n.series,
];
case LibraryMediaType.podcasts:
return [l10n.shows];
case LibraryMediaType.radio:
return [l10n.stations];
}
}
// ============ PAGE VIEWS ============
/// PERF: Build only the requested tab (for PageView.builder)
Widget _buildTabAtIndex(BuildContext context, S l10n, int index) {
switch (_selectedMediaType) {
case LibraryMediaType.music:
// Always 4 tabs: Artists, Albums, Tracks, Playlists
switch (index) {
case 0: return _buildArtistsTab(context, l10n);
case 1: return _buildAlbumsTab(context, l10n);
case 2: return _buildTracksTab(context, l10n);
case 3: return _buildPlaylistsTab(context, l10n);
default: return const SizedBox();
}
case LibraryMediaType.books:
switch (index) {
case 0: return _buildBooksAuthorsTab(context, l10n);
case 1: return _buildAllBooksTab(context, l10n);
case 2: return _buildSeriesTab(context, l10n);
default: return const SizedBox();
}
case LibraryMediaType.podcasts:
return _buildPodcastsTab(context, l10n);
case LibraryMediaType.radio:
return _buildRadioStationsTab(context, l10n);
}
}
// ============ BOOKS TABS ============
Widget _buildBooksAuthorsTab(BuildContext context, S l10n) {
// Use Selector to rebuild when enabledProviderIds changes
return Selector<MusicAssistantProvider, Set<String>>(
selector: (_, provider) => provider.enabledProviderIds.toSet(),
builder: (context, enabledProviders, _) {
final colorScheme = Theme.of(context).colorScheme;
final syncService = SyncService.instance;
if (_isLoadingAudiobooks) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
}
// Client-side filtering using SyncService source tracking for instant updates
var audiobooks = enabledProviders.isNotEmpty
? syncService.getAudiobooksFilteredByProviders(enabledProviders)
: syncService.cachedAudiobooks;
// Filter by favorites if enabled
if (_showFavoritesOnly) {
audiobooks = audiobooks.where((a) => a.favorite == true).toList();
}
if (audiobooks.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
context: context,
icon: Icons.favorite_border,
title: l10n.noFavoriteAudiobooks,
subtitle: l10n.tapHeartAudiobook,
);
}
return EmptyState.custom(
context: context,
icon: MdiIcons.bookOutline,
title: l10n.noAudiobooks,
subtitle: l10n.addAudiobooksHint,
onRefresh: () => _loadAudiobooks(),
);
}
// PERF: Use cached grouping/sorting - only re-sorts when data or sort order changes
final (sortedAuthorNames, groupedByAuthor) = _getSortedAuthors(audiobooks);
// Match music artists tab layout - no header, direct list/grid
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.background,
onRefresh: () => _loadAudiobooks(favoriteOnly: _showFavoritesOnly ? true : null),
child: LetterScrollbar(
controller: _authorsScrollController,
items: sortedAuthorNames,
displayMode: _getScrollbarDisplayMode(_authorsSortOrder),
onDragStateChanged: _onLetterScrollbarDragChanged,
bottomPadding: BottomSpacing.withMiniPlayer,
child: _authorsViewMode == 'list'
? ListView.builder(
controller: _authorsScrollController,
key: PageStorageKey<String>('books_authors_list_${enabledProviders.length}'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: sortedAuthorNames.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
itemBuilder: (context, index) {
final authorName = sortedAuthorNames[index];
return _buildAuthorListTile(authorName, groupedByAuthor[authorName]!, l10n);
},
)
: GridView.builder(
controller: _authorsScrollController,
key: PageStorageKey<String>('books_authors_grid_${enabledProviders.length}'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _authorsViewMode == 'grid3' ? 3 : 2,
childAspectRatio: _authorsViewMode == 'grid3' ? 0.75 : 0.80, // Match music artists
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: sortedAuthorNames.length,
itemBuilder: (context, index) {
final authorName = sortedAuthorNames[index];
return _buildAuthorCard(authorName, groupedByAuthor[authorName]!, l10n);
},
),
),
);
},
);
}
Widget _buildAuthorListTile(String authorName, List<Audiobook> books, S l10n) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final authorImageUrl = _authorImages[authorName];
final heroSuffix = _showFavoritesOnly ? '_fav' : '';
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: Hero(
tag: HeroTags.authorImage + authorName + '_library$heroSuffix',
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Stack(
alignment: Alignment.center,
children: [
// Icon always present underneath
Icon(
Icons.person_rounded,
color: colorScheme.onPrimaryContainer,
size: 28,
),
// Image covers icon when loaded
if (authorImageUrl != null)
ClipOval(
child: CachedNetworkImage(
imageUrl: authorImageUrl,
fit: BoxFit.cover,
width: 48,
height: 48,
// PERF: Match display size to avoid unnecessary memory usage
memCacheWidth: 128,
memCacheHeight: 128,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
// Transparent placeholder - icon shows through
placeholder: (_, __) => const SizedBox.shrink(),
// On error, shrink to show icon underneath
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
),
],
),
),
),
title: Text(
authorName,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${books.length} ${books.length == 1 ? l10n.audiobookSingular : l10n.audiobooks}',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
onTap: () => _navigateToAuthor(authorName, books, heroTagSuffix: 'library$heroSuffix', initialAuthorImageUrl: authorImageUrl),
);
}
Widget _buildAuthorCard(String authorName, List<Audiobook> books, S l10n) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final authorImageUrl = _authorImages[authorName];
final heroSuffix = _showFavoritesOnly ? '_fav' : '';
final iconSize = _authorsViewMode == 'grid3' ? 36.0 : 48.0;
// Match music artist card layout
return GestureDetector(
onTap: () => _navigateToAuthor(authorName, books, heroTagSuffix: 'library$heroSuffix', initialAuthorImageUrl: authorImageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Use LayoutBuilder to ensure proper circle (like music artists)
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
// Use the smaller dimension to ensure a circle
final size = constraints.maxWidth < constraints.maxHeight
? constraints.maxWidth
: constraints.maxHeight;
return Center(
child: Hero(
tag: HeroTags.authorImage + authorName + '_library$heroSuffix',
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Stack(
alignment: Alignment.center,
children: [
// Icon always present underneath
Icon(
Icons.person_rounded,
color: colorScheme.onPrimaryContainer,
size: iconSize,
),
// Image covers icon when loaded
if (authorImageUrl != null)
SizedBox(
width: size,
height: size,
child: ClipOval(
child: CachedNetworkImage(
imageUrl: authorImageUrl,
fit: BoxFit.cover,
width: size,
height: size,
// Only set width - height scales proportionally to preserve aspect ratio
memCacheWidth: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
// Transparent placeholder - icon shows through
placeholder: (_, __) => const SizedBox.shrink(),
// On error, shrink to show icon underneath
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
),
),
],
),
),
),
);
},
),
),
Spacing.vGap8,
Text(
authorName,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
),
);
}
void _navigateToAuthor(String authorName, List<Audiobook> books, {String? heroTagSuffix, String? initialAuthorImageUrl}) {
updateAdaptiveColorsFromImage(context, initialAuthorImageUrl);
Navigator.push(
context,
FadeSlidePageRoute(
child: AudiobookAuthorScreen(
authorName: authorName,
audiobooks: books,
heroTagSuffix: heroTagSuffix,
initialAuthorImageUrl: initialAuthorImageUrl,
),
),
);
}
Widget _buildAudiobookListTile(BuildContext context, Audiobook book, MusicAssistantProvider maProvider) {
final imageUrl = maProvider.getImageUrl(book, size: 128);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final heroSuffix = _showFavoritesOnly ? '_fav' : '';
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: Hero(
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_library$heroSuffix',
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
),
),
),
title: Hero(
tag: HeroTags.audiobookTitle + (book.uri ?? book.itemId) + '_library$heroSuffix',
child: Material(
color: Colors.transparent,
child: Text(
book.name,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
subtitle: Text(
book.authorsString,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: book.progress > 0
? SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
value: book.progress,
strokeWidth: 3,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
),
)
: null,
onTap: () => _navigateToAudiobook(book, heroTagSuffix: 'library$heroSuffix', initialImageUrl: imageUrl),
);
}
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix, String? initialImageUrl}) {
updateAdaptiveColorsFromImage(context, initialImageUrl);
Navigator.push(
context,
FadeSlidePageRoute(
child: AudiobookDetailScreen(
audiobook: book,
heroTagSuffix: heroTagSuffix,
initialImageUrl: initialImageUrl,
),
),
);
}
Widget _buildAllBooksTab(BuildContext context, S l10n) {
// Use Selector to rebuild when enabledProviderIds changes
return Selector<MusicAssistantProvider, Set<String>>(
selector: (_, provider) => provider.enabledProviderIds.toSet(),
builder: (context, enabledProviders, _) {
final colorScheme = Theme.of(context).colorScheme;
final maProvider = context.read<MusicAssistantProvider>();
final syncService = SyncService.instance;
if (_isLoadingAudiobooks) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
}
// Client-side filtering using SyncService source tracking for instant updates
var audiobooks = enabledProviders.isNotEmpty
? syncService.getAudiobooksFilteredByProviders(enabledProviders)
: List<Audiobook>.from(syncService.cachedAudiobooks);
// Filter by favorites if enabled
if (_showFavoritesOnly) {
audiobooks = audiobooks.where((a) => a.favorite == true).toList();
}
// Apply sort order (client-side for audiobooks)
switch (_audiobooksSortOrder) {
case 'year':
audiobooks.sort((a, b) {
if (a.year == null && b.year == null) return a.name.compareTo(b.name);
if (a.year == null) return 1;
if (b.year == null) return -1;
return a.year!.compareTo(b.year!);
});
break;
case 'year_desc':
audiobooks.sort((a, b) {
if (a.year == null && b.year == null) return a.name.compareTo(b.name);
if (a.year == null) return 1;
if (b.year == null) return -1;
return b.year!.compareTo(a.year!);
});
break;
case 'alpha_desc':
audiobooks.sort((a, b) => b.name.toLowerCase().compareTo(a.name.toLowerCase()));
break;
default: // 'alpha' or any other
audiobooks.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
}
if (audiobooks.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
context: context,
icon: Icons.favorite_border,
title: l10n.noFavoriteAudiobooks,
subtitle: l10n.tapHeartAudiobook,
);
}
return EmptyState.custom(
context: context,
icon: MdiIcons.bookOutline,
title: l10n.noAudiobooks,
subtitle: l10n.addAudiobooksHint,
onRefresh: () => _loadAudiobooks(),
);
}
final audiobookNames = audiobooks.map((a) => a.name).toList();
// Generate year labels for year sort mode
final audiobookYears = (_audiobooksSortOrder == 'year' || _audiobooksSortOrder == 'year_desc')
? audiobooks.map((a) => a.year?.toString() ?? '?').toList()
: null;
// Match music albums tab layout - no header, direct list/grid
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.background,
onRefresh: () => _loadAudiobooks(favoriteOnly: _showFavoritesOnly ? true : null),
child: LetterScrollbar(
controller: _audiobooksScrollController,
items: audiobookNames,
displayMode: _getScrollbarDisplayMode(_audiobooksSortOrder),
displayLabels: audiobookYears,
onDragStateChanged: _onLetterScrollbarDragChanged,
bottomPadding: BottomSpacing.withMiniPlayer,
child: _audiobooksViewMode == 'list'
? ListView.builder(
controller: _audiobooksScrollController,
key: PageStorageKey<String>('all_books_list_${_showFavoritesOnly ? 'fav' : 'all'}_${enabledProviders.length}_$_audiobooksSortOrder'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: audiobooks.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
itemBuilder: (context, index) {
return _buildAudiobookListTile(context, audiobooks[index], maProvider);
},
)
: GridView.builder(
controller: _audiobooksScrollController,
key: PageStorageKey<String>('all_books_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_audiobooksViewMode\_${enabledProviders.length}_$_audiobooksSortOrder'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _audiobooksViewMode == 'grid3' ? 3 : 2,
childAspectRatio: _audiobooksViewMode == 'grid3' ? 0.70 : 0.75, // Match music albums
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: audiobooks.length,
itemBuilder: (context, index) {
return _buildAudiobookCard(context, audiobooks[index], maProvider);
},
),
),
);
},
);
}
Widget _buildAudiobookCard(BuildContext context, Audiobook book, MusicAssistantProvider maProvider) {
final imageUrl = maProvider.getImageUrl(book, size: 256);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final heroSuffix = _showFavoritesOnly ? '_fav' : '';
return GestureDetector(
onTap: () => _navigateToAudiobook(book, heroTagSuffix: 'library$heroSuffix', initialImageUrl: imageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Square artwork with progress inside
AspectRatio(
aspectRatio: 1.0,
child: Stack(
children: [
Hero(
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_library$heroSuffix',
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: double.infinity,
height: double.infinity,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// PERF: Reduced from 512 to 128 - display is ~56px
memCacheWidth: 128,
memCacheHeight: 128,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.bookOutline,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.bookOutline,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
// Progress indicator overlay inside artwork
if (book.progress > 0)
Positioned(
bottom: 8,
left: 8,
right: 8,
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: book.progress,
backgroundColor: Colors.black38,
color: colorScheme.primary,
minHeight: 4,
),
),
),
// Provider icon overlay
if (book.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: book.providerMappings!.first.providerDomain,
),
],
),
),
Spacing.vGap8,
Hero(
tag: HeroTags.audiobookTitle + (book.uri ?? book.itemId) + '_library$heroSuffix',
child: Material(
color: Colors.transparent,
child: Text(
book.name,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(height: 2),
Text(
book.authorsString,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
Widget _buildSeriesTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final maProvider = context.read<MusicAssistantProvider>();
// Loading state
if (_isLoadingSeries) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: colorScheme.primary),
const SizedBox(height: 16),
Text(
l10n.loading,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 14,
),
),
],
),
);
}
// Empty state
if (_series.isEmpty) {
return RefreshIndicator(
onRefresh: _loadSeries,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.collections_bookmark_rounded,
size: 64,
color: colorScheme.primary.withOpacity(0.3),
),
const SizedBox(height: 16),
Text(
l10n.noSeriesFound,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
Spacing.vGap8,
Text(
_seriesLoaded
? l10n.noSeriesAvailable
: l10n.pullToLoadSeries,
textAlign: TextAlign.center,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 14,
),
),
const SizedBox(height: 24),
FilledButton.tonal(
onPressed: _loadSeries,
child: Text(l10n.loadSeries),
),
],
),
),
),
),
],
),
);
}
// Sort series based on current sort order
final sortedSeries = List<AudiobookSeries>.from(_sortedSeries);
switch (_seriesSortOrder) {
case 'alpha':
sortedSeries.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
break;
case 'alpha_desc':
sortedSeries.sort((a, b) => b.name.toLowerCase().compareTo(a.name.toLowerCase()));
break;
case 'books':
sortedSeries.sort((a, b) {
// Use cached book count if available, fallback to model's bookCount
final aCount = _seriesBookCounts[a.id] ?? a.bookCount ?? 0;
final bCount = _seriesBookCounts[b.id] ?? b.bookCount ?? 0;
if (bCount != aCount) return bCount.compareTo(aCount);
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
});
break;
}
final seriesNames = sortedSeries.map((s) => s.name).toList();
// Series view - supports grid2, grid3, and list modes
return RefreshIndicator(
onRefresh: _loadSeries,
child: LetterScrollbar(
controller: _seriesScrollController,
items: seriesNames,
displayMode: _getScrollbarDisplayMode(_seriesSortOrder),
onDragStateChanged: _onLetterScrollbarDragChanged,
bottomPadding: BottomSpacing.withMiniPlayer,
child: _seriesViewMode == 'list'
? ListView.builder(
controller: _seriesScrollController,
key: const PageStorageKey<String>('series_list'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: sortedSeries.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
itemBuilder: (context, index) {
return _buildSeriesListTile(context, sortedSeries[index], maProvider, l10n);
},
)
: GridView.builder(
controller: _seriesScrollController,
key: PageStorageKey<String>('series_grid_$_seriesViewMode'),
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _seriesViewMode == 'grid3' ? 3 : 2,
childAspectRatio: _seriesViewMode == 'grid3' ? 0.70 : 0.75,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: sortedSeries.length,
itemBuilder: (context, index) {
final series = sortedSeries[index];
return _buildSeriesCard(context, series, maProvider, l10n, maxCoverGridSize: _seriesViewMode == 'grid3' ? 2 : 3);
},
),
),
);
}
Widget _buildSeriesListTile(BuildContext context, AudiobookSeries series, MusicAssistantProvider maProvider, S l10n) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// Trigger loading of series covers if not cached
if (!_seriesBookCovers.containsKey(series.id) && !_seriesCoversLoading.contains(series.id)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadSeriesCovers(series.id, maProvider);
});
}
final covers = _seriesBookCovers[series.id];
final firstCover = covers != null && covers.isNotEmpty ? covers.first : null;
final heroTag = 'series_cover_${series.id}';
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: Hero(
tag: heroTag,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: firstCover != null
? CachedNetworkImage(
imageUrl: firstCover,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
placeholder: (_, __) => Icon(
Icons.collections_bookmark_rounded,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
Icons.collections_bookmark_rounded,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.collections_bookmark_rounded,
color: colorScheme.onSurfaceVariant,
),
),
),
),
title: Text(
series.name,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Builder(
builder: (context) {
final count = series.bookCount ?? _seriesBookCounts[series.id];
if (count == null) return const SizedBox.shrink();
return Text(
'$count ${count == 1 ? l10n.book : l10n.books}',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
);
},
),
onTap: () {
_logger.log('📚 Tapped series: ${series.name}, path: ${series.id}');
updateAdaptiveColorsFromImage(context, firstCover);
Navigator.push(
context,
FadeSlidePageRoute(
child: AudiobookSeriesScreen(
series: series,
heroTag: heroTag,
initialCovers: covers,
),
),
);
},
);
}
Widget _buildSeriesCard(BuildContext context, AudiobookSeries series, MusicAssistantProvider maProvider, S l10n, {int maxCoverGridSize = 3}) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// Trigger loading of series covers if not cached
if (!_seriesBookCovers.containsKey(series.id) && !_seriesCoversLoading.contains(series.id)) {
// Use addPostFrameCallback to avoid calling setState during build
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadSeriesCovers(series.id, maProvider);
});
}
// Matches books tab style - square artwork with text below
final heroTag = 'series_cover_${series.id}';
final cachedCovers = _seriesBookCovers[series.id];
return GestureDetector(
onTap: () {
_logger.log('📚 Tapped series: ${series.name}, path: ${series.id}');
updateAdaptiveColorsFromImage(context, cachedCovers?.firstOrNull);
Navigator.push(
context,
FadeSlidePageRoute(
child: AudiobookSeriesScreen(
series: series,
heroTag: heroTag,
initialCovers: cachedCovers,
),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Square cover grid with Hero animation
Hero(
tag: heroTag,
// RepaintBoundary caches the rendered grid for smooth animation
child: RepaintBoundary(
child: AspectRatio(
aspectRatio: 1.0,
child: ClipRRect(
borderRadius: BorderRadius.circular(12), // Match detail screen
child: Container(
color: colorScheme.surfaceVariant,
child: _buildSeriesCoverGrid(series, colorScheme, maProvider, maxGridSize: maxCoverGridSize),
),
),
),
),
),
Spacing.vGap8,
Text(
series.name,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
Builder(
builder: (context) {
final count = series.bookCount ?? _seriesBookCounts[series.id];
if (count == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'$count ${count == 1 ? l10n.book : l10n.books}',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
);
},
),
],
),
);
}
Widget _buildSeriesCoverGrid(AudiobookSeries series, ColorScheme colorScheme, MusicAssistantProvider maProvider, {int maxGridSize = 3}) {
final covers = _seriesBookCovers[series.id];
final isLoading = _seriesCoversLoading.contains(series.id);
// If we have covers, show the grid
if (covers != null && covers.isNotEmpty) {
// Determine grid size based on number of covers
// 1 cover = 1x1, 2-4 covers = 2x2, 5+ covers = 3x3
int gridSize;
if (covers.length == 1) {
gridSize = 1;
} else if (covers.length <= 4) {
gridSize = 2;
} else {
gridSize = 3;
}
// Respect maxGridSize parameter (for smaller displays like 3-column grid)
gridSize = gridSize.clamp(1, maxGridSize);
final displayCovers = covers.take(gridSize * gridSize).toList();
// Use extracted colors from book covers if available, otherwise fall back to static palette
final extractedColors = _seriesExtractedColors[series.id];
const fallbackColors = [
Color(0xFF2D3436), // Dark slate
Color(0xFF34495E), // Dark blue-grey
Color(0xFF4A3728), // Dark brown
Color(0xFF2C3E50), // Midnight blue
Color(0xFF3D3D3D), // Charcoal
Color(0xFF4A4458), // Dark purple-grey
Color(0xFF3E4A47), // Dark teal-grey
Color(0xFF4A3F35), // Dark warm grey
];
final emptyColors = (extractedColors != null && extractedColors.isNotEmpty)
? extractedColors
: fallbackColors;
// Use series ID to pick consistent colors for this series
final colorSeed = series.id.hashCode;
// Use simple Column/Row layout instead of GridView to avoid scroll-related animations
// No margins between cells for seamless appearance
return Column(
children: List.generate(gridSize, (row) {
return Expanded(
child: Row(
children: List.generate(gridSize, (col) {
final index = row * gridSize + col;
if (index >= displayCovers.length) {
// Empty cell - use nested grid pattern
return Expanded(
child: _buildEmptyCell(colorSeed, index, emptyColors),
);
}
return Expanded(
child: CachedNetworkImage(
imageUrl: displayCovers[index],
fit: BoxFit.cover,
// PERF: Add cache sizing for series cover thumbnails
memCacheWidth: 150,
memCacheHeight: 150,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Container(
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (_, __, ___) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.book,
color: colorScheme.onSurfaceVariant.withOpacity(0.3),
size: 20,
),
),
),
);
}),
),
);
}),
);
}
// Show loading shimmer or placeholder
if (isLoading) {
return _buildSeriesLoadingGrid(colorScheme);
}
// Fallback placeholder
return _buildSeriesPlaceholder(colorScheme);
}
Widget _buildSeriesLoadingGrid(ColorScheme colorScheme) {
// Static placeholder grid using Column/Row - no animations, no grid lines
return Container(
color: colorScheme.surfaceContainerHighest,
);
}
Widget _buildSeriesPlaceholder(ColorScheme colorScheme) {
return Center(
child: Icon(
Icons.collections_bookmark_rounded,
size: 48,
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
),
);
}
/// Builds an empty cell with either a solid color or a nested grid
/// The pattern is deterministic based on series ID and cell index
Widget _buildEmptyCell(int colorSeed, int cellIndex, List<Color> emptyColors) {
// Tone down colors - reduce saturation and darken
final colors = emptyColors.map((c) {
final hsl = HSLColor.fromColor(c);
return hsl
.withSaturation((hsl.saturation * 0.5).clamp(0.05, 0.25))
.withLightness((hsl.lightness * 0.7).clamp(0.08, 0.20))
.toColor();
}).toList();
// Use combined seed for deterministic but varied patterns
final seed = colorSeed + cellIndex * 17; // Prime multiplier for better distribution
// Determine nested grid size: 1 (solid), 2 (2x2), or 3 (3x3)
// Distribution: ~50% solid, ~30% 2x2, ~20% 3x3
final sizeRoll = seed.abs() % 100;
int nestedSize;
if (sizeRoll < 50) {
nestedSize = 1; // Solid color
} else if (sizeRoll < 80) {
nestedSize = 2; // 2x2 grid
} else {
nestedSize = 3; // 3x3 grid
}
if (nestedSize == 1) {
// Solid color
final colorIndex = seed.abs() % colors.length;
return Container(color: colors[colorIndex]);
}
// Build nested grid (no margins - seamless)
return Column(
children: List.generate(nestedSize, (row) {
return Expanded(
child: Row(
children: List.generate(nestedSize, (col) {
final nestedIndex = row * nestedSize + col;
// Use different seed for each nested cell
final nestedSeed = seed + nestedIndex * 7;
final colorIndex = nestedSeed.abs() % colors.length;
return Expanded(
child: Container(color: colors[colorIndex]),
);
}),
),
);
}),
);
}
// ============ PODCASTS TAB ============
Widget _buildPodcastsTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// PERF: Use select() to only rebuild when podcasts, loading state, or enabled providers changes
// Use podcastsUnfiltered to avoid double-filtering with MA's provider filter
final (allPodcasts, isLoading, enabledProviders) = context.select<MusicAssistantProvider, (List<MediaItem>, bool, Set<String>)>(
(p) => (p.podcastsUnfiltered, p.isLoadingPodcasts, p.enabledProviderIds.toSet()),
);
// Use read() for methods that don't need reactive updates
final maProvider = context.read<MusicAssistantProvider>();
if (isLoading) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
}
// Filter by enabled providers using providerMappings
final filteredPodcasts = enabledProviders.isNotEmpty
? allPodcasts.where((p) {
final mappings = p.providerMappings;
if (mappings == null || mappings.isEmpty) return false;
// Only match if the item is IN the library for an enabled provider
return mappings.any((m) => m.inLibrary && enabledProviders.contains(m.providerInstance));
}).toList()
: allPodcasts;
// Filter by favorites if enabled
final podcasts = _showFavoritesOnly
? filteredPodcasts.where((p) => p.favorite == true).toList()
: filteredPodcasts;
if (podcasts.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
context: context,
icon: Icons.favorite_border,
title: l10n.noFavoritePodcasts,
subtitle: l10n.longPressPodcastHint,
);
}
return EmptyState.custom(
context: context,
icon: MdiIcons.podcast,
title: l10n.noPodcasts,
subtitle: l10n.addPodcastsHint,
onRefresh: () => maProvider.loadPodcasts(),
);
}
// Trust server-side sorting - don't re-sort client-side
// Server handles: name, name_desc, timestamp_added_desc, last_played_desc, play_count_desc
final sortedPodcasts = podcasts;
// Pre-cache podcast images for smooth hero animations
_precachePodcastImages(sortedPodcasts, maProvider);
// PERF: Request larger images from API but decode at appropriate size for memory
// Use consistent 256 for all views to improve hero animation smoothness (matches detail screen)
const cacheSize = 256;
// Generate podcast names for letter scrollbar
final podcastNames = sortedPodcasts.map((p) => p.name).toList();
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.background,
onRefresh: () => maProvider.loadPodcasts(),
child: LetterScrollbar(
controller: _podcastsScrollController,
items: podcastNames,
displayMode: _getScrollbarDisplayMode(_podcastsSortOrder),
onDragStateChanged: _onLetterScrollbarDragChanged,
bottomPadding: BottomSpacing.withMiniPlayer,
child: _podcastsViewMode == 'list'
? ListView.builder(
controller: _podcastsScrollController,
key: const PageStorageKey<String>('podcasts_list'),
cacheExtent: 1000,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
itemCount: sortedPodcasts.length,
itemBuilder: (context, index) {
final podcast = sortedPodcasts[index];
// iTunes URL from persisted cache (loaded on app start for instant high-res)
final imageUrl = maProvider.getPodcastImageUrl(podcast);
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: Hero(
tag: HeroTags.podcastCover + (podcast.uri ?? podcast.itemId) + '_library',
// Match detail screen: ClipRRect(16) → Container → CachedNetworkImage
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
width: 56,
height: 56,
fit: BoxFit.cover,
// FIXED: Add memCacheWidth to ensure consistent decode size for smooth Hero
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
MdiIcons.podcast,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.podcast,
color: colorScheme.onSurfaceVariant,
),
),
),
),
title: Text(
podcast.name,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: podcast.metadata?['author'] != null
? Text(
podcast.metadata!['author'] as String,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
onTap: () => _openPodcastDetails(podcast, maProvider, imageUrl),
);
},
)
: GridView.builder(
controller: _podcastsScrollController,
key: PageStorageKey<String>('podcasts_grid_$_podcastsViewMode'),
cacheExtent: 1000,
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _podcastsViewMode == 'grid3' ? 3 : 2,
childAspectRatio: _podcastsViewMode == 'grid3' ? 0.75 : 0.80,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: sortedPodcasts.length,
itemBuilder: (context, index) {
final podcast = sortedPodcasts[index];
return _buildPodcastCard(podcast, maProvider, cacheSize);
},
),
),
);
}
Widget _buildPodcastCard(MediaItem podcast, MusicAssistantProvider maProvider, int cacheSize) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// iTunes URL from persisted cache (loaded on app start for instant high-res)
final imageUrl = maProvider.getPodcastImageUrl(podcast);
return GestureDetector(
onTap: () => _openPodcastDetails(podcast, maProvider, imageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
AspectRatio(
aspectRatio: 1.0,
child: Hero(
tag: HeroTags.podcastCover + (podcast.uri ?? podcast.itemId) + '_library',
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
// Match detail screen structure exactly
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// FIXED: Add memCacheWidth to ensure consistent decode size for smooth Hero
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.podcast,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (podcast.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: podcast.providerMappings!.first.providerDomain,
),
],
),
),
),
),
Spacing.vGap8,
Text(
podcast.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
height: 1.15,
),
),
],
),
);
}
void _openPodcastDetails(MediaItem podcast, MusicAssistantProvider maProvider, String? imageUrl) {
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
FadeSlidePageRoute(
child: PodcastDetailScreen(
podcast: podcast,
heroTagSuffix: 'library',
initialImageUrl: imageUrl,
),
),
);
}
/// Pre-cache podcast images so hero animations are smooth on first tap
void _precachePodcastImages(List<MediaItem> podcasts, MusicAssistantProvider maProvider) {
if (!mounted || _hasPrecachedPodcasts) return;
_hasPrecachedPodcasts = true;
// Only precache first ~10 visible items to avoid excessive network/memory use
final podcastsToCache = podcasts.take(10);
for (final podcast in podcastsToCache) {
// iTunes URL from persisted cache
final imageUrl = maProvider.getPodcastImageUrl(podcast);
if (imageUrl != null) {
// Use CachedNetworkImageProvider to warm the cache
precacheImage(
CachedNetworkImageProvider(imageUrl),
context,
).catchError((_) {
// Silently ignore precache errors
return false;
});
}
}
}
// ============ RADIO TAB ============
Widget _buildRadioStationsTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// PERF: Use select() to only rebuild when radio stations, loading state, or enabled providers changes
// Use radioStationsUnfiltered to avoid double-filtering with MA's provider filter
final (allRadioStations, isLoading, enabledProviders) = context.select<MusicAssistantProvider, (List<MediaItem>, bool, Set<String>)>(
(p) => (p.radioStationsUnfiltered, p.isLoadingRadio, p.enabledProviderIds.toSet()),
);
// Use read() for methods that don't need reactive updates
final maProvider = context.read<MusicAssistantProvider>();
if (isLoading) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
}
// Filter by enabled providers using providerMappings
final filteredRadioStations = enabledProviders.isNotEmpty
? allRadioStations.where((s) {
final mappings = s.providerMappings;
if (mappings == null || mappings.isEmpty) return false;
return mappings.any((m) => enabledProviders.contains(m.providerInstance));
}).toList()
: allRadioStations;
// Filter by favorites if enabled
final radioStations = _showFavoritesOnly
? filteredRadioStations.where((s) => s.favorite == true).toList()
: filteredRadioStations;
if (radioStations.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
context: context,
icon: Icons.favorite_border,
title: l10n.noFavoriteRadioStations,
subtitle: l10n.longPressRadioHint,
);
}
return EmptyState.custom(
context: context,
icon: MdiIcons.radio,
title: l10n.noRadioStations,
subtitle: l10n.addRadioStationsHint,
onRefresh: () => maProvider.loadRadioStations(),
);
}
// PERF: Use appropriate cache size based on view mode
final cacheSize = _radioViewMode == 'grid3' ? 200 : 256;
// Trust server-side sorting - don't re-sort client-side
// Server handles: name, name_desc, timestamp_added_desc, last_played_desc, play_count_desc
final sortedRadioStations = radioStations;
// Generate radio station names for letter scrollbar
final radioNames = sortedRadioStations.map((s) => s.name).toList();
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.background,
onRefresh: () => maProvider.loadRadioStations(),
child: LetterScrollbar(
controller: _radioScrollController,
items: radioNames,
displayMode: _getScrollbarDisplayMode(_radioSortOrder),
onDragStateChanged: _onLetterScrollbarDragChanged,
bottomPadding: BottomSpacing.withMiniPlayer,
child: _radioViewMode == 'list'
? ListView.builder(
controller: _radioScrollController,
key: const PageStorageKey<String>('radio_stations_list'),
cacheExtent: 1000,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
itemCount: sortedRadioStations.length,
itemBuilder: (context, index) {
final station = sortedRadioStations[index];
final imageUrl = maProvider.getImageUrl(station, size: cacheSize);
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
width: 56,
height: 56,
fit: BoxFit.cover,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => Container(
width: 56,
height: 56,
color: colorScheme.surfaceVariant,
child: Icon(MdiIcons.radio, color: colorScheme.onSurfaceVariant),
),
errorWidget: (context, url, error) => Container(
width: 56,
height: 56,
color: colorScheme.surfaceVariant,
child: Icon(MdiIcons.radio, color: colorScheme.onSurfaceVariant),
),
)
: Container(
width: 56,
height: 56,
color: colorScheme.surfaceVariant,
child: Icon(MdiIcons.radio, color: colorScheme.onSurfaceVariant),
),
),
title: Text(
station.name,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: station.metadata?['description'] != null
? Text(
station.metadata!['description'] as String,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
onTap: () => _playRadioStation(maProvider, station),
);
},
)
: GridView.builder(
controller: _radioScrollController,
key: PageStorageKey<String>('radio_stations_grid_$_radioViewMode'),
cacheExtent: 1000,
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _radioViewMode == 'grid3' ? 3 : 2,
childAspectRatio: _radioViewMode == 'grid3' ? 0.75 : 0.80,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: sortedRadioStations.length,
itemBuilder: (context, index) {
final station = sortedRadioStations[index];
return _buildRadioCard(station, maProvider, cacheSize);
},
),
),
);
}
Widget _buildRadioCard(MediaItem station, MusicAssistantProvider maProvider, int cacheSize) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final imageUrl = maProvider.getImageUrl(station, size: cacheSize);
return GestureDetector(
onTap: () => _playRadioStation(maProvider, station),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
AspectRatio(
aspectRatio: 1.0,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Center(
child: Icon(
MdiIcons.radio,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.radio,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
Spacing.vGap8,
Text(
station.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
height: 1.15,
),
),
],
),
);
}
void _playRadioStation(MusicAssistantProvider maProvider, MediaItem station) {
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer != null) {
maProvider.api?.playRadioStation(selectedPlayer.playerId, station);
}
}
// ============ ARTISTS TAB ============
Widget _buildArtistsTab(BuildContext context, S l10n) {
// Note: We don't use Selector here because we need to react to both
// MusicAssistantProvider changes AND SyncService changes (source tracking).
// The parent's _onSyncServiceChanged listener triggers setState() for SyncService updates.
final maProvider = context.watch<MusicAssistantProvider>();
final isLoading = maProvider.isLoading;
final enabledProviders = maProvider.enabledProviderIds.toSet();
final colorScheme = Theme.of(context).colorScheme;
final syncService = SyncService.instance;
// Show loading only if actually loading AND no cached data available
if (isLoading && syncService.cachedArtists.isEmpty) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
}
// Always use SyncService data - it has the complete library with source tracking
final filteredArtists = enabledProviders.isNotEmpty
? syncService.getArtistsFilteredByProviders(enabledProviders)
: syncService.cachedArtists;
// Filter by favorites if enabled
final artists = _showFavoritesOnly
? filteredArtists.where((a) => a.favorite == true).toList()
: filteredArtists;
if (artists.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
context: context,
icon: Icons.favorite_border,
title: l10n.noFavoriteArtists,
subtitle: l10n.tapHeartArtist,
);
}
return EmptyState.artists(
context: context,
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
}
// PERF: Use cached sorting - only re-sorts when data or sort order changes
final sortedArtists = _getSortedArtists(artists);
final artistNames = _cachedArtistNames;
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.background,
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
child: LetterScrollbar(
controller: _artistsScrollController,
items: artistNames,
displayMode: _getScrollbarDisplayMode(_artistsSortOrder),
onDragStateChanged: _onLetterScrollbarDragChanged,
bottomPadding: BottomSpacing.withMiniPlayer,
child: _artistsViewMode == 'list'
? ListView.builder(
controller: _artistsScrollController,
key: PageStorageKey<String>('library_artists_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: sortedArtists.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
itemBuilder: (context, index) {
final artist = sortedArtists[index];
return _buildArtistTile(
context,
artist,
key: ValueKey(artist.uri ?? artist.itemId),
);
},
)
: GridView.builder(
controller: _artistsScrollController,
key: PageStorageKey<String>('library_artists_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _artistsViewMode == 'grid3' ? 3 : 2,
childAspectRatio: _artistsViewMode == 'grid3' ? 0.75 : 0.80,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: sortedArtists.length,
itemBuilder: (context, index) {
final artist = sortedArtists[index];
return _buildArtistGridCard(context, artist);
},
),
),
);
}
Widget _buildArtistTile(
BuildContext context,
Artist artist, {
Key? key,
}) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final maProvider = context.read<MusicAssistantProvider>();
final suffix = '_library';
// Get image URL for hero animation
final imageUrl = maProvider.getImageUrl(artist, size: 256);
return RepaintBoundary(
child: ListTile(
key: key,
leading: ArtistAvatar(
artist: artist,
radius: 24,
imageSize: 128,
heroTag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
),
title: Hero(
tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
artist.name,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
onTap: () {
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
FadeSlidePageRoute(
child: ArtistDetailsScreen(
artist: artist,
heroTagSuffix: 'library',
initialImageUrl: imageUrl,
),
),
);
},
),
);
}
Widget _buildArtistGridCard(BuildContext context, Artist artist) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(artist, size: 256);
return GestureDetector(
onTap: () {
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
FadeSlidePageRoute(
child: ArtistDetailsScreen(
artist: artist,
heroTagSuffix: 'library_grid',
initialImageUrl: imageUrl,
),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Use LayoutBuilder to get available width for proper circle sizing
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
// Use the smaller dimension to ensure a circle
final size = constraints.maxWidth < constraints.maxHeight
? constraints.maxWidth
: constraints.maxHeight;
// PERF: Use appropriate cache size based on grid columns
final cacheSize = _artistsViewMode == 'grid3' ? 200 : 300;
return Center(
child: ArtistAvatar(
artist: artist,
radius: size / 2,
imageSize: cacheSize,
heroTag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + '_library_grid',
),
);
},
),
),
Spacing.vGap8,
Text(
artist.name,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
),
);
}
// ============ ALBUMS TAB ============
Widget _buildAlbumsTab(BuildContext context, S l10n) {
// Note: We don't use Selector here because we need to react to both
// MusicAssistantProvider changes AND SyncService changes (source tracking).
// The parent's _onSyncServiceChanged listener triggers setState() for SyncService updates.
final maProvider = context.watch<MusicAssistantProvider>();
final isLoading = maProvider.isLoading;
final enabledProviders = maProvider.enabledProviderIds.toSet();
final colorScheme = Theme.of(context).colorScheme;
final syncService = SyncService.instance;
// Show loading only if actually loading AND no cached data available
if (isLoading && syncService.cachedAlbums.isEmpty) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
}
// Always use SyncService data - it has the complete library with source tracking
final filteredAlbums = enabledProviders.isNotEmpty
? syncService.getAlbumsFilteredByProviders(enabledProviders)
: syncService.cachedAlbums;
// Filter by favorites if enabled
final albums = _showFavoritesOnly
? filteredAlbums.where((a) => a.favorite == true).toList()
: filteredAlbums;
if (albums.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
context: context,
icon: Icons.favorite_border,
title: l10n.noFavoriteAlbums,
subtitle: l10n.tapHeartAlbum,
);
}
return EmptyState.albums(
context: context,
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
}
// PERF: Use cached sorting - only re-sorts when data or sort order changes
final sortedAlbums = _getSortedAlbums(albums);
final albumNames = _cachedAlbumNames;
// Generate year labels for year sort mode
final albumYears = (_albumsSortOrder == 'year' || _albumsSortOrder == 'year_desc')
? sortedAlbums.map((a) => a.year?.toString() ?? '?').toList()
: null;
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.background,
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
child: LetterScrollbar(
controller: _albumsScrollController,
items: albumNames,
displayMode: _getScrollbarDisplayMode(_albumsSortOrder),
displayLabels: albumYears,
onDragStateChanged: _onLetterScrollbarDragChanged,
bottomPadding: BottomSpacing.withMiniPlayer,
child: _albumsViewMode == 'list'
? ListView.builder(
controller: _albumsScrollController,
key: PageStorageKey<String>('library_albums_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_albumsViewMode'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
itemCount: sortedAlbums.length,
itemBuilder: (context, index) {
final album = sortedAlbums[index];
return _buildAlbumListTile(context, album);
},
)
: GridView.builder(
controller: _albumsScrollController,
key: PageStorageKey<String>('library_albums_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_albumsViewMode'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _albumsViewMode == 'grid3' ? 3 : 2,
childAspectRatio: _albumsViewMode == 'grid3' ? 0.70 : 0.75,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: sortedAlbums.length,
itemBuilder: (context, index) {
final album = sortedAlbums[index];
return AlbumCard(
key: ValueKey(album.uri ?? album.itemId),
album: album,
heroTagSuffix: 'library_grid',
// Use 256 to match detail screen for smooth Hero animation
imageCacheSize: 256,
);
},
),
),
);
}
Widget _buildAlbumListTile(BuildContext context, Album album) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(album, size: 128);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 56,
height: 56,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.album_rounded,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.album_rounded,
color: colorScheme.onSurfaceVariant,
),
),
),
title: Text(
album.nameWithYear,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
album.artistsString,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () {
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
FadeSlidePageRoute(
child: AlbumDetailsScreen(
album: album,
initialImageUrl: imageUrl,
),
),
);
},
);
}
// ============ PLAYLISTS TAB ============
Widget _buildPlaylistsTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme;
if (_isLoadingPlaylists) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
}
// Provider filtering is done server-side via API calls
if (_playlists.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
context: context,
icon: Icons.favorite_border,
title: l10n.noFavoritePlaylists,
subtitle: l10n.tapHeartPlaylist,
);
}
return EmptyState.playlists(context: context, onRefresh: () => _loadPlaylists());
}
// PERF: Use pre-sorted lists (sorted once on load)
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.background,
onRefresh: () => _loadPlaylists(favoriteOnly: _showFavoritesOnly ? true : null),
child: LetterScrollbar(
controller: _playlistsScrollController,
items: _playlistNames,
displayMode: _getScrollbarDisplayMode(_playlistsSortOrder),
onDragStateChanged: _onLetterScrollbarDragChanged,
bottomPadding: BottomSpacing.withMiniPlayer,
child: _playlistsViewMode == 'list'
? ListView.builder(
controller: _playlistsScrollController,
key: PageStorageKey<String>('library_playlists_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_playlistsViewMode\_$_playlistsSortOrder'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: _sortedPlaylists.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
itemBuilder: (context, index) {
final playlist = _sortedPlaylists[index];
return _buildPlaylistTile(context, playlist, l10n);
},
)
: GridView.builder(
controller: _playlistsScrollController,
key: PageStorageKey<String>('library_playlists_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_playlistsViewMode\_$_playlistsSortOrder'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _playlistsViewMode == 'grid3' ? 3 : 2,
childAspectRatio: _playlistsViewMode == 'grid3' ? 0.75 : 0.80,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: _sortedPlaylists.length,
itemBuilder: (context, index) {
final playlist = _sortedPlaylists[index];
return _buildPlaylistGridCard(context, playlist, l10n);
},
),
),
);
}
Widget _buildPlaylistTile(BuildContext context, Playlist playlist, S l10n) {
final provider = context.read<MusicAssistantProvider>();
final imageUrl = provider.api?.getImageUrl(playlist, size: 128);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// Unique suffix for list view context
const heroSuffix = '_library_list';
return RepaintBoundary(
child: ListTile(
key: ValueKey(playlist.itemId),
leading: Hero(
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 48,
height: 48,
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.playlist_play_rounded,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.playlist_play_rounded,
color: colorScheme.onSurfaceVariant,
),
),
// Provider icon overlay
if (playlist.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: playlist.providerMappings!.first.providerDomain,
size: 16,
),
],
),
),
),
),
title: Hero(
tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + heroSuffix,
child: Material(
color: Colors.transparent,
child: Text(
playlist.name,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
subtitle: Text(
playlist.trackCount != null
? '${playlist.trackCount} ${l10n.tracks}'
: playlist.owner ?? l10n.playlist,
style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: playlist.favorite == true
? const Icon(Icons.favorite, color: StatusColors.favorite, size: 20)
: null,
onTap: () {
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
FadeSlidePageRoute(
child: PlaylistDetailsScreen(
playlist: playlist,
heroTagSuffix: 'library_list',
initialImageUrl: imageUrl,
),
),
);
},
),
);
}
Widget _buildPlaylistGridCard(BuildContext context, Playlist playlist, S l10n) {
final provider = context.read<MusicAssistantProvider>();
final imageUrl = provider.api?.getImageUrl(playlist, size: 256);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// Unique suffix for grid view context
const heroSuffix = '_library_grid';
return RepaintBoundary(
child: GestureDetector(
onTap: () {
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
FadeSlidePageRoute(
child: PlaylistDetailsScreen(
playlist: playlist,
heroTagSuffix: 'library_grid',
initialImageUrl: imageUrl,
),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Hero(
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Center(
child: Icon(
Icons.playlist_play_rounded,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
Icons.playlist_play_rounded,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (playlist.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: playlist.providerMappings!.first.providerDomain,
),
],
),
),
),
),
Spacing.vGap8,
Hero(
tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + heroSuffix,
child: Material(
color: Colors.transparent,
child: Text(
playlist.name,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
Text(
playlist.trackCount != null
? '${playlist.trackCount} ${l10n.tracks}'
: playlist.owner ?? l10n.playlist,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
// ============ TRACKS TAB ============
Widget _buildTracksTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme;
if (_isLoadingTracks && _allTracks.isEmpty) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
}
// Get the appropriate track list based on favorites mode
// Provider filtering is done server-side via API calls
final List<Track> displayTracks;
if (_showFavoritesOnly) {
// Use the dedicated favorites list that was loaded by _loadFavoriteTracks()
displayTracks = _sortedFavoriteTracks;
} else {
displayTracks = _sortedAllTracks;
}
if (displayTracks.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
context: context,
icon: Icons.favorite_border,
title: l10n.noFavoriteTracks,
subtitle: l10n.longPressTrackHint,
);
}
return EmptyState.custom(
context: context,
icon: Icons.music_note,
title: l10n.noTracks,
subtitle: l10n.addTracksHint,
onRefresh: _loadAllTracks,
);
}
// PERF: Use cached track sort keys - only recompute when data or sort order changes
final trackSortKeys = _getTrackSortKeys(displayTracks);
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.background,
onRefresh: _loadAllTracks,
child: LetterScrollbar(
controller: _tracksScrollController,
items: trackSortKeys,
displayMode: _getScrollbarDisplayMode(_tracksSortOrder),
onDragStateChanged: _onLetterScrollbarDragChanged,
bottomPadding: BottomSpacing.withMiniPlayer,
child: ListView.builder(
controller: _tracksScrollController,
key: PageStorageKey<String>('library_tracks_list_${_showFavoritesOnly ? 'fav' : 'all'}'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
itemCount: displayTracks.length + (_hasMoreTracks && !_showFavoritesOnly ? 1 : 0),
itemBuilder: (context, index) {
// Show loading indicator at the end when loading more
if (index >= displayTracks.length) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Center(
child: _isLoadingMoreTracks
? CircularProgressIndicator(color: colorScheme.primary)
: const SizedBox(),
),
);
}
final track = displayTracks[index];
return _buildTrackTile(context, track);
},
),
),
);
}
Widget _buildTrackTile(BuildContext context, Track track) {
final maProvider = context.read<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// Get image URL from track itself
final imageUrl = maProvider.api?.getImageUrl(track, size: 128);
return RepaintBoundary(
child: ListTile(
key: ValueKey(track.uri ?? track.itemId),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(6),
image: imageUrl != null
? DecorationImage(
image: CachedNetworkImageProvider(imageUrl),
fit: BoxFit.cover,
)
: null,
),
child: imageUrl == null
? Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24)
: null,
),
title: Text(
track.name,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
track.artistsString,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (track.favorite == true)
const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(
Icons.favorite,
color: StatusColors.favorite,
size: 18,
),
),
if (track.duration != null)
Text(
_formatTrackDuration(track.duration!),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
onTap: () async {
final player = maProvider.selectedPlayer;
if (player == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
try {
// Start radio from this track
await maProvider.api?.playRadio(player.playerId, track);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to start radio: $e')),
);
}
}
},
),
);
}
String _formatTrackDuration(Duration duration) {
final totalSeconds = duration.inSeconds;
final minutes = totalSeconds ~/ 60;
final seconds = totalSeconds % 60;
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
}
/// Custom PageScrollPhysics with faster spring for quicker settling
/// This allows vertical scrolling to work sooner after a horizontal swipe
class _FastPageScrollPhysics extends PageScrollPhysics {
const _FastPageScrollPhysics({super.parent});
@override
_FastPageScrollPhysics applyTo(ScrollPhysics? ancestor) {
return _FastPageScrollPhysics(parent: buildParent(ancestor));
}
@override
SpringDescription get spring => const SpringDescription(
mass: 80, // Increased mass for more controlled movement
stiffness: 100, // Reduced stiffness to prevent over-snapping
damping: 1.2, // Slightly over-damped to prevent overshoot
);
}
/// Options menu overlay that stays open on sort selection
class _OptionsMenuOverlay extends StatefulWidget {
final Offset position;
final Size buttonSize;
final ColorScheme colorScheme;
final String currentSort;
final String currentViewMode;
final bool hasViewModes;
final bool showFavoritesOnly;
final bool showOnlyArtistsWithAlbums;
final LibraryMediaType selectedMediaType;
final int tabIndex;
final List<(String, String, IconData, bool)> sortFields;
final (String, bool) Function(String) parseSortOrder;
final String Function(String, bool) buildSortOrder;
final void Function(String) onSortChanged;
final void Function(String) onViewModeChanged;
final VoidCallback onFavoritesToggled;
final VoidCallback onArtistsFilterToggled;
final VoidCallback onDismiss;
// Provider filter
final List<(ProviderInstance, int)> relevantProviders;
final Set<String> enabledProviderIds;
final void Function(String providerId, bool enabled) onProviderToggled;
// ABS library filter (Books tab)
final List<Map<String, String>> absLibraries;
final Map<String, bool> absLibraryEnabled;
final void Function(String libraryPath, bool enabled) onAbsLibraryToggled;
const _OptionsMenuOverlay({
required this.position,
required this.buttonSize,
required this.colorScheme,
required this.currentSort,
required this.currentViewMode,
required this.hasViewModes,
required this.showFavoritesOnly,
required this.showOnlyArtistsWithAlbums,
required this.selectedMediaType,
required this.tabIndex,
required this.sortFields,
required this.parseSortOrder,
required this.buildSortOrder,
required this.onSortChanged,
required this.onViewModeChanged,
required this.onFavoritesToggled,
required this.onArtistsFilterToggled,
required this.onDismiss,
required this.relevantProviders,
required this.enabledProviderIds,
required this.onProviderToggled,
required this.absLibraries,
required this.absLibraryEnabled,
required this.onAbsLibraryToggled,
});
@override
State<_OptionsMenuOverlay> createState() => _OptionsMenuOverlayState();
}
class _OptionsMenuOverlayState extends State<_OptionsMenuOverlay>
with SingleTickerProviderStateMixin {
late String _currentSort;
late String _currentViewMode;
late bool _showFavoritesOnly;
late bool _showOnlyArtistsWithAlbums;
// Note: We don't use local state for enabledProviders anymore - the Selector
// watches the Provider directly and rebuilds when it changes
late Map<String, bool> _localAbsLibraryEnabled;
late AnimationController _animController;
late Animation<double> _scaleAnimation;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_currentSort = widget.currentSort;
_currentViewMode = widget.currentViewMode;
_showFavoritesOnly = widget.showFavoritesOnly;
_showOnlyArtistsWithAlbums = widget.showOnlyArtistsWithAlbums;
_localAbsLibraryEnabled = Map.from(widget.absLibraryEnabled);
_animController = AnimationController(
duration: const Duration(milliseconds: 150),
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 0.95, end: 1.0).animate(
CurvedAnimation(parent: _animController, curve: Curves.easeOut),
);
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _animController, curve: Curves.easeOut),
);
_animController.forward();
}
@override
void dispose() {
_animController.dispose();
super.dispose();
}
@override
void didUpdateWidget(_OptionsMenuOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.currentSort != widget.currentSort) {
_currentSort = widget.currentSort;
}
// Note: We don't sync enabledProviders here anymore - the Selector watches
// the Provider directly and handles rebuilds when the state changes
}
void _handleSortTap(String baseField, bool defaultDesc) {
final (currentBaseField, currentIsDesc) = widget.parseSortOrder(_currentSort);
String newSort;
if (baseField == currentBaseField) {
// Toggle direction
newSort = widget.buildSortOrder(baseField, !currentIsDesc);
} else {
// Select with default direction
newSort = widget.buildSortOrder(baseField, defaultDesc);
}
setState(() {
_currentSort = newSort;
});
widget.onSortChanged(newSort);
}
void _handleViewModeTap(String mode) {
setState(() {
_currentViewMode = mode;
});
widget.onViewModeChanged(mode);
}
void _handleFavoritesTap() {
setState(() {
_showFavoritesOnly = !_showFavoritesOnly;
});
widget.onFavoritesToggled();
}
void _handleArtistsFilterTap() {
setState(() {
_showOnlyArtistsWithAlbums = !_showOnlyArtistsWithAlbums;
});
widget.onArtistsFilterToggled();
}
void _handleProviderTap(String providerId) {
// Read directly from Provider to get current state (not stale props)
final maProvider = context.read<MusicAssistantProvider>();
final currentEnabled = maProvider.enabledProviderIds;
final isCurrentlyEnabled = currentEnabled.isEmpty || currentEnabled.contains(providerId);
// Don't allow disabling the last provider
if (isCurrentlyEnabled && currentEnabled.isNotEmpty && currentEnabled.length <= 1) {
return; // Can't disable the last one
}
// Let the callback handle the actual toggle - no local state needed
// The Provider will notifyListeners and trigger a rebuild
widget.onProviderToggled(providerId, !isCurrentlyEnabled);
}
void _handleAbsLibraryTap(String libraryPath) {
final isEnabled = _localAbsLibraryEnabled[libraryPath] ?? true;
// Don't allow disabling the last library
final enabledCount = _localAbsLibraryEnabled.values.where((v) => v).length;
if (isEnabled && enabledCount <= 1) return;
setState(() {
_localAbsLibraryEnabled[libraryPath] = !isEnabled;
});
widget.onAbsLibraryToggled(libraryPath, !isEnabled);
}
@override
Widget build(BuildContext context) {
final colorScheme = widget.colorScheme;
final (currentBaseField, currentIsDesc) = widget.parseSortOrder(_currentSort);
// Position menu below and to the left of the button
final menuWidth = 220.0;
final screenWidth = MediaQuery.of(context).size.width;
final left = (widget.position.dx + widget.buttonSize.width - menuWidth).clamp(8.0, screenWidth - menuWidth - 8);
final top = widget.position.dy + widget.buttonSize.height + 4;
return Stack(
children: [
// Dismiss on tap outside
FadeTransition(
opacity: _fadeAnimation,
child: ModalBarrier(
dismissible: true,
onDismiss: widget.onDismiss,
color: Colors.black12,
),
),
// Menu with scale and fade animation
Positioned(
left: left,
top: top,
child: FadeTransition(
opacity: _fadeAnimation,
child: ScaleTransition(
scale: _scaleAnimation,
alignment: Alignment.topRight,
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
color: colorScheme.surface,
child: Container(
width: menuWidth,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height - top - 50,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Sort section header
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 4),
child: Text(
'Sort',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
// Sort options
...widget.sortFields.map((field) {
final (baseField, label, icon, defaultDesc) = field;
final isSelected = currentBaseField == baseField;
final isDesc = isSelected ? currentIsDesc : defaultDesc;
return InkWell(
onTap: () => _handleSortTap(baseField, defaultDesc),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
// Selection indicator - fixed width
SizedBox(
width: 24,
child: isSelected
? Icon(Icons.check, size: 18, color: colorScheme.primary)
: null,
),
const SizedBox(width: 8),
Icon(
icon,
size: 18,
color: isSelected ? colorScheme.primary : colorScheme.onSurface.withOpacity(0.7),
),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: TextStyle(
color: isSelected ? colorScheme.primary : colorScheme.onSurface,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
),
// Direction indicator - fixed position
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary.withOpacity(0.1)
: colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(4),
),
child: Icon(
isDesc ? Icons.arrow_downward : Icons.arrow_upward,
size: 14,
color: isSelected ? colorScheme.primary : colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
),
);
}),
// View section
if (widget.hasViewModes) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 4),
child: Text(
'View',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
_buildViewModeItem('list', 'List', Icons.view_list, colorScheme),
_buildViewModeItem('grid2', '2-Column Grid', Icons.grid_on, colorScheme),
_buildViewModeItem('grid3', '3-Column Grid', Icons.grid_view, colorScheme),
],
// Filter section
const Divider(height: 1),
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 4),
child: Text(
'Filter',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
// Favorites toggle
InkWell(
onTap: _handleFavoritesTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
// Checkmark on left - fixed width
SizedBox(
width: 24,
child: _showFavoritesOnly
? Icon(Icons.check, size: 18, color: StatusColors.favorite)
: null,
),
const SizedBox(width: 8),
Icon(
_showFavoritesOnly ? Icons.favorite : Icons.favorite_border,
size: 18,
color: _showFavoritesOnly ? StatusColors.favorite : null,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Favorites Only',
style: TextStyle(
color: _showFavoritesOnly ? StatusColors.favorite : null,
fontWeight: _showFavoritesOnly ? FontWeight.w600 : FontWeight.normal,
),
),
),
],
),
),
),
// Artists filter (only show on Artists tab)
if (widget.selectedMediaType == LibraryMediaType.music && widget.tabIndex == 0)
InkWell(
onTap: _handleArtistsFilterTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
// Checkmark on left - fixed width
SizedBox(
width: 24,
child: _showOnlyArtistsWithAlbums
? Icon(Icons.check, size: 18, color: colorScheme.primary)
: null,
),
const SizedBox(width: 8),
Icon(
_showOnlyArtistsWithAlbums ? Icons.album : Icons.album_outlined,
size: 18,
color: _showOnlyArtistsWithAlbums ? colorScheme.primary : null,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'With Albums Only',
style: TextStyle(
color: _showOnlyArtistsWithAlbums ? colorScheme.primary : null,
fontWeight: _showOnlyArtistsWithAlbums ? FontWeight.w600 : FontWeight.normal,
),
),
),
],
),
),
),
// Providers section - only shown if multiple providers support this category
// relevantProviders is pre-filtered by capability (e.g., radio providers won't appear in Artists)
// Use Consumer to rebuild when provider changes (Selector had issues with List equality)
if (widget.relevantProviders.length > 1)
Consumer<MusicAssistantProvider>(
builder: (context, maProvider, _) {
final enabledSet = maProvider.enabledProviderIds.toSet();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(height: 1),
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 4),
child: Text(
'Providers',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
// Show all providers that support this category (allows toggling any on/off)
...widget.relevantProviders.map((providerData) {
final (provider, itemCount) = providerData;
// Empty set means all enabled
final isEnabled = enabledSet.isEmpty || enabledSet.contains(provider.instanceId);
return InkWell(
onTap: () => _handleProviderTap(provider.instanceId),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
// Checkmark on left - fixed width
SizedBox(
width: 24,
child: isEnabled
? Icon(Icons.check, size: 18, color: colorScheme.primary)
: null,
),
const SizedBox(width: 8),
Icon(
isEnabled ? Icons.cloud : Icons.cloud_outlined,
size: 18,
color: isEnabled
? colorScheme.primary
: colorScheme.onSurface.withOpacity(0.7),
),
const SizedBox(width: 12),
Expanded(
child: Text(
provider.name,
style: TextStyle(
color: isEnabled ? colorScheme.primary : null,
fontWeight: isEnabled ? FontWeight.w600 : FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
),
),
// Item count badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isEnabled
? colorScheme.primary.withOpacity(0.1)
: colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(4),
),
child: Text(
itemCount.toString(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: isEnabled
? colorScheme.primary
: colorScheme.onSurface.withOpacity(0.5),
),
),
),
],
),
),
);
}),
],
);
},
),
// ABS Libraries section - only for Books tab with multiple libraries
if (widget.selectedMediaType == LibraryMediaType.books &&
widget.absLibraries.length > 1) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 4),
child: Text(
'Libraries',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
...widget.absLibraries.map((library) {
final path = library['path'] ?? '';
final name = library['name'] ?? path;
final isEnabled = _localAbsLibraryEnabled[path] ?? true;
return InkWell(
onTap: () => _handleAbsLibraryTap(path),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
// Checkmark on left - fixed width
SizedBox(
width: 24,
child: isEnabled
? Icon(Icons.check, size: 18, color: colorScheme.primary)
: null,
),
const SizedBox(width: 8),
Icon(
isEnabled ? Icons.library_books : Icons.library_books_outlined,
size: 18,
color: isEnabled
? colorScheme.primary
: colorScheme.onSurface.withOpacity(0.7),
),
const SizedBox(width: 12),
Expanded(
child: Text(
name,
style: TextStyle(
color: isEnabled ? colorScheme.primary : null,
fontWeight: isEnabled ? FontWeight.w600 : FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}),
],
const SizedBox(height: 8),
],
),
),
),
),
),
),
),
],
);
}
Widget _buildViewModeItem(String mode, String label, IconData icon, ColorScheme colorScheme) {
final isSelected = _currentViewMode == mode;
return InkWell(
onTap: () => _handleViewModeTap(mode),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
SizedBox(
width: 24,
child: isSelected
? Icon(Icons.check, size: 18, color: colorScheme.primary)
: null,
),
const SizedBox(width: 8),
Icon(
icon,
size: 18,
color: isSelected ? colorScheme.primary : null,
),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: TextStyle(
color: isSelected ? colorScheme.primary : null,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
),
],
),
),
);
}
}