Context menu and options menu now account for mini player/nav bar insets so they don't get clipped behind the player overlay. Detail screens use different bottom spacing since the nav bar is hidden. Home screen row height calculation simplified by removing the caching/ratchet mechanism. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
6489 lines
240 KiB
Dart
6489 lines
240 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 '../widgets/hires_badge.dart';
|
|
import '../widgets/media_context_menu.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,
|
|
itemExtent: 67,
|
|
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),
|
|
minVerticalPadding: 0,
|
|
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 GestureDetector(
|
|
onLongPressStart: (details) {
|
|
MediaContextMenu.show(
|
|
context: context,
|
|
position: details.globalPosition,
|
|
mediaType: ContextMenuMediaType.audiobook,
|
|
item: book,
|
|
isFavorite: book.favorite ?? false,
|
|
isInLibrary: true,
|
|
onToggleFavorite: () => _toggleAudiobookFavorite(book),
|
|
onToggleLibrary: () => _toggleAudiobookLibrary(book),
|
|
);
|
|
},
|
|
child: ListTile(
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
|
minVerticalPadding: 0,
|
|
leading: Hero(
|
|
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_library$heroSuffix',
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: Container(
|
|
width: 48,
|
|
height: 48,
|
|
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,
|
|
itemExtent: 67,
|
|
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),
|
|
onLongPressStart: (details) {
|
|
MediaContextMenu.show(
|
|
context: context,
|
|
position: details.globalPosition,
|
|
mediaType: ContextMenuMediaType.audiobook,
|
|
item: book,
|
|
isFavorite: book.favorite ?? false,
|
|
isInLibrary: true,
|
|
onToggleFavorite: () => _toggleAudiobookFavorite(book),
|
|
onToggleLibrary: () => _toggleAudiobookLibrary(book),
|
|
);
|
|
},
|
|
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,
|
|
itemExtent: 67,
|
|
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),
|
|
minVerticalPadding: 0,
|
|
leading: Hero(
|
|
tag: heroTag,
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: Container(
|
|
width: 48,
|
|
height: 48,
|
|
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 ============
|
|
// Consumer wrapper avoids using context.select inside PageView.builder's
|
|
// sliver context, which would rebuild the entire list instead of individual items.
|
|
Widget _buildPodcastsTab(BuildContext context, S l10n) {
|
|
return Consumer<MusicAssistantProvider>(
|
|
builder: (context, maProvider, _) {
|
|
return _buildPodcastsTabContent(context, l10n, maProvider);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildPodcastsTabContent(BuildContext context, S l10n, MusicAssistantProvider maProvider) {
|
|
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,
|
|
itemExtent: 67,
|
|
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 GestureDetector(
|
|
onLongPressStart: (details) {
|
|
MediaContextMenu.show(
|
|
context: context,
|
|
position: details.globalPosition,
|
|
mediaType: ContextMenuMediaType.podcast,
|
|
item: podcast,
|
|
isFavorite: podcast.favorite ?? false,
|
|
isInLibrary: true,
|
|
onToggleFavorite: () => _togglePodcastFavorite(podcast),
|
|
onToggleLibrary: () => _togglePodcastLibrary(podcast),
|
|
);
|
|
},
|
|
child: ListTile(
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
|
minVerticalPadding: 0,
|
|
leading: Hero(
|
|
tag: HeroTags.podcastCover + (podcast.uri ?? podcast.itemId) + '_library',
|
|
// Match detail screen: ClipRRect(16) → Container → CachedNetworkImage
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Container(
|
|
width: 48,
|
|
height: 48,
|
|
color: colorScheme.surfaceContainerHighest,
|
|
child: imageUrl != null
|
|
? CachedNetworkImage(
|
|
imageUrl: imageUrl,
|
|
width: 48,
|
|
height: 48,
|
|
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),
|
|
onLongPressStart: (details) {
|
|
MediaContextMenu.show(
|
|
context: context,
|
|
position: details.globalPosition,
|
|
mediaType: ContextMenuMediaType.podcast,
|
|
item: podcast,
|
|
isFavorite: podcast.favorite ?? false,
|
|
isInLibrary: true,
|
|
onToggleFavorite: () => _togglePodcastFavorite(podcast),
|
|
onToggleLibrary: () => _togglePodcastLibrary(podcast),
|
|
);
|
|
},
|
|
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 ============
|
|
// Consumer wrapper avoids using context.select inside PageView.builder's
|
|
// sliver context, which would rebuild the entire list instead of individual items.
|
|
Widget _buildRadioStationsTab(BuildContext context, S l10n) {
|
|
return Consumer<MusicAssistantProvider>(
|
|
builder: (context, maProvider, _) {
|
|
return _buildRadioTabContent(context, l10n, maProvider);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildRadioTabContent(BuildContext context, S l10n, MusicAssistantProvider maProvider) {
|
|
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,
|
|
itemExtent: 67,
|
|
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 GestureDetector(
|
|
onLongPressStart: (details) {
|
|
MediaContextMenu.show(
|
|
context: context,
|
|
position: details.globalPosition,
|
|
mediaType: ContextMenuMediaType.radio,
|
|
item: station,
|
|
isFavorite: station.favorite ?? false,
|
|
isInLibrary: true,
|
|
onToggleFavorite: () => _toggleRadioFavorite(station),
|
|
onToggleLibrary: () => _toggleRadioLibrary(station),
|
|
);
|
|
},
|
|
child: ListTile(
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
|
minVerticalPadding: 0,
|
|
leading: ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: imageUrl != null
|
|
? CachedNetworkImage(
|
|
imageUrl: imageUrl,
|
|
width: 48,
|
|
height: 48,
|
|
fit: BoxFit.cover,
|
|
memCacheWidth: 128,
|
|
memCacheHeight: 128,
|
|
fadeInDuration: Duration.zero,
|
|
fadeOutDuration: Duration.zero,
|
|
placeholder: (context, url) => Container(
|
|
width: 48,
|
|
height: 48,
|
|
color: colorScheme.surfaceVariant,
|
|
child: Icon(MdiIcons.radio, color: colorScheme.onSurfaceVariant),
|
|
),
|
|
errorWidget: (context, url, error) => Container(
|
|
width: 48,
|
|
height: 48,
|
|
color: colorScheme.surfaceVariant,
|
|
child: Icon(MdiIcons.radio, color: colorScheme.onSurfaceVariant),
|
|
),
|
|
)
|
|
: Container(
|
|
width: 48,
|
|
height: 48,
|
|
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),
|
|
onLongPressStart: (details) {
|
|
MediaContextMenu.show(
|
|
context: context,
|
|
position: details.globalPosition,
|
|
mediaType: ContextMenuMediaType.radio,
|
|
item: station,
|
|
isFavorite: station.favorite ?? false,
|
|
isInLibrary: true,
|
|
onToggleFavorite: () => _toggleRadioFavorite(station),
|
|
onToggleLibrary: () => _toggleRadioLibrary(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 ============
|
|
// Consumer wrapper avoids using context.select inside PageView.builder's
|
|
// sliver context, which would rebuild the entire list instead of individual items.
|
|
Widget _buildArtistsTab(BuildContext context, S l10n) {
|
|
return Consumer<MusicAssistantProvider>(
|
|
builder: (context, maProvider, _) {
|
|
return _buildArtistsTabContent(context, l10n, maProvider);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildArtistsTabContent(BuildContext context, S l10n, MusicAssistantProvider maProvider) {
|
|
// Use select to only rebuild when specific fields change
|
|
// SyncService changes are handled by _onSyncServiceChanged listener
|
|
final (isLoading, enabledProviders) = context.select<MusicAssistantProvider, (bool, Set<String>)>(
|
|
(p) => (p.isLoading, p.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,
|
|
itemExtent: 67,
|
|
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: GestureDetector(
|
|
onLongPressStart: (details) {
|
|
MediaContextMenu.show(
|
|
context: context,
|
|
position: details.globalPosition,
|
|
mediaType: ContextMenuMediaType.artist,
|
|
item: artist,
|
|
isFavorite: artist.favorite ?? false,
|
|
isInLibrary: artist.inLibrary,
|
|
onToggleFavorite: () => _toggleArtistFavorite(artist),
|
|
onToggleLibrary: () => _toggleArtistLibrary(artist),
|
|
);
|
|
},
|
|
child: ListTile(
|
|
key: key,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
|
minVerticalPadding: 0,
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
onLongPressStart: (details) {
|
|
MediaContextMenu.show(
|
|
context: context,
|
|
position: details.globalPosition,
|
|
mediaType: ContextMenuMediaType.artist,
|
|
item: artist,
|
|
isFavorite: artist.favorite ?? false,
|
|
isInLibrary: artist.inLibrary,
|
|
onToggleFavorite: () => _toggleArtistFavorite(artist),
|
|
onToggleLibrary: () => _toggleArtistLibrary(artist),
|
|
);
|
|
},
|
|
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 ============
|
|
// Consumer wrapper avoids using context.select inside PageView.builder's
|
|
// sliver context, which would rebuild the entire list instead of individual items.
|
|
Widget _buildAlbumsTab(BuildContext context, S l10n) {
|
|
return Consumer<MusicAssistantProvider>(
|
|
builder: (context, maProvider, _) {
|
|
return _buildAlbumsTabContent(context, l10n, maProvider);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildAlbumsTabContent(BuildContext context, S l10n, MusicAssistantProvider maProvider) {
|
|
// Use select to only rebuild when specific fields change
|
|
// SyncService changes are handled by _onSyncServiceChanged listener
|
|
final (isLoading, enabledProviders) = context.select<MusicAssistantProvider, (bool, Set<String>)>(
|
|
(p) => (p.isLoading, p.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,
|
|
itemExtent: 67,
|
|
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 GestureDetector(
|
|
onLongPressStart: (details) {
|
|
MediaContextMenu.show(
|
|
context: context,
|
|
position: details.globalPosition,
|
|
mediaType: ContextMenuMediaType.album,
|
|
item: album,
|
|
isFavorite: album.favorite ?? false,
|
|
isInLibrary: album.inLibrary,
|
|
onToggleFavorite: () => _toggleAlbumFavorite(album),
|
|
onToggleLibrary: () => _toggleAlbumLibrary(album),
|
|
);
|
|
},
|
|
child: 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 GestureDetector(
|
|
onLongPressStart: (details) {
|
|
MediaContextMenu.show(
|
|
context: context,
|
|
position: details.globalPosition,
|
|
mediaType: ContextMenuMediaType.album,
|
|
item: album,
|
|
isFavorite: album.favorite ?? false,
|
|
isInLibrary: album.inLibrary,
|
|
onToggleFavorite: () => _toggleAlbumFavorite(album),
|
|
onToggleLibrary: () => _toggleAlbumLibrary(album),
|
|
);
|
|
},
|
|
child: ListTile(
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
|
minVerticalPadding: 0,
|
|
leading: ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Container(
|
|
width: 48,
|
|
height: 48,
|
|
color: colorScheme.surfaceVariant,
|
|
child: imageUrl != null
|
|
? CachedNetworkImage(
|
|
imageUrl: imageUrl,
|
|
fit: BoxFit.cover,
|
|
memCacheWidth: 128,
|
|
memCacheHeight: 128,
|
|
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,
|
|
itemExtent: 67,
|
|
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: GestureDetector(
|
|
onLongPressStart: (details) {
|
|
MediaContextMenu.show(
|
|
context: context,
|
|
position: details.globalPosition,
|
|
mediaType: ContextMenuMediaType.playlist,
|
|
item: playlist,
|
|
isFavorite: playlist.favorite ?? false,
|
|
isInLibrary: playlist.inLibrary,
|
|
onToggleFavorite: () => _togglePlaylistFavorite(playlist),
|
|
onToggleLibrary: () => _togglePlaylistLibrary(playlist),
|
|
);
|
|
},
|
|
child: ListTile(
|
|
key: ValueKey(playlist.itemId),
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
|
minVerticalPadding: 0,
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
onLongPressStart: (details) {
|
|
MediaContextMenu.show(
|
|
context: context,
|
|
position: details.globalPosition,
|
|
mediaType: ContextMenuMediaType.playlist,
|
|
item: playlist,
|
|
isFavorite: playlist.favorite ?? false,
|
|
isInLibrary: playlist.inLibrary,
|
|
onToggleFavorite: () => _togglePlaylistFavorite(playlist),
|
|
onToggleLibrary: () => _togglePlaylistLibrary(playlist),
|
|
);
|
|
},
|
|
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,
|
|
itemExtent: 67,
|
|
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: GestureDetector(
|
|
onLongPressStart: (details) {
|
|
MediaContextMenu.show(
|
|
context: context,
|
|
position: details.globalPosition,
|
|
mediaType: ContextMenuMediaType.track,
|
|
item: track,
|
|
isFavorite: track.favorite ?? false,
|
|
isInLibrary: track.inLibrary,
|
|
onToggleFavorite: () => _toggleTrackFavorite(track),
|
|
onToggleLibrary: () => _toggleTrackLibrary(track),
|
|
);
|
|
},
|
|
child: ListTile(
|
|
key: ValueKey(track.uri ?? track.itemId),
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
|
minVerticalPadding: 0,
|
|
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 (HiResBadge.getTooltip(track) != null) ...[
|
|
HiResBadge.fromTrack(track, primaryColor: colorScheme.primary)!,
|
|
const SizedBox(width: 12),
|
|
],
|
|
if (track.favorite == true)
|
|
const Padding(
|
|
padding: EdgeInsets.only(right: 8),
|
|
child: Icon(
|
|
Icons.favorite,
|
|
color: StatusColors.favorite,
|
|
size: 18,
|
|
),
|
|
),
|
|
if (track.duration != null)
|
|
SizedBox(
|
|
width: 40,
|
|
child: Text(
|
|
_formatTrackDuration(track.duration!),
|
|
style: textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurface.withOpacity(0.5),
|
|
),
|
|
textAlign: TextAlign.right,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
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')}';
|
|
}
|
|
|
|
// Context menu toggle handlers
|
|
Future<void> _toggleAlbumFavorite(Album album) async {
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final l10n = S.of(context)!;
|
|
|
|
try {
|
|
final newState = !(album.favorite ?? false);
|
|
|
|
if (newState) {
|
|
String actualProvider = album.provider;
|
|
String actualItemId = album.itemId;
|
|
|
|
if (album.providerMappings != null && album.providerMappings!.isNotEmpty) {
|
|
final mapping = album.providerMappings!.firstWhere(
|
|
(m) => m.available && m.providerInstance != 'library',
|
|
orElse: () => album.providerMappings!.firstWhere(
|
|
(m) => m.available,
|
|
orElse: () => album.providerMappings!.first,
|
|
),
|
|
);
|
|
actualProvider = mapping.providerDomain;
|
|
actualItemId = mapping.itemId;
|
|
}
|
|
|
|
await maProvider.addToFavorites(
|
|
mediaType: 'album',
|
|
itemId: actualItemId,
|
|
provider: actualProvider,
|
|
);
|
|
} else {
|
|
int? libraryItemId;
|
|
if (album.provider == 'library') {
|
|
libraryItemId = int.tryParse(album.itemId);
|
|
} else if (album.providerMappings != null) {
|
|
final libraryMapping = album.providerMappings!.firstWhere(
|
|
(m) => m.providerInstance == 'library',
|
|
orElse: () => album.providerMappings!.first,
|
|
);
|
|
if (libraryMapping.providerInstance == 'library') {
|
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
|
}
|
|
}
|
|
|
|
if (libraryItemId != null) {
|
|
await maProvider.removeFromFavorites(
|
|
mediaType: 'album',
|
|
libraryItemId: libraryItemId,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(newState ? l10n.addedToFavorites : l10n.removedFromFavorites),
|
|
duration: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_logger.log('Error toggling album favorite: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleAlbumLibrary(Album album) async {
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final l10n = S.of(context)!;
|
|
|
|
try {
|
|
final newState = !album.inLibrary;
|
|
|
|
if (newState) {
|
|
String? actualProvider;
|
|
String? actualItemId;
|
|
|
|
if (album.providerMappings != null && album.providerMappings!.isNotEmpty) {
|
|
final nonLibraryMapping = album.providerMappings!.where(
|
|
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
|
|
).firstOrNull;
|
|
|
|
if (nonLibraryMapping != null) {
|
|
actualProvider = nonLibraryMapping.providerDomain;
|
|
actualItemId = nonLibraryMapping.itemId;
|
|
}
|
|
}
|
|
|
|
if (actualProvider == null || actualItemId == null) {
|
|
if (album.provider != 'library') {
|
|
actualProvider = album.provider;
|
|
actualItemId = album.itemId;
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
await maProvider.addToLibrary(
|
|
mediaType: 'album',
|
|
provider: actualProvider,
|
|
itemId: actualItemId,
|
|
);
|
|
} else {
|
|
int? libraryItemId;
|
|
if (album.provider == 'library') {
|
|
libraryItemId = int.tryParse(album.itemId);
|
|
} else if (album.providerMappings != null) {
|
|
final libraryMapping = album.providerMappings!.firstWhere(
|
|
(m) => m.providerInstance == 'library',
|
|
orElse: () => album.providerMappings!.first,
|
|
);
|
|
if (libraryMapping.providerInstance == 'library') {
|
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
|
}
|
|
}
|
|
|
|
if (libraryItemId != null) {
|
|
await maProvider.removeFromLibrary(
|
|
mediaType: 'album',
|
|
libraryItemId: libraryItemId,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(newState ? l10n.addedToLibrary : l10n.removedFromLibrary),
|
|
duration: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_logger.log('Error toggling album library: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleArtistFavorite(Artist artist) async {
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final l10n = S.of(context)!;
|
|
|
|
try {
|
|
final newState = !(artist.favorite ?? false);
|
|
|
|
if (newState) {
|
|
String actualProvider = artist.provider;
|
|
String actualItemId = artist.itemId;
|
|
|
|
if (artist.providerMappings != null && artist.providerMappings!.isNotEmpty) {
|
|
final mapping = artist.providerMappings!.firstWhere(
|
|
(m) => m.available && m.providerInstance != 'library',
|
|
orElse: () => artist.providerMappings!.firstWhere(
|
|
(m) => m.available,
|
|
orElse: () => artist.providerMappings!.first,
|
|
),
|
|
);
|
|
actualProvider = mapping.providerDomain;
|
|
actualItemId = mapping.itemId;
|
|
}
|
|
|
|
await maProvider.addToFavorites(
|
|
mediaType: 'artist',
|
|
itemId: actualItemId,
|
|
provider: actualProvider,
|
|
);
|
|
} else {
|
|
int? libraryItemId;
|
|
if (artist.provider == 'library') {
|
|
libraryItemId = int.tryParse(artist.itemId);
|
|
} else if (artist.providerMappings != null) {
|
|
final libraryMapping = artist.providerMappings!.firstWhere(
|
|
(m) => m.providerInstance == 'library',
|
|
orElse: () => artist.providerMappings!.first,
|
|
);
|
|
if (libraryMapping.providerInstance == 'library') {
|
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
|
}
|
|
}
|
|
|
|
if (libraryItemId != null) {
|
|
await maProvider.removeFromFavorites(
|
|
mediaType: 'artist',
|
|
libraryItemId: libraryItemId,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(newState ? l10n.addedToFavorites : l10n.removedFromFavorites),
|
|
duration: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_logger.log('Error toggling artist favorite: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleArtistLibrary(Artist artist) async {
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final l10n = S.of(context)!;
|
|
|
|
try {
|
|
final newState = !artist.inLibrary;
|
|
|
|
if (newState) {
|
|
String? actualProvider;
|
|
String? actualItemId;
|
|
|
|
if (artist.providerMappings != null && artist.providerMappings!.isNotEmpty) {
|
|
final nonLibraryMapping = artist.providerMappings!.where(
|
|
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
|
|
).firstOrNull;
|
|
|
|
if (nonLibraryMapping != null) {
|
|
actualProvider = nonLibraryMapping.providerDomain;
|
|
actualItemId = nonLibraryMapping.itemId;
|
|
}
|
|
}
|
|
|
|
if (actualProvider == null || actualItemId == null) {
|
|
if (artist.provider != 'library') {
|
|
actualProvider = artist.provider;
|
|
actualItemId = artist.itemId;
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
await maProvider.addToLibrary(
|
|
mediaType: 'artist',
|
|
provider: actualProvider,
|
|
itemId: actualItemId,
|
|
);
|
|
} else {
|
|
int? libraryItemId;
|
|
if (artist.provider == 'library') {
|
|
libraryItemId = int.tryParse(artist.itemId);
|
|
} else if (artist.providerMappings != null) {
|
|
final libraryMapping = artist.providerMappings!.firstWhere(
|
|
(m) => m.providerInstance == 'library',
|
|
orElse: () => artist.providerMappings!.first,
|
|
);
|
|
if (libraryMapping.providerInstance == 'library') {
|
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
|
}
|
|
}
|
|
|
|
if (libraryItemId != null) {
|
|
await maProvider.removeFromLibrary(
|
|
mediaType: 'artist',
|
|
libraryItemId: libraryItemId,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(newState ? l10n.addedToLibrary : l10n.removedFromLibrary),
|
|
duration: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_logger.log('Error toggling artist library: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleTrackFavorite(Track track) async {
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final l10n = S.of(context)!;
|
|
|
|
try {
|
|
final newState = !(track.favorite ?? false);
|
|
|
|
if (newState) {
|
|
String actualProvider = track.provider;
|
|
String actualItemId = track.itemId;
|
|
|
|
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
|
|
final mapping = track.providerMappings!.firstWhere(
|
|
(m) => m.available && m.providerInstance != 'library',
|
|
orElse: () => track.providerMappings!.firstWhere(
|
|
(m) => m.available,
|
|
orElse: () => track.providerMappings!.first,
|
|
),
|
|
);
|
|
actualProvider = mapping.providerDomain;
|
|
actualItemId = mapping.itemId;
|
|
}
|
|
|
|
await maProvider.addToFavorites(
|
|
mediaType: 'track',
|
|
itemId: actualItemId,
|
|
provider: actualProvider,
|
|
);
|
|
} else {
|
|
int? libraryItemId;
|
|
if (track.provider == 'library') {
|
|
libraryItemId = int.tryParse(track.itemId);
|
|
} else if (track.providerMappings != null) {
|
|
final libraryMapping = track.providerMappings!.firstWhere(
|
|
(m) => m.providerInstance == 'library',
|
|
orElse: () => track.providerMappings!.first,
|
|
);
|
|
if (libraryMapping.providerInstance == 'library') {
|
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
|
}
|
|
}
|
|
|
|
if (libraryItemId != null) {
|
|
await maProvider.removeFromFavorites(
|
|
mediaType: 'track',
|
|
libraryItemId: libraryItemId,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(newState ? l10n.addedToFavorites : l10n.removedFromFavorites),
|
|
duration: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_logger.log('Error toggling track favorite: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _togglePlaylistFavorite(Playlist playlist) async {
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final l10n = S.of(context)!;
|
|
|
|
try {
|
|
final newState = !(playlist.favorite ?? false);
|
|
|
|
if (newState) {
|
|
String actualProvider = playlist.provider;
|
|
String actualItemId = playlist.itemId;
|
|
|
|
if (playlist.providerMappings != null && playlist.providerMappings!.isNotEmpty) {
|
|
final mapping = playlist.providerMappings!.firstWhere(
|
|
(m) => m.available && m.providerInstance != 'library',
|
|
orElse: () => playlist.providerMappings!.firstWhere(
|
|
(m) => m.available,
|
|
orElse: () => playlist.providerMappings!.first,
|
|
),
|
|
);
|
|
actualProvider = mapping.providerDomain;
|
|
actualItemId = mapping.itemId;
|
|
}
|
|
|
|
await maProvider.addToFavorites(
|
|
mediaType: 'playlist',
|
|
itemId: actualItemId,
|
|
provider: actualProvider,
|
|
);
|
|
} else {
|
|
int? libraryItemId;
|
|
if (playlist.provider == 'library') {
|
|
libraryItemId = int.tryParse(playlist.itemId);
|
|
} else if (playlist.providerMappings != null) {
|
|
final libraryMapping = playlist.providerMappings!.firstWhere(
|
|
(m) => m.providerInstance == 'library',
|
|
orElse: () => playlist.providerMappings!.first,
|
|
);
|
|
if (libraryMapping.providerInstance == 'library') {
|
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
|
}
|
|
}
|
|
|
|
if (libraryItemId != null) {
|
|
await maProvider.removeFromFavorites(
|
|
mediaType: 'playlist',
|
|
libraryItemId: libraryItemId,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(newState ? l10n.addedToFavorites : l10n.removedFromFavorites),
|
|
duration: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_logger.log('Error toggling playlist favorite: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleTrackLibrary(Track track) async {
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final l10n = S.of(context)!;
|
|
|
|
try {
|
|
final newState = !track.inLibrary;
|
|
|
|
if (newState) {
|
|
String? actualProvider;
|
|
String? actualItemId;
|
|
|
|
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
|
|
final nonLibraryMapping = track.providerMappings!.where(
|
|
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
|
|
).firstOrNull;
|
|
|
|
if (nonLibraryMapping != null) {
|
|
actualProvider = nonLibraryMapping.providerDomain;
|
|
actualItemId = nonLibraryMapping.itemId;
|
|
}
|
|
}
|
|
|
|
if (actualProvider == null || actualItemId == null) {
|
|
if (track.provider != 'library') {
|
|
actualProvider = track.provider;
|
|
actualItemId = track.itemId;
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
await maProvider.addToLibrary(
|
|
mediaType: 'track',
|
|
provider: actualProvider,
|
|
itemId: actualItemId,
|
|
);
|
|
} else {
|
|
int? libraryItemId;
|
|
if (track.provider == 'library') {
|
|
libraryItemId = int.tryParse(track.itemId);
|
|
} else if (track.providerMappings != null) {
|
|
final libraryMapping = track.providerMappings!.firstWhere(
|
|
(m) => m.providerInstance == 'library',
|
|
orElse: () => track.providerMappings!.first,
|
|
);
|
|
if (libraryMapping.providerInstance == 'library') {
|
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
|
}
|
|
}
|
|
|
|
if (libraryItemId != null) {
|
|
await maProvider.removeFromLibrary(
|
|
mediaType: 'track',
|
|
libraryItemId: libraryItemId,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(newState ? l10n.addedToLibrary : l10n.removedFromLibrary),
|
|
duration: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_logger.log('Error toggling track library: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _togglePlaylistLibrary(Playlist playlist) async {
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final l10n = S.of(context)!;
|
|
|
|
try {
|
|
final newState = !playlist.inLibrary;
|
|
|
|
if (newState) {
|
|
String? actualProvider;
|
|
String? actualItemId;
|
|
|
|
if (playlist.providerMappings != null && playlist.providerMappings!.isNotEmpty) {
|
|
final nonLibraryMapping = playlist.providerMappings!.where(
|
|
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
|
|
).firstOrNull;
|
|
|
|
if (nonLibraryMapping != null) {
|
|
actualProvider = nonLibraryMapping.providerDomain;
|
|
actualItemId = nonLibraryMapping.itemId;
|
|
}
|
|
}
|
|
|
|
if (actualProvider == null || actualItemId == null) {
|
|
if (playlist.provider != 'library') {
|
|
actualProvider = playlist.provider;
|
|
actualItemId = playlist.itemId;
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
await maProvider.addToLibrary(
|
|
mediaType: 'playlist',
|
|
provider: actualProvider,
|
|
itemId: actualItemId,
|
|
);
|
|
} else {
|
|
int? libraryItemId;
|
|
if (playlist.provider == 'library') {
|
|
libraryItemId = int.tryParse(playlist.itemId);
|
|
} else if (playlist.providerMappings != null) {
|
|
final libraryMapping = playlist.providerMappings!.firstWhere(
|
|
(m) => m.providerInstance == 'library',
|
|
orElse: () => playlist.providerMappings!.first,
|
|
);
|
|
if (libraryMapping.providerInstance == 'library') {
|
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
|
}
|
|
}
|
|
|
|
if (libraryItemId != null) {
|
|
await maProvider.removeFromLibrary(
|
|
mediaType: 'playlist',
|
|
libraryItemId: libraryItemId,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(newState ? l10n.addedToLibrary : l10n.removedFromLibrary),
|
|
duration: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_logger.log('Error toggling playlist library: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleAudiobookFavorite(Audiobook book) async {
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final l10n = S.of(context)!;
|
|
|
|
try {
|
|
final newState = !(book.favorite ?? false);
|
|
|
|
if (newState) {
|
|
String actualProvider = book.provider;
|
|
String actualItemId = book.itemId;
|
|
|
|
if (book.providerMappings != null && book.providerMappings!.isNotEmpty) {
|
|
final mapping = book.providerMappings!.firstWhere(
|
|
(m) => m.available && m.providerInstance != 'library',
|
|
orElse: () => book.providerMappings!.firstWhere(
|
|
(m) => m.available,
|
|
orElse: () => book.providerMappings!.first,
|
|
),
|
|
);
|
|
actualProvider = mapping.providerDomain;
|
|
actualItemId = mapping.itemId;
|
|
}
|
|
|
|
await maProvider.addToFavorites(
|
|
mediaType: 'audiobook',
|
|
itemId: actualItemId,
|
|
provider: actualProvider,
|
|
);
|
|
} else {
|
|
int? libraryItemId;
|
|
if (book.provider == 'library') {
|
|
libraryItemId = int.tryParse(book.itemId);
|
|
} else if (book.providerMappings != null) {
|
|
final libraryMapping = book.providerMappings!.firstWhere(
|
|
(m) => m.providerInstance == 'library',
|
|
orElse: () => book.providerMappings!.first,
|
|
);
|
|
if (libraryMapping.providerInstance == 'library') {
|
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
|
}
|
|
}
|
|
|
|
if (libraryItemId != null) {
|
|
await maProvider.removeFromFavorites(
|
|
mediaType: 'audiobook',
|
|
libraryItemId: libraryItemId,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(newState ? l10n.addedToFavorites : l10n.removedFromFavorites),
|
|
duration: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_logger.log('Error toggling audiobook favorite: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleAudiobookLibrary(Audiobook book) async {
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final l10n = S.of(context)!;
|
|
|
|
try {
|
|
final newState = !book.inLibrary;
|
|
|
|
if (newState) {
|
|
String? actualProvider;
|
|
String? actualItemId;
|
|
|
|
if (book.providerMappings != null && book.providerMappings!.isNotEmpty) {
|
|
final nonLibraryMapping = book.providerMappings!.where(
|
|
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
|
|
).firstOrNull;
|
|
|
|
if (nonLibraryMapping != null) {
|
|
actualProvider = nonLibraryMapping.providerDomain;
|
|
actualItemId = nonLibraryMapping.itemId;
|
|
}
|
|
}
|
|
|
|
if (actualProvider == null || actualItemId == null) {
|
|
if (book.provider != 'library') {
|
|
actualProvider = book.provider;
|
|
actualItemId = book.itemId;
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
await maProvider.addToLibrary(
|
|
mediaType: 'audiobook',
|
|
provider: actualProvider,
|
|
itemId: actualItemId,
|
|
);
|
|
} else {
|
|
int? libraryItemId;
|
|
if (book.provider == 'library') {
|
|
libraryItemId = int.tryParse(book.itemId);
|
|
} else if (book.providerMappings != null) {
|
|
final libraryMapping = book.providerMappings!.firstWhere(
|
|
(m) => m.providerInstance == 'library',
|
|
orElse: () => book.providerMappings!.first,
|
|
);
|
|
if (libraryMapping.providerInstance == 'library') {
|
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
|
}
|
|
}
|
|
|
|
if (libraryItemId != null) {
|
|
await maProvider.removeFromLibrary(
|
|
mediaType: 'audiobook',
|
|
libraryItemId: libraryItemId,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(newState ? l10n.addedToLibrary : l10n.removedFromLibrary),
|
|
duration: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_logger.log('Error toggling audiobook library: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _togglePodcastLibrary(MediaItem podcast) async {
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final l10n = S.of(context)!;
|
|
|
|
try {
|
|
final newState = !podcast.inLibrary;
|
|
|
|
if (newState) {
|
|
String? actualProvider;
|
|
String? actualItemId;
|
|
|
|
if (podcast.providerMappings != null && podcast.providerMappings!.isNotEmpty) {
|
|
final nonLibraryMapping = podcast.providerMappings!.where(
|
|
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
|
|
).firstOrNull;
|
|
|
|
if (nonLibraryMapping != null) {
|
|
actualProvider = nonLibraryMapping.providerDomain;
|
|
actualItemId = nonLibraryMapping.itemId;
|
|
}
|
|
}
|
|
|
|
if (actualProvider == null || actualItemId == null) {
|
|
if (podcast.provider != 'library') {
|
|
actualProvider = podcast.provider;
|
|
actualItemId = podcast.itemId;
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
await maProvider.addToLibrary(
|
|
mediaType: 'podcast',
|
|
provider: actualProvider,
|
|
itemId: actualItemId,
|
|
);
|
|
} else {
|
|
int? libraryItemId;
|
|
if (podcast.provider == 'library') {
|
|
libraryItemId = int.tryParse(podcast.itemId);
|
|
} else if (podcast.providerMappings != null) {
|
|
final libraryMapping = podcast.providerMappings!.firstWhere(
|
|
(m) => m.providerInstance == 'library',
|
|
orElse: () => podcast.providerMappings!.first,
|
|
);
|
|
if (libraryMapping.providerInstance == 'library') {
|
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
|
}
|
|
}
|
|
|
|
if (libraryItemId != null) {
|
|
await maProvider.removeFromLibrary(
|
|
mediaType: 'podcast',
|
|
libraryItemId: libraryItemId,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(newState ? l10n.addedToLibrary : l10n.removedFromLibrary),
|
|
duration: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_logger.log('Error toggling podcast library: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleRadioLibrary(MediaItem station) async {
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final l10n = S.of(context)!;
|
|
|
|
try {
|
|
final newState = !station.inLibrary;
|
|
|
|
if (newState) {
|
|
String? actualProvider;
|
|
String? actualItemId;
|
|
|
|
if (station.providerMappings != null && station.providerMappings!.isNotEmpty) {
|
|
final nonLibraryMapping = station.providerMappings!.where(
|
|
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
|
|
).firstOrNull;
|
|
|
|
if (nonLibraryMapping != null) {
|
|
actualProvider = nonLibraryMapping.providerDomain;
|
|
actualItemId = nonLibraryMapping.itemId;
|
|
}
|
|
}
|
|
|
|
if (actualProvider == null || actualItemId == null) {
|
|
if (station.provider != 'library') {
|
|
actualProvider = station.provider;
|
|
actualItemId = station.itemId;
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
await maProvider.addToLibrary(
|
|
mediaType: 'radio',
|
|
provider: actualProvider,
|
|
itemId: actualItemId,
|
|
);
|
|
} else {
|
|
int? libraryItemId;
|
|
if (station.provider == 'library') {
|
|
libraryItemId = int.tryParse(station.itemId);
|
|
} else if (station.providerMappings != null) {
|
|
final libraryMapping = station.providerMappings!.firstWhere(
|
|
(m) => m.providerInstance == 'library',
|
|
orElse: () => station.providerMappings!.first,
|
|
);
|
|
if (libraryMapping.providerInstance == 'library') {
|
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
|
}
|
|
}
|
|
|
|
if (libraryItemId != null) {
|
|
await maProvider.removeFromLibrary(
|
|
mediaType: 'radio',
|
|
libraryItemId: libraryItemId,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(newState ? l10n.addedToLibrary : l10n.removedFromLibrary),
|
|
duration: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_logger.log('Error toggling radio library: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _togglePodcastFavorite(MediaItem podcast) async {
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final l10n = S.of(context)!;
|
|
|
|
try {
|
|
final newState = !(podcast.favorite ?? false);
|
|
|
|
if (newState) {
|
|
String actualProvider = podcast.provider;
|
|
String actualItemId = podcast.itemId;
|
|
|
|
if (podcast.providerMappings != null && podcast.providerMappings!.isNotEmpty) {
|
|
final mapping = podcast.providerMappings!.firstWhere(
|
|
(m) => m.available && m.providerInstance != 'library',
|
|
orElse: () => podcast.providerMappings!.firstWhere(
|
|
(m) => m.available,
|
|
orElse: () => podcast.providerMappings!.first,
|
|
),
|
|
);
|
|
actualProvider = mapping.providerDomain;
|
|
actualItemId = mapping.itemId;
|
|
}
|
|
|
|
await maProvider.addToFavorites(
|
|
mediaType: 'podcast',
|
|
itemId: actualItemId,
|
|
provider: actualProvider,
|
|
);
|
|
} else {
|
|
int? libraryItemId;
|
|
if (podcast.provider == 'library') {
|
|
libraryItemId = int.tryParse(podcast.itemId);
|
|
} else if (podcast.providerMappings != null) {
|
|
final libraryMapping = podcast.providerMappings!.firstWhere(
|
|
(m) => m.providerInstance == 'library',
|
|
orElse: () => podcast.providerMappings!.first,
|
|
);
|
|
if (libraryMapping.providerInstance == 'library') {
|
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
|
}
|
|
}
|
|
|
|
if (libraryItemId != null) {
|
|
await maProvider.removeFromFavorites(
|
|
mediaType: 'podcast',
|
|
libraryItemId: libraryItemId,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(newState ? l10n.addedToFavorites : l10n.removedFromFavorites),
|
|
duration: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_logger.log('Error toggling podcast favorite: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleRadioFavorite(MediaItem station) async {
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final l10n = S.of(context)!;
|
|
|
|
try {
|
|
final newState = !(station.favorite ?? false);
|
|
|
|
if (newState) {
|
|
String actualProvider = station.provider;
|
|
String actualItemId = station.itemId;
|
|
|
|
if (station.providerMappings != null && station.providerMappings!.isNotEmpty) {
|
|
final mapping = station.providerMappings!.firstWhere(
|
|
(m) => m.available && m.providerInstance != 'library',
|
|
orElse: () => station.providerMappings!.firstWhere(
|
|
(m) => m.available,
|
|
orElse: () => station.providerMappings!.first,
|
|
),
|
|
);
|
|
actualProvider = mapping.providerDomain;
|
|
actualItemId = mapping.itemId;
|
|
}
|
|
|
|
await maProvider.addToFavorites(
|
|
mediaType: 'radio',
|
|
itemId: actualItemId,
|
|
provider: actualProvider,
|
|
);
|
|
} else {
|
|
int? libraryItemId;
|
|
if (station.provider == 'library') {
|
|
libraryItemId = int.tryParse(station.itemId);
|
|
} else if (station.providerMappings != null) {
|
|
final libraryMapping = station.providerMappings!.firstWhere(
|
|
(m) => m.providerInstance == 'library',
|
|
orElse: () => station.providerMappings!.first,
|
|
);
|
|
if (libraryMapping.providerInstance == 'library') {
|
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
|
}
|
|
}
|
|
|
|
if (libraryItemId != null) {
|
|
await maProvider.removeFromFavorites(
|
|
mediaType: 'radio',
|
|
libraryItemId: libraryItemId,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(newState ? l10n.addedToFavorites : l10n.removedFromFavorites),
|
|
duration: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_logger.log('Error toggling radio favorite: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 screenSize = MediaQuery.of(context).size;
|
|
final bottomInset = BottomSpacing.withMiniPlayer + MediaQuery.of(context).viewPadding.bottom;
|
|
final left = (widget.position.dx + widget.buttonSize.width - menuWidth).clamp(8.0, screenSize.width - 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: screenSize.height - top - bottomInset,
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|