Compare commits
66
Commits
@@ -25,4 +25,7 @@ class HeroTags {
|
|||||||
|
|
||||||
/// Audiobook author image hero tag prefix
|
/// Audiobook author image hero tag prefix
|
||||||
static const String authorImage = 'author_image_';
|
static const String authorImage = 'author_image_';
|
||||||
|
|
||||||
|
/// Podcast cover hero tag prefix
|
||||||
|
static const String podcastCover = 'podcast_cover_';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,12 @@
|
|||||||
"tracks": "Songs",
|
"tracks": "Songs",
|
||||||
"playlists": "Wiedergabelisten",
|
"playlists": "Wiedergabelisten",
|
||||||
"audiobooks": "Hörbücher",
|
"audiobooks": "Hörbücher",
|
||||||
|
"music": "Musik",
|
||||||
|
"radio": "Radio",
|
||||||
|
"stations": "Sender",
|
||||||
|
"selectLibrary": "Bibliothek auswählen",
|
||||||
|
"noRadioStations": "Keine Radiosender",
|
||||||
|
"addRadioStationsHint": "Füge Radiosender in Music Assistant hinzu",
|
||||||
"searchFailed": "Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.",
|
"searchFailed": "Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.",
|
||||||
|
|
||||||
"queue": "Warteschlange",
|
"queue": "Warteschlange",
|
||||||
|
|||||||
@@ -316,6 +316,36 @@
|
|||||||
"description": "Audiobooks category"
|
"description": "Audiobooks category"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"music": "Music",
|
||||||
|
"@music": {
|
||||||
|
"description": "Music category"
|
||||||
|
},
|
||||||
|
|
||||||
|
"radio": "Radio",
|
||||||
|
"@radio": {
|
||||||
|
"description": "Radio category"
|
||||||
|
},
|
||||||
|
|
||||||
|
"stations": "Stations",
|
||||||
|
"@stations": {
|
||||||
|
"description": "Radio stations subcategory"
|
||||||
|
},
|
||||||
|
|
||||||
|
"selectLibrary": "Select Library",
|
||||||
|
"@selectLibrary": {
|
||||||
|
"description": "Title for library type selection bottom sheet"
|
||||||
|
},
|
||||||
|
|
||||||
|
"noRadioStations": "No radio stations",
|
||||||
|
"@noRadioStations": {
|
||||||
|
"description": "Empty state when no radio stations are available"
|
||||||
|
},
|
||||||
|
|
||||||
|
"addRadioStationsHint": "Add radio stations in Music Assistant",
|
||||||
|
"@addRadioStationsHint": {
|
||||||
|
"description": "Hint for adding radio stations"
|
||||||
|
},
|
||||||
|
|
||||||
"searchFailed": "Search failed. Please check your connection.",
|
"searchFailed": "Search failed. Please check your connection.",
|
||||||
"@searchFailed": {
|
"@searchFailed": {
|
||||||
"description": "Search error message"
|
"description": "Search error message"
|
||||||
@@ -630,6 +660,16 @@
|
|||||||
"description": "Radio button label"
|
"description": "Radio button label"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"playingRadioStation": "Playing {name}",
|
||||||
|
"@playingRadioStation": {
|
||||||
|
"description": "Status when playing radio station",
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
"addedToQueue": "Added to queue",
|
"addedToQueue": "Added to queue",
|
||||||
"@addedToQueue": {
|
"@addedToQueue": {
|
||||||
"description": "Confirmation when item added to queue"
|
"description": "Confirmation when item added to queue"
|
||||||
@@ -1036,6 +1076,10 @@
|
|||||||
"shows": "Shows",
|
"shows": "Shows",
|
||||||
"podcasts": "Podcasts",
|
"podcasts": "Podcasts",
|
||||||
"podcastSupportComingSoon": "Podcast support coming soon",
|
"podcastSupportComingSoon": "Podcast support coming soon",
|
||||||
|
"noPodcasts": "No podcasts",
|
||||||
|
"addPodcastsHint": "Subscribe to podcasts in Music Assistant",
|
||||||
|
"episodes": "Episodes",
|
||||||
|
"episode": "Episode",
|
||||||
"playlist": "Playlist",
|
"playlist": "Playlist",
|
||||||
"connectionError": "Connection Error",
|
"connectionError": "Connection Error",
|
||||||
"twoColumnGrid": "2-column grid",
|
"twoColumnGrid": "2-column grid",
|
||||||
@@ -1168,6 +1212,11 @@
|
|||||||
"description": "Singular form of track for search type indicator"
|
"description": "Singular form of track for search type indicator"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"podcastSingular": "Podcast",
|
||||||
|
"@podcastSingular": {
|
||||||
|
"description": "Singular form of podcast for search type indicator"
|
||||||
|
},
|
||||||
|
|
||||||
"trackCount": "{count} {count, plural, =1{track} other{tracks}}",
|
"trackCount": "{count} {count, plural, =1{track} other{tracks}}",
|
||||||
"@trackCount": {
|
"@trackCount": {
|
||||||
"description": "Track count with plural",
|
"description": "Track count with plural",
|
||||||
|
|||||||
@@ -66,6 +66,12 @@
|
|||||||
"tracks": "Canciones",
|
"tracks": "Canciones",
|
||||||
"playlists": "Listas de reproducción",
|
"playlists": "Listas de reproducción",
|
||||||
"audiobooks": "Audiolibros",
|
"audiobooks": "Audiolibros",
|
||||||
|
"music": "Música",
|
||||||
|
"radio": "Radio",
|
||||||
|
"stations": "Emisoras",
|
||||||
|
"selectLibrary": "Seleccionar Biblioteca",
|
||||||
|
"noRadioStations": "No hay emisoras de radio",
|
||||||
|
"addRadioStationsHint": "Añade emisoras de radio en Music Assistant",
|
||||||
"searchFailed": "Búsqueda fallida. Por favor, verifica tu conexión.",
|
"searchFailed": "Búsqueda fallida. Por favor, verifica tu conexión.",
|
||||||
"queue": "Cola",
|
"queue": "Cola",
|
||||||
"playerQueue": "Cola de {playerName}",
|
"playerQueue": "Cola de {playerName}",
|
||||||
|
|||||||
@@ -52,7 +52,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
List<Album> _albums = [];
|
List<Album> _albums = [];
|
||||||
List<Track> _tracks = [];
|
List<Track> _tracks = [];
|
||||||
List<Track> _cachedFavoriteTracks = []; // Cached for instant display before full library loads
|
List<Track> _cachedFavoriteTracks = []; // Cached for instant display before full library loads
|
||||||
|
List<MediaItem> _radioStations = [];
|
||||||
|
List<MediaItem> _podcasts = [];
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
bool _isLoadingRadio = false;
|
||||||
|
bool _isLoadingPodcasts = false;
|
||||||
|
|
||||||
// Player state
|
// Player state
|
||||||
Player? _selectedPlayer;
|
Player? _selectedPlayer;
|
||||||
@@ -95,6 +99,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
'tracks': [],
|
'tracks': [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Podcast cover cache: podcastId -> best available cover URL
|
||||||
|
// This is populated with episode covers when podcast covers are low-res
|
||||||
|
final Map<String, String> _podcastCoverCache = {};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// GETTERS
|
// GETTERS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -108,7 +116,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
List<Artist> get artists => _artists;
|
List<Artist> get artists => _artists;
|
||||||
List<Album> get albums => _albums;
|
List<Album> get albums => _albums;
|
||||||
List<Track> get tracks => _tracks;
|
List<Track> get tracks => _tracks;
|
||||||
|
List<MediaItem> get radioStations => _radioStations;
|
||||||
|
List<MediaItem> get podcasts => _podcasts;
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isLoadingRadio => _isLoadingRadio;
|
||||||
|
bool get isLoadingPodcasts => _isLoadingPodcasts;
|
||||||
|
|
||||||
/// Whether library is syncing in background
|
/// Whether library is syncing in background
|
||||||
bool get isSyncing => SyncService.instance.isSyncing;
|
bool get isSyncing => SyncService.instance.isSyncing;
|
||||||
@@ -207,6 +219,66 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
/// Whether we're currently playing an audiobook
|
/// Whether we're currently playing an audiobook
|
||||||
bool get isPlayingAudiobook => _currentAudiobook != null;
|
bool get isPlayingAudiobook => _currentAudiobook != null;
|
||||||
|
|
||||||
|
/// Whether we're currently playing a podcast episode
|
||||||
|
/// Detected by checking if the current track's URI contains podcast_episode
|
||||||
|
bool get isPlayingPodcast {
|
||||||
|
final uri = _currentTrack?.uri;
|
||||||
|
if (uri == null) return false;
|
||||||
|
return uri.contains('podcast_episode') || uri.contains('podcast/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the podcast name when playing a podcast episode
|
||||||
|
/// Returns the podcast name from metadata, album name, or artist name
|
||||||
|
String? get currentPodcastName {
|
||||||
|
if (!isPlayingPodcast || _currentTrack == null) return null;
|
||||||
|
|
||||||
|
// Debug logging to understand podcast data structure
|
||||||
|
_logger.log('🎙️ Getting podcast name for: ${_currentTrack!.name}');
|
||||||
|
_logger.log('🎙️ Track URI: ${_currentTrack!.uri}');
|
||||||
|
_logger.log('🎙️ Album: ${_currentTrack!.album?.name}');
|
||||||
|
_logger.log('🎙️ Artists: ${_currentTrack!.artists?.map((a) => a.name).join(", ")}');
|
||||||
|
_logger.log('🎙️ Metadata keys: ${_currentTrack!.metadata?.keys.toList()}');
|
||||||
|
|
||||||
|
// Try metadata first (if episode has parent podcast info)
|
||||||
|
final metadata = _currentTrack!.metadata;
|
||||||
|
if (metadata != null) {
|
||||||
|
// Log all metadata for debugging
|
||||||
|
_logger.log('🎙️ Full metadata: $metadata');
|
||||||
|
|
||||||
|
// Check for podcast_name or podcast.name in metadata
|
||||||
|
if (metadata['podcast_name'] != null) {
|
||||||
|
_logger.log('🎙️ Found podcast_name in metadata');
|
||||||
|
return metadata['podcast_name'] as String;
|
||||||
|
}
|
||||||
|
if (metadata['podcast'] is Map) {
|
||||||
|
final podcast = metadata['podcast'] as Map;
|
||||||
|
if (podcast['name'] != null) {
|
||||||
|
_logger.log('🎙️ Found podcast.name in metadata');
|
||||||
|
return podcast['name'] as String;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for 'title' in metadata (some providers put podcast name here)
|
||||||
|
if (metadata['title'] != null && metadata['title'] != _currentTrack!.name) {
|
||||||
|
_logger.log('🎙️ Found title in metadata: ${metadata['title']}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to album name (often contains podcast name)
|
||||||
|
if (_currentTrack!.album != null && _currentTrack!.album!.name.isNotEmpty) {
|
||||||
|
_logger.log('🎙️ Using album name as podcast name');
|
||||||
|
return _currentTrack!.album!.name;
|
||||||
|
}
|
||||||
|
// Fall back to artist (sometimes the podcast name is set as artist)
|
||||||
|
if (_currentTrack!.artists != null && _currentTrack!.artists!.isNotEmpty) {
|
||||||
|
final artistName = _currentTrack!.artists!.first.name;
|
||||||
|
if (artistName != 'Unknown Artist' && artistName.isNotEmpty) {
|
||||||
|
_logger.log('🎙️ Using artist name as podcast name');
|
||||||
|
return artistName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_logger.log('🎙️ No podcast name found, returning null');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
String get lastSearchQuery => _lastSearchQuery;
|
String get lastSearchQuery => _lastSearchQuery;
|
||||||
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
|
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
|
||||||
|
|
||||||
@@ -362,6 +434,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// Load cached home rows from database for instant discover/recent display
|
// Load cached home rows from database for instant discover/recent display
|
||||||
await _cacheService.loadHomeRowsFromDatabase();
|
await _cacheService.loadHomeRowsFromDatabase();
|
||||||
|
|
||||||
|
// Load podcast cover cache (iTunes URLs) for instant high-res display
|
||||||
|
await _loadPodcastCoverCache();
|
||||||
|
|
||||||
// Initialize offline action queue
|
// Initialize offline action queue
|
||||||
await OfflineActionQueue.instance.initialize();
|
await OfflineActionQueue.instance.initialize();
|
||||||
|
|
||||||
@@ -476,6 +551,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load podcast cover cache (iTunes URLs) from storage for instant high-res display
|
||||||
|
Future<void> _loadPodcastCoverCache() async {
|
||||||
|
try {
|
||||||
|
final cache = await SettingsService.getPodcastCoverCache();
|
||||||
|
if (cache.isNotEmpty) {
|
||||||
|
_podcastCoverCache.addAll(cache);
|
||||||
|
_logger.log('📦 Loaded ${cache.length} podcast covers from cache (instant high-res)');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Error loading podcast cover cache: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Persist current playback state to database (fire-and-forget)
|
/// Persist current playback state to database (fire-and-forget)
|
||||||
void _persistPlaybackState() {
|
void _persistPlaybackState() {
|
||||||
if (_selectedPlayer == null) return;
|
if (_selectedPlayer == null) return;
|
||||||
@@ -1738,6 +1826,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
final mediaType = currentMedia['media_type'] as String?;
|
final mediaType = currentMedia['media_type'] as String?;
|
||||||
final uri = currentMedia['uri'] as String?;
|
final uri = currentMedia['uri'] as String?;
|
||||||
|
|
||||||
|
// Debug: Log all currentMedia fields for podcast episodes
|
||||||
|
if (uri != null && (uri.contains('podcast_episode') || uri.contains('podcast/'))) {
|
||||||
|
_logger.log('🎙️ Podcast currentMedia keys: ${currentMedia.keys.toList()}');
|
||||||
|
_logger.log('🎙️ Podcast currentMedia: $currentMedia');
|
||||||
|
}
|
||||||
|
|
||||||
// Check for external source (optical, Spotify, etc.) - skip caching stale metadata
|
// Check for external source (optical, Spotify, etc.) - skip caching stale metadata
|
||||||
bool isExternalSource = false;
|
bool isExternalSource = false;
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
@@ -1805,6 +1899,14 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract podcast info if available (for podcast episodes)
|
||||||
|
final podcastData = currentMedia['podcast'];
|
||||||
|
if (podcastData != null) {
|
||||||
|
metadata ??= {};
|
||||||
|
metadata['podcast'] = podcastData;
|
||||||
|
_logger.log('🎙️ Found podcast in currentMedia: $podcastData');
|
||||||
|
}
|
||||||
|
|
||||||
// Parse artist from title if artist is missing but title contains " - "
|
// Parse artist from title if artist is missing but title contains " - "
|
||||||
var trackTitle = currentMedia['title'] as String? ?? 'Unknown Track';
|
var trackTitle = currentMedia['title'] as String? ?? 'Unknown Track';
|
||||||
var artistName = currentMedia['artist'] as String?;
|
var artistName = currentMedia['artist'] as String?;
|
||||||
@@ -1836,9 +1938,17 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
!existingTrack.name.contains(' - ');
|
!existingTrack.name.contains(' - ');
|
||||||
final existingHasImage = existingTrack?.metadata?['images'] != null;
|
final existingHasImage = existingTrack?.metadata?['images'] != null;
|
||||||
final newHasImage = metadata != null;
|
final newHasImage = metadata != null;
|
||||||
|
final newHasAlbum = albumName != null;
|
||||||
|
final existingHasAlbum = existingTrack?.album != null;
|
||||||
|
|
||||||
|
// For podcasts, album info is crucial (it's the podcast name)
|
||||||
|
// If new track has album but existing doesn't, prefer new or merge
|
||||||
|
final isPodcastUri = uri != null && (uri.contains('podcast_episode') || uri.contains('podcast/'));
|
||||||
|
|
||||||
// Keep existing if it has proper artist OR has image that new one lacks
|
// Keep existing if it has proper artist OR has image that new one lacks
|
||||||
final keepExisting = existingHasProperArtist || (existingHasImage && !newHasImage);
|
// BUT for podcasts, if new has album and existing doesn't, we need that album data
|
||||||
|
final keepExisting = (existingHasProperArtist || (existingHasImage && !newHasImage))
|
||||||
|
&& !(isPodcastUri && newHasAlbum && !existingHasAlbum);
|
||||||
|
|
||||||
if (!keepExisting) {
|
if (!keepExisting) {
|
||||||
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
|
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
|
||||||
@@ -1855,14 +1965,32 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_cacheService.setCachedTrackForPlayer(computedSendspinId, trackFromEvent);
|
_cacheService.setCachedTrackForPlayer(computedSendspinId, trackFromEvent);
|
||||||
_logger.log('📋 Also cached under computed Sendspin ID: $computedSendspinId');
|
_logger.log('📋 Also cached under computed Sendspin ID: $computedSendspinId');
|
||||||
}
|
}
|
||||||
|
} else if (isPodcastUri && newHasAlbum && existingTrack != null && !existingHasAlbum) {
|
||||||
|
// Merge: keep existing but add album from new track
|
||||||
|
final mergedTrack = Track(
|
||||||
|
itemId: existingTrack.itemId,
|
||||||
|
provider: existingTrack.provider,
|
||||||
|
name: existingTrack.name,
|
||||||
|
uri: existingTrack.uri,
|
||||||
|
duration: existingTrack.duration,
|
||||||
|
artists: existingTrack.artists,
|
||||||
|
album: trackFromEvent.album, // Take album from new track
|
||||||
|
metadata: existingTrack.metadata,
|
||||||
|
);
|
||||||
|
_cacheService.setCachedTrackForPlayer(playerId, mergedTrack);
|
||||||
|
_logger.log('📋 Merged album info into existing track for $playerName: ${albumName}');
|
||||||
} else {
|
} else {
|
||||||
_logger.log('📋 Skipped caching for $playerName - already have better data (artist: $existingHasProperArtist, image: $existingHasImage)');
|
_logger.log('📋 Skipped caching for $playerName - already have better data (artist: $existingHasProperArtist, image: $existingHasImage)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// For selected player, _updatePlayerState() is already called above which fetches queue data
|
// For selected player, _updatePlayerState() is already called above which fetches queue data
|
||||||
// Only update _currentTrack here if we don't have it yet (initial load)
|
// Only update _currentTrack here if we don't have it yet OR if we have better album data for podcasts
|
||||||
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId && _currentTrack == null) {
|
final shouldUpdateCurrentTrack = _selectedPlayer != null &&
|
||||||
_currentTrack = trackFromEvent;
|
playerId == _selectedPlayer!.playerId &&
|
||||||
|
(_currentTrack == null || (isPodcastUri && newHasAlbum && _currentTrack?.album == null));
|
||||||
|
if (shouldUpdateCurrentTrack) {
|
||||||
|
_currentTrack = _cacheService.getCachedTrackForPlayer(playerId) ?? trackFromEvent;
|
||||||
|
_logger.log('📋 Updated _currentTrack for podcast with album: ${_currentTrack?.album?.name}');
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -3601,6 +3729,76 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
await syncService.syncFromApi(_api!);
|
await syncService.syncFromApi(_api!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load radio stations from the library
|
||||||
|
Future<void> loadRadioStations() async {
|
||||||
|
if (!isConnected || _api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_isLoadingRadio = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
_radioStations = await _api!.getRadioStations(limit: 100);
|
||||||
|
|
||||||
|
_logger.log('📻 Loaded ${_radioStations.length} radio stations');
|
||||||
|
_isLoadingRadio = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to load radio stations: $e');
|
||||||
|
_isLoadingRadio = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadPodcasts() async {
|
||||||
|
if (!isConnected || _api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_isLoadingPodcasts = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
_podcasts = await _api!.getPodcasts(limit: 100);
|
||||||
|
|
||||||
|
_logger.log('🎙️ Loaded ${_podcasts.length} podcasts');
|
||||||
|
_isLoadingPodcasts = false;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
// Fetch episode covers in background for podcasts with low-res images
|
||||||
|
_loadPodcastCoversInBackground();
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to load podcasts: $e');
|
||||||
|
_isLoadingPodcasts = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load high-resolution podcast covers from iTunes in background
|
||||||
|
/// iTunes provides 800x800 artwork for most podcasts (reduced from 1400 for efficiency)
|
||||||
|
Future<void> _loadPodcastCoversInBackground() async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
for (final podcast in _podcasts) {
|
||||||
|
try {
|
||||||
|
// Skip if already cached (either in memory or loaded from persistence)
|
||||||
|
if (_podcastCoverCache.containsKey(podcast.itemId)) continue;
|
||||||
|
|
||||||
|
// Try iTunes for high-res artwork
|
||||||
|
final itunesArtwork = await _api!.getITunesPodcastArtwork(podcast.name);
|
||||||
|
|
||||||
|
if (itunesArtwork != null) {
|
||||||
|
_podcastCoverCache[podcast.itemId] = itunesArtwork;
|
||||||
|
_logger.log('🎙️ Cached iTunes artwork for ${podcast.name}');
|
||||||
|
|
||||||
|
// Persist to storage for instant display on next launch
|
||||||
|
SettingsService.addPodcastCoverToCache(podcast.itemId, itunesArtwork);
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to load cover for ${podcast.name}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> loadArtists({int? limit, int? offset, String? search}) async {
|
Future<void> loadArtists({int? limit, int? offset, String? search}) async {
|
||||||
if (!isConnected) return;
|
if (!isConnected) return;
|
||||||
|
|
||||||
@@ -3687,6 +3885,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
return _api?.getImageUrl(item, size: size);
|
return _api?.getImageUrl(item, size: size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get best available podcast cover URL
|
||||||
|
/// Returns cached iTunes URL (800x800) if available, otherwise falls back to MA imageproxy
|
||||||
|
/// The cache is persisted to storage and loaded on app start for instant high-res display
|
||||||
|
String? getPodcastImageUrl(MediaItem podcast, {int size = 256}) {
|
||||||
|
// Return cached iTunes URL if available (persisted across app launches)
|
||||||
|
final cachedUrl = _podcastCoverCache[podcast.itemId];
|
||||||
|
if (cachedUrl != null) {
|
||||||
|
return cachedUrl;
|
||||||
|
}
|
||||||
|
// Fall back to MA imageproxy (will be replaced once iTunes fetch completes)
|
||||||
|
return _api?.getImageUrl(podcast, size: size);
|
||||||
|
}
|
||||||
|
|
||||||
/// Get artist image URL with fallback to external sources (Deezer, Fanart.tv)
|
/// Get artist image URL with fallback to external sources (Deezer, Fanart.tv)
|
||||||
/// Returns a Future since fallback requires async API calls
|
/// Returns a Future since fallback requires async API calls
|
||||||
Future<String?> getArtistImageUrlWithFallback(Artist artist, {int size = 256}) async {
|
Future<String?> getArtistImageUrlWithFallback(Artist artist, {int size = 256}) async {
|
||||||
|
|||||||
@@ -706,8 +706,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
backgroundColor: colorScheme.background,
|
backgroundColor: colorScheme.background,
|
||||||
body: LayoutBuilder(
|
body: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
// Responsive cover size: 50% of screen width, clamped between 200-320
|
// Responsive cover size: 70% of screen width, clamped between 200-320
|
||||||
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
|
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
|
||||||
final expandedHeight = coverSize + 70;
|
final expandedHeight = coverSize + 70;
|
||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
@@ -769,7 +769,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24.0),
|
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -553,8 +553,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
backgroundColor: colorScheme.background,
|
backgroundColor: colorScheme.background,
|
||||||
body: LayoutBuilder(
|
body: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
// Responsive cover size: 40% of screen width, clamped between 160-280 (smaller for circular artist image)
|
// Responsive cover size: 70% of screen width, clamped between 160-280 (smaller for circular artist image)
|
||||||
final coverSize = (constraints.maxWidth * 0.4).clamp(160.0, 280.0);
|
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
|
||||||
final expandedHeight = coverSize + 100;
|
final expandedHeight = coverSize + 100;
|
||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
|
|||||||
@@ -429,8 +429,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
body: LayoutBuilder(
|
body: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
// Responsive cover size: 50% of screen width, clamped between 200-320
|
// Responsive cover size: 70% of screen width, clamped between 200-320
|
||||||
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
|
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
|
||||||
final expandedHeight = coverSize + 70;
|
final expandedHeight = coverSize + 70;
|
||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
|
|||||||
@@ -277,8 +277,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
|||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
body: LayoutBuilder(
|
body: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
// Responsive cover size: 50% of screen width, clamped between 200-320
|
// Responsive cover size: 70% of screen width, clamped between 200-320
|
||||||
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
|
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
|
||||||
final expandedHeight = coverSize + 70;
|
final expandedHeight = coverSize + 70;
|
||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
// Simple calculation: available height divided by 3 rows
|
// Simple calculation: available height divided by 3 rows
|
||||||
// Each row includes its title, artwork, and info
|
// Each row includes its title, artwork, and info
|
||||||
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
|
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
|
||||||
final rowHeight = availableHeight / 3;
|
final rowHeight = (availableHeight + 8) / 3;
|
||||||
|
|
||||||
// Use Android 12+ stretch overscroll effect
|
// Use Android 12+ stretch overscroll effect
|
||||||
return NotificationListener<ScrollNotification>(
|
return NotificationListener<ScrollNotification>(
|
||||||
|
|||||||
+1077
-271
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,685 @@
|
|||||||
|
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 '../models/media_item.dart';
|
||||||
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../widgets/global_player_overlay.dart';
|
||||||
|
import '../widgets/player_picker_sheet.dart';
|
||||||
|
import '../theme/palette_helper.dart';
|
||||||
|
import '../theme/theme_provider.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import '../constants/hero_tags.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
class PodcastDetailScreen extends StatefulWidget {
|
||||||
|
final MediaItem podcast;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
final String? initialImageUrl;
|
||||||
|
|
||||||
|
const PodcastDetailScreen({
|
||||||
|
super.key,
|
||||||
|
required this.podcast,
|
||||||
|
this.heroTagSuffix,
|
||||||
|
this.initialImageUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PodcastDetailScreen> createState() => _PodcastDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||||
|
final _logger = DebugLogger();
|
||||||
|
ColorScheme? _lightColorScheme;
|
||||||
|
ColorScheme? _darkColorScheme;
|
||||||
|
List<MediaItem> _episodes = [];
|
||||||
|
bool _isLoadingEpisodes = false;
|
||||||
|
bool _isDescriptionExpanded = false;
|
||||||
|
String? _expandedEpisodeId; // Track which episode is expanded for actions
|
||||||
|
|
||||||
|
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
Future.delayed(const Duration(milliseconds: 350), () {
|
||||||
|
if (mounted) {
|
||||||
|
_extractColors();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_loadEpisodes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _extractColors() async {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
// Use initialImageUrl first for consistency with hero animation
|
||||||
|
final imageUrl = widget.initialImageUrl ?? maProvider.getPodcastImageUrl(widget.podcast);
|
||||||
|
|
||||||
|
if (imageUrl != null) {
|
||||||
|
try {
|
||||||
|
final colorSchemes = await PaletteHelper.extractColorSchemes(
|
||||||
|
CachedNetworkImageProvider(imageUrl),
|
||||||
|
);
|
||||||
|
if (mounted && colorSchemes != null) {
|
||||||
|
setState(() {
|
||||||
|
_lightColorScheme = colorSchemes.$1;
|
||||||
|
_darkColorScheme = colorSchemes.$2;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('🎙️ Error extracting colors: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadEpisodes() async {
|
||||||
|
if (_isLoadingEpisodes) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoadingEpisodes = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
if (maProvider.api != null) {
|
||||||
|
final episodes = await maProvider.api!.getPodcastEpisodes(
|
||||||
|
widget.podcast.itemId,
|
||||||
|
provider: widget.podcast.provider,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
_logger.log('🎙️ Loaded ${episodes.length} episodes for ${widget.podcast.name}');
|
||||||
|
// Debug: Log first episode metadata to see available fields
|
||||||
|
if (episodes.isNotEmpty) {
|
||||||
|
final first = episodes.first;
|
||||||
|
_logger.log('🎙️ First episode metadata keys: ${first.metadata?.keys.toList()}');
|
||||||
|
_logger.log('🎙️ First episode metadata: ${first.metadata}');
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_episodes = episodes;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('🎙️ Error loading episodes: $e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoadingEpisodes = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _playEpisode(MediaItem episode) async {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final selectedPlayer = maProvider.selectedPlayer;
|
||||||
|
|
||||||
|
if (selectedPlayer != null) {
|
||||||
|
try {
|
||||||
|
await maProvider.api?.playPodcastEpisode(selectedPlayer.playerId, episode);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Playing: ${episode.name}'),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('🎙️ Error playing episode: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to play episode: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_showPlayOnMenu(context, episode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showPlayOnMenu(BuildContext context, MediaItem episode) {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
GlobalPlayerOverlay.hidePlayer();
|
||||||
|
|
||||||
|
showPlayerPickerSheet(
|
||||||
|
context: context,
|
||||||
|
title: S.of(context)!.playOn,
|
||||||
|
players: maProvider.availablePlayers,
|
||||||
|
selectedPlayer: maProvider.selectedPlayer,
|
||||||
|
onPlayerSelected: (player) async {
|
||||||
|
maProvider.selectPlayer(player);
|
||||||
|
await maProvider.api?.playPodcastEpisode(player.playerId, episode);
|
||||||
|
},
|
||||||
|
).whenComplete(() {
|
||||||
|
GlobalPlayerOverlay.showPlayer();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(Duration? duration) {
|
||||||
|
if (duration == null || duration == Duration.zero) return '';
|
||||||
|
final hours = duration.inHours;
|
||||||
|
final minutes = duration.inMinutes.remainder(60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return '${hours}h ${minutes}m';
|
||||||
|
}
|
||||||
|
return '${minutes} min';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format release date from episode metadata
|
||||||
|
/// Tries various common field names for publication date
|
||||||
|
String? _formatReleaseDate(Map<String, dynamic>? metadata) {
|
||||||
|
if (metadata == null) return null;
|
||||||
|
|
||||||
|
// Try various common field names for publication date
|
||||||
|
dynamic dateValue = metadata['published'] ??
|
||||||
|
metadata['pub_date'] ??
|
||||||
|
metadata['release_date'] ??
|
||||||
|
metadata['aired'] ??
|
||||||
|
metadata['timestamp'];
|
||||||
|
|
||||||
|
if (dateValue == null) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
DateTime? date;
|
||||||
|
if (dateValue is String) {
|
||||||
|
date = DateTime.tryParse(dateValue);
|
||||||
|
} else if (dateValue is int) {
|
||||||
|
// Unix timestamp
|
||||||
|
date = DateTime.fromMillisecondsSinceEpoch(dateValue * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date != null) {
|
||||||
|
// Format as "Jan 15, 2024"
|
||||||
|
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
return '${months[date.month - 1]} ${date.day}, ${date.year}';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('🎙️ Error parsing release date: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add episode to queue on current player
|
||||||
|
Future<void> _addToQueue(MediaItem episode) async {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final selectedPlayer = maProvider.selectedPlayer;
|
||||||
|
|
||||||
|
if (selectedPlayer == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert MediaItem to Track for queue addition
|
||||||
|
final track = Track(
|
||||||
|
itemId: episode.itemId,
|
||||||
|
provider: episode.provider,
|
||||||
|
name: episode.name,
|
||||||
|
uri: episode.uri,
|
||||||
|
duration: episode.duration,
|
||||||
|
metadata: episode.metadata,
|
||||||
|
);
|
||||||
|
await maProvider.addTrackToQueue(selectedPlayer.playerId, track);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(S.of(context)!.addedToQueue),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('🎙️ Error adding episode to queue: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(S.of(context)!.failedToAddToQueue(e.toString()))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final maProvider = context.watch<MusicAssistantProvider>();
|
||||||
|
// CRITICAL: Use initialImageUrl for hero animation consistency
|
||||||
|
// The library passes the same URL it uses for display, ensuring cache hit
|
||||||
|
// Only fall back to provider URL if initialImageUrl is null
|
||||||
|
final imageUrl = widget.initialImageUrl ?? maProvider.getPodcastImageUrl(widget.podcast);
|
||||||
|
|
||||||
|
final adaptiveTheme = context.select<ThemeProvider, bool>(
|
||||||
|
(provider) => provider.adaptiveTheme,
|
||||||
|
);
|
||||||
|
final adaptiveLightScheme = context.select<ThemeProvider, ColorScheme?>(
|
||||||
|
(provider) => provider.adaptiveLightScheme,
|
||||||
|
);
|
||||||
|
final adaptiveDarkScheme = context.select<ThemeProvider, ColorScheme?>(
|
||||||
|
(provider) => provider.adaptiveDarkScheme,
|
||||||
|
);
|
||||||
|
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
ColorScheme? adaptiveScheme;
|
||||||
|
if (adaptiveTheme) {
|
||||||
|
adaptiveScheme = isDark
|
||||||
|
? (_darkColorScheme ?? adaptiveDarkScheme)
|
||||||
|
: (_lightColorScheme ?? adaptiveLightScheme);
|
||||||
|
}
|
||||||
|
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
final description = widget.podcast.metadata?['description'] as String?;
|
||||||
|
final author = widget.podcast.metadata?['author'] as String?;
|
||||||
|
|
||||||
|
return PopScope(
|
||||||
|
canPop: true,
|
||||||
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
|
if (didPop) {
|
||||||
|
clearAdaptiveColorsOnBack(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
body: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
|
||||||
|
final expandedHeight = coverSize + 70;
|
||||||
|
|
||||||
|
return CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverAppBar(
|
||||||
|
expandedHeight: expandedHeight,
|
||||||
|
pinned: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
|
onPressed: () {
|
||||||
|
clearAdaptiveColorsOnBack(context);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
background: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 60),
|
||||||
|
Hero(
|
||||||
|
tag: HeroTags.podcastCover + (widget.podcast.uri ?? widget.podcast.itemId) + _heroTagSuffix,
|
||||||
|
// Match audiobook pattern: ClipRRect + CachedNetworkImage for smooth hero
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Container(
|
||||||
|
width: coverSize,
|
||||||
|
height: coverSize,
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: imageUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => Center(
|
||||||
|
child: Icon(
|
||||||
|
MdiIcons.podcast,
|
||||||
|
size: coverSize * 0.43,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Center(
|
||||||
|
child: Icon(
|
||||||
|
MdiIcons.podcast,
|
||||||
|
size: coverSize * 0.43,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: Icon(
|
||||||
|
MdiIcons.podcast,
|
||||||
|
size: coverSize * 0.43,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Podcast title
|
||||||
|
Text(
|
||||||
|
widget.podcast.name,
|
||||||
|
style: textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (author != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
author,
|
||||||
|
style: textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Description
|
||||||
|
if (description != null && description.isNotEmpty) ...[
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isDescriptionExpanded = !_isDescriptionExpanded;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: AnimatedCrossFade(
|
||||||
|
firstChild: Text(
|
||||||
|
description,
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
secondChild: Text(
|
||||||
|
description,
|
||||||
|
style: textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
crossFadeState: _isDescriptionExpanded
|
||||||
|
? CrossFadeState.showSecond
|
||||||
|
: CrossFadeState.showFirst,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Episodes header
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
S.of(context)!.episodes,
|
||||||
|
style: textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (_episodes.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
'(${_episodes.length})',
|
||||||
|
style: textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Episodes list
|
||||||
|
if (_isLoadingEpisodes)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (_episodes.isEmpty)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
MdiIcons.microphoneOff,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.3),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No episodes found',
|
||||||
|
style: textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final episode = _episodes[index];
|
||||||
|
final episodeDescription = episode.metadata?['description'] as String?;
|
||||||
|
final duration = episode.duration;
|
||||||
|
final episodeId = episode.uri ?? episode.itemId;
|
||||||
|
final isExpanded = _expandedEpisodeId == episodeId;
|
||||||
|
final episodeImageUrl = maProvider.getImageUrl(episode, size: 256);
|
||||||
|
|
||||||
|
// Try to get release date from metadata
|
||||||
|
final releaseDate = _formatReleaseDate(episode.metadata);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: () => _playEpisode(episode),
|
||||||
|
onLongPress: () {
|
||||||
|
setState(() {
|
||||||
|
_expandedEpisodeId = isExpanded ? null : episodeId;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Episode cover (bigger than before)
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: episodeImageUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: episodeImageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => Icon(
|
||||||
|
MdiIcons.podcast,
|
||||||
|
size: 32,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Icon(
|
||||||
|
MdiIcons.podcast,
|
||||||
|
size: 32,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
MdiIcons.podcast,
|
||||||
|
size: 32,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// Episode info
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
episode.name,
|
||||||
|
style: textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (episodeDescription != null && episodeDescription.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
episodeDescription,
|
||||||
|
maxLines: isExpanded ? 100 : 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
// Duration and release date row
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (duration != null && duration > Duration.zero) ...[
|
||||||
|
Icon(
|
||||||
|
Icons.access_time,
|
||||||
|
size: 14,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_formatDuration(duration),
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (duration != null && duration > Duration.zero && releaseDate != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Text(
|
||||||
|
'•',
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (releaseDate != null)
|
||||||
|
Text(
|
||||||
|
releaseDate,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Expanded action buttons (matching album track style)
|
||||||
|
AnimatedCrossFade(
|
||||||
|
firstChild: const SizedBox.shrink(),
|
||||||
|
secondChild: Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 100, right: 16, bottom: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Play button
|
||||||
|
SizedBox(
|
||||||
|
height: 44,
|
||||||
|
width: 44,
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: () => _playEpisode(episode),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.play_arrow, size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
// Play on... button
|
||||||
|
SizedBox(
|
||||||
|
height: 44,
|
||||||
|
width: 44,
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: () => _showPlayOnMenu(context, episode),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.speaker_group_outlined, size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
// Add to queue button
|
||||||
|
SizedBox(
|
||||||
|
height: 44,
|
||||||
|
width: 44,
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: () => _addToQueue(episode),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.playlist_add, size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
crossFadeState: isExpanded
|
||||||
|
? CrossFadeState.showSecond
|
||||||
|
: CrossFadeState.showFirst,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: _episodes.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bottom padding for mini player
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SizedBox(height: 100),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+605
-49
@@ -19,6 +19,7 @@ import 'album_details_screen.dart';
|
|||||||
import 'artist_details_screen.dart';
|
import 'artist_details_screen.dart';
|
||||||
import 'playlist_details_screen.dart';
|
import 'playlist_details_screen.dart';
|
||||||
import 'audiobook_detail_screen.dart';
|
import 'audiobook_detail_screen.dart';
|
||||||
|
import 'podcast_detail_screen.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
class SearchScreen extends StatefulWidget {
|
class SearchScreen extends StatefulWidget {
|
||||||
@@ -29,7 +30,7 @@ class SearchScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper enum and class for ListView.builder item types
|
// Helper enum and class for ListView.builder item types
|
||||||
enum _ListItemType { header, artist, album, track, playlist, audiobook, spacer }
|
enum _ListItemType { header, artist, album, track, playlist, audiobook, radio, podcast, spacer }
|
||||||
|
|
||||||
class _ListItem {
|
class _ListItem {
|
||||||
final _ListItemType type;
|
final _ListItemType type;
|
||||||
@@ -68,6 +69,16 @@ class _ListItem {
|
|||||||
headerTitle = null,
|
headerTitle = null,
|
||||||
headerCount = null;
|
headerCount = null;
|
||||||
|
|
||||||
|
_ListItem.radio(this.mediaItem, {this.relevanceScore})
|
||||||
|
: type = _ListItemType.radio,
|
||||||
|
headerTitle = null,
|
||||||
|
headerCount = null;
|
||||||
|
|
||||||
|
_ListItem.podcast(this.mediaItem, {this.relevanceScore})
|
||||||
|
: type = _ListItemType.podcast,
|
||||||
|
headerTitle = null,
|
||||||
|
headerCount = null;
|
||||||
|
|
||||||
_ListItem.spacer()
|
_ListItem.spacer()
|
||||||
: type = _ListItemType.spacer,
|
: type = _ListItemType.spacer,
|
||||||
mediaItem = null,
|
mediaItem = null,
|
||||||
@@ -89,11 +100,13 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
'tracks': [],
|
'tracks': [],
|
||||||
'playlists': [],
|
'playlists': [],
|
||||||
'audiobooks': [],
|
'audiobooks': [],
|
||||||
|
'radios': [],
|
||||||
'podcasts': [],
|
'podcasts': [],
|
||||||
};
|
};
|
||||||
bool _isSearching = false;
|
bool _isSearching = false;
|
||||||
bool _hasSearched = false;
|
bool _hasSearched = false;
|
||||||
String _activeFilter = 'all'; // 'all', 'artists', 'albums', 'tracks', 'playlists', 'audiobooks'
|
// PERF: Use ValueNotifier to avoid full screen rebuild on filter change
|
||||||
|
final ValueNotifier<String> _activeFilterNotifier = ValueNotifier('all');
|
||||||
String? _searchError;
|
String? _searchError;
|
||||||
List<String> _recentSearches = [];
|
List<String> _recentSearches = [];
|
||||||
bool _libraryOnly = false;
|
bool _libraryOnly = false;
|
||||||
@@ -128,8 +141,8 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
if (_searchResults['tracks']?.isNotEmpty == true) filters.add('tracks');
|
if (_searchResults['tracks']?.isNotEmpty == true) filters.add('tracks');
|
||||||
if (_searchResults['playlists']?.isNotEmpty == true) filters.add('playlists');
|
if (_searchResults['playlists']?.isNotEmpty == true) filters.add('playlists');
|
||||||
if (_searchResults['audiobooks']?.isNotEmpty == true) filters.add('audiobooks');
|
if (_searchResults['audiobooks']?.isNotEmpty == true) filters.add('audiobooks');
|
||||||
// Always show podcasts filter (placeholder until fully integrated)
|
if (_searchResults['radios']?.isNotEmpty == true) filters.add('radios');
|
||||||
filters.add('podcasts');
|
if (_searchResults['podcasts']?.isNotEmpty == true) filters.add('podcasts');
|
||||||
_cachedAvailableFilters = filters;
|
_cachedAvailableFilters = filters;
|
||||||
return filters;
|
return filters;
|
||||||
}
|
}
|
||||||
@@ -151,9 +164,9 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
void _onPageChanged(int pageIndex) {
|
void _onPageChanged(int pageIndex) {
|
||||||
final filters = _getAvailableFilters();
|
final filters = _getAvailableFilters();
|
||||||
if (pageIndex >= 0 && pageIndex < filters.length) {
|
if (pageIndex >= 0 && pageIndex < filters.length) {
|
||||||
setState(() {
|
// Only update ValueNotifier - no setState needed
|
||||||
_activeFilter = filters[pageIndex];
|
// ValueListenableBuilder will rebuild only the filter chips
|
||||||
});
|
_activeFilterNotifier.value = filters[pageIndex];
|
||||||
_scrollFilterIntoView(pageIndex);
|
_scrollFilterIntoView(pageIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,6 +253,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
_pageController.dispose();
|
_pageController.dispose();
|
||||||
_filterScrollController.dispose();
|
_filterScrollController.dispose();
|
||||||
|
_activeFilterNotifier.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +291,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
Future<void> _performSearch(String query, {bool keepFocus = false}) async {
|
Future<void> _performSearch(String query, {bool keepFocus = false}) async {
|
||||||
if (query.isEmpty) {
|
if (query.isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'podcasts': []};
|
_searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'radios': [], 'podcasts': []};
|
||||||
_hasSearched = false;
|
_hasSearched = false;
|
||||||
_searchError = null;
|
_searchError = null;
|
||||||
_cachedListItems.clear(); // PERF: Clear cache
|
_cachedListItems.clear(); // PERF: Clear cache
|
||||||
@@ -295,9 +309,71 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
final provider = context.read<MusicAssistantProvider>();
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
final results = await provider.searchWithCache(query, libraryOnly: _libraryOnly);
|
final results = await provider.searchWithCache(query, libraryOnly: _libraryOnly);
|
||||||
|
|
||||||
|
// Search for radios: combine library filtering + global search
|
||||||
|
final queryLower = query.toLowerCase();
|
||||||
|
|
||||||
|
// 1. Filter from library radio stations
|
||||||
|
final libraryRadios = provider.radioStations
|
||||||
|
.where((radio) => radio.name.toLowerCase().contains(queryLower))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// 2. Also search globally via API (for providers like TuneIn)
|
||||||
|
List<MediaItem> globalRadios = [];
|
||||||
|
if (!_libraryOnly) {
|
||||||
|
try {
|
||||||
|
globalRadios = await provider.api?.searchRadioStations(query) ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Global radio search failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Combine and deduplicate by name
|
||||||
|
final allRadios = <String, MediaItem>{};
|
||||||
|
for (final radio in libraryRadios) {
|
||||||
|
allRadios[radio.name.toLowerCase()] = radio;
|
||||||
|
}
|
||||||
|
for (final radio in globalRadios) {
|
||||||
|
final key = radio.name.toLowerCase();
|
||||||
|
if (!allRadios.containsKey(key)) {
|
||||||
|
allRadios[key] = radio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for podcasts: combine library filtering + global search
|
||||||
|
// 1. Filter from library podcasts
|
||||||
|
final libraryPodcasts = provider.podcasts
|
||||||
|
.where((podcast) => podcast.name.toLowerCase().contains(queryLower))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// 2. Also search globally via API (for providers like iTunes)
|
||||||
|
List<MediaItem> globalPodcasts = [];
|
||||||
|
if (!_libraryOnly) {
|
||||||
|
try {
|
||||||
|
globalPodcasts = await provider.api?.searchPodcasts(query) ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Global podcast search failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Combine and deduplicate by name
|
||||||
|
final allPodcasts = <String, MediaItem>{};
|
||||||
|
for (final podcast in libraryPodcasts) {
|
||||||
|
allPodcasts[podcast.name.toLowerCase()] = podcast;
|
||||||
|
}
|
||||||
|
for (final podcast in globalPodcasts) {
|
||||||
|
final key = podcast.name.toLowerCase();
|
||||||
|
if (!allPodcasts.containsKey(key)) {
|
||||||
|
allPodcasts[key] = podcast;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_searchResults = results;
|
_searchResults = {
|
||||||
|
...results,
|
||||||
|
'radios': allRadios.values.toList(),
|
||||||
|
'podcasts': allPodcasts.values.toList(),
|
||||||
|
};
|
||||||
_isSearching = false;
|
_isSearching = false;
|
||||||
_hasSearched = true;
|
_hasSearched = true;
|
||||||
_cachedListItems.clear(); // PERF: Clear cache on new results
|
_cachedListItems.clear(); // PERF: Clear cache on new results
|
||||||
@@ -544,9 +620,12 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
final tracks = _searchResults['tracks'] as List<MediaItem>? ?? [];
|
final tracks = _searchResults['tracks'] as List<MediaItem>? ?? [];
|
||||||
final playlists = _searchResults['playlists'] as List<MediaItem>? ?? [];
|
final playlists = _searchResults['playlists'] as List<MediaItem>? ?? [];
|
||||||
final audiobooks = _searchResults['audiobooks'] as List<MediaItem>? ?? [];
|
final audiobooks = _searchResults['audiobooks'] as List<MediaItem>? ?? [];
|
||||||
|
final radios = _searchResults['radios'] as List<MediaItem>? ?? [];
|
||||||
|
final podcasts = _searchResults['podcasts'] as List<MediaItem>? ?? [];
|
||||||
|
|
||||||
final hasResults = artists.isNotEmpty || albums.isNotEmpty || tracks.isNotEmpty ||
|
final hasResults = artists.isNotEmpty || albums.isNotEmpty || tracks.isNotEmpty ||
|
||||||
playlists.isNotEmpty || audiobooks.isNotEmpty;
|
playlists.isNotEmpty || audiobooks.isNotEmpty || radios.isNotEmpty ||
|
||||||
|
podcasts.isNotEmpty;
|
||||||
|
|
||||||
if (!hasResults) {
|
if (!hasResults) {
|
||||||
return EmptyState.search(context: context);
|
return EmptyState.search(context: context);
|
||||||
@@ -555,31 +634,41 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
// Column layout - filter bar above results, no overlay
|
// Column layout - filter bar above results, no overlay
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Filter tabs - always visible
|
// Filter tabs - rounded container matching search bar width
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 36, // Match library screen filter row height
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
controller: _filterScrollController,
|
controller: _filterScrollController,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
child: _buildFilterSelector(colorScheme),
|
child: _buildFilterSelector(colorScheme),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
// Results with swipeable pages
|
// Results with swipeable pages
|
||||||
Expanded(
|
Expanded(
|
||||||
child: NotificationListener<ScrollNotification>(
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// Main scrollable content
|
||||||
|
NotificationListener<ScrollNotification>(
|
||||||
onNotification: _handleScrollNotification,
|
onNotification: _handleScrollNotification,
|
||||||
child: PageView.builder(
|
child: PageView.builder(
|
||||||
controller: _pageController,
|
controller: _pageController,
|
||||||
onPageChanged: _onPageChanged,
|
onPageChanged: _onPageChanged,
|
||||||
itemCount: _getAvailableFilters().length,
|
itemCount: _getAvailableFilters().length,
|
||||||
|
// Faster settling so vertical scroll works sooner after swipe
|
||||||
|
physics: const _FastPageScrollPhysics(),
|
||||||
itemBuilder: (context, pageIndex) {
|
itemBuilder: (context, pageIndex) {
|
||||||
final filters = _getAvailableFilters();
|
final filters = _getAvailableFilters();
|
||||||
final filterForPage = filters[pageIndex];
|
final filterForPage = filters[pageIndex];
|
||||||
|
|
||||||
// PERF: Use cached list items to avoid rebuilding during animation
|
// PERF: Use cached list items to avoid rebuilding during animation
|
||||||
final listItems = _cachedListItems[filterForPage] ??= _buildListItemsForFilter(
|
final listItems = _cachedListItems[filterForPage] ??= _buildListItemsForFilter(
|
||||||
filterForPage, artists, albums, tracks, playlists, audiobooks,
|
filterForPage, artists, albums, tracks, playlists, audiobooks, radios, podcasts,
|
||||||
);
|
);
|
||||||
|
|
||||||
// PERF: Wrap each page in RepaintBoundary to isolate repaints during swipe
|
// PERF: Wrap each page in RepaintBoundary to isolate repaints during swipe
|
||||||
@@ -588,8 +677,8 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
// PERF: Use key to preserve scroll position per filter
|
// PERF: Use key to preserve scroll position per filter
|
||||||
key: PageStorageKey('list_$filterForPage'),
|
key: PageStorageKey('list_$filterForPage'),
|
||||||
padding: EdgeInsets.fromLTRB(16, 0, 16, BottomSpacing.navBarOnly),
|
padding: EdgeInsets.fromLTRB(16, 16, 16, BottomSpacing.navBarOnly),
|
||||||
cacheExtent: 500,
|
cacheExtent: 1000,
|
||||||
addAutomaticKeepAlives: false,
|
addAutomaticKeepAlives: false,
|
||||||
// PERF: false because each tile already has RepaintBoundary
|
// PERF: false because each tile already has RepaintBoundary
|
||||||
addRepaintBoundaries: false,
|
addRepaintBoundaries: false,
|
||||||
@@ -601,7 +690,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
case _ListItemType.header:
|
case _ListItemType.header:
|
||||||
return _buildSectionHeader(item.headerTitle!, item.headerCount!);
|
return _buildSectionHeader(item.headerTitle!, item.headerCount!);
|
||||||
case _ListItemType.artist:
|
case _ListItemType.artist:
|
||||||
return _buildArtistTile(item.mediaItem! as Artist);
|
return _buildArtistTile(item.mediaItem! as Artist, showType: showTypeInSubtitle);
|
||||||
case _ListItemType.album:
|
case _ListItemType.album:
|
||||||
return _buildAlbumTile(item.mediaItem! as Album, showType: showTypeInSubtitle);
|
return _buildAlbumTile(item.mediaItem! as Album, showType: showTypeInSubtitle);
|
||||||
case _ListItemType.track:
|
case _ListItemType.track:
|
||||||
@@ -610,6 +699,10 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
return _buildPlaylistTile(item.mediaItem! as Playlist, showType: showTypeInSubtitle);
|
return _buildPlaylistTile(item.mediaItem! as Playlist, showType: showTypeInSubtitle);
|
||||||
case _ListItemType.audiobook:
|
case _ListItemType.audiobook:
|
||||||
return _buildAudiobookTile(item.mediaItem! as Audiobook, showType: showTypeInSubtitle);
|
return _buildAudiobookTile(item.mediaItem! as Audiobook, showType: showTypeInSubtitle);
|
||||||
|
case _ListItemType.radio:
|
||||||
|
return _buildRadioTile(item.mediaItem!, showType: showTypeInSubtitle);
|
||||||
|
case _ListItemType.podcast:
|
||||||
|
return _buildPodcastTile(item.mediaItem!, showType: showTypeInSubtitle);
|
||||||
case _ListItemType.spacer:
|
case _ListItemType.spacer:
|
||||||
return const SizedBox(height: 24);
|
return const SizedBox(height: 24);
|
||||||
}
|
}
|
||||||
@@ -619,6 +712,29 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -655,7 +771,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
score += 5;
|
score += 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secondary field matching (artist name for albums/tracks)
|
// Secondary field matching (artist/author/creator name)
|
||||||
if (item is Album) {
|
if (item is Album) {
|
||||||
final artistLower = item.artistsString.toLowerCase();
|
final artistLower = item.artistsString.toLowerCase();
|
||||||
if (artistLower == queryLower) {
|
if (artistLower == queryLower) {
|
||||||
@@ -677,13 +793,93 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
score += 5;
|
score += 5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (item is Audiobook) {
|
||||||
|
// Check audiobook author names
|
||||||
|
final authorLower = item.authorsString.toLowerCase();
|
||||||
|
if (authorLower == queryLower) {
|
||||||
|
score += 15; // Author exact match
|
||||||
|
} else if (authorLower.contains(queryLower)) {
|
||||||
|
score += 8; // Author contains query
|
||||||
|
}
|
||||||
|
// Also check narrator names
|
||||||
|
final narratorLower = item.narratorsString.toLowerCase();
|
||||||
|
if (narratorLower.contains(queryLower)) {
|
||||||
|
score += 5;
|
||||||
|
}
|
||||||
|
} else if (item.mediaType == MediaType.podcast || item.mediaType == MediaType.podcastEpisode) {
|
||||||
|
// Check podcast metadata for author/creator
|
||||||
|
final metadata = item.metadata;
|
||||||
|
bool foundCreatorMatch = false;
|
||||||
|
if (metadata != null) {
|
||||||
|
final author = (metadata['author'] as String? ?? '').toLowerCase();
|
||||||
|
final publisher = (metadata['publisher'] as String? ?? '').toLowerCase();
|
||||||
|
final owner = (metadata['owner'] as String? ?? '').toLowerCase();
|
||||||
|
final creator = (metadata['creator'] as String? ?? '').toLowerCase();
|
||||||
|
|
||||||
|
// Check all possible creator fields
|
||||||
|
final creatorFields = [author, publisher, owner, creator].where((s) => s.isNotEmpty);
|
||||||
|
bool foundExact = false;
|
||||||
|
bool foundContains = false;
|
||||||
|
for (final field in creatorFields) {
|
||||||
|
if (field == queryLower) {
|
||||||
|
foundExact = true;
|
||||||
|
break;
|
||||||
|
} else if (field.contains(queryLower)) {
|
||||||
|
foundContains = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundExact) {
|
||||||
|
score += 15; // Creator exact match
|
||||||
|
foundCreatorMatch = true;
|
||||||
|
} else if (foundContains) {
|
||||||
|
score += 8; // Creator contains query
|
||||||
|
foundCreatorMatch = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check description for keyword matches
|
||||||
|
final description = (metadata['description'] as String? ?? '').toLowerCase();
|
||||||
|
if (description.contains(queryLower)) {
|
||||||
|
score += 5; // Increased from 3 to match other media types
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: If no creator metadata matched, but query matches strongly in name,
|
||||||
|
// give a boost. Podcast names often include the host's name (e.g., "The Louis Theroux Podcast")
|
||||||
|
if (!foundCreatorMatch && nameLower.contains(queryLower)) {
|
||||||
|
// Multi-word query that matches in name suggests creator/host name
|
||||||
|
if (queryLower.contains(' ')) {
|
||||||
|
// Calculate how prominent the query is in the name
|
||||||
|
// e.g., "Louis Theroux Interviews" with query "Louis Theroux" = 14/23 = ~60%
|
||||||
|
final prominence = queryLower.length / nameLower.length;
|
||||||
|
if (prominence >= 0.5) {
|
||||||
|
score += 15; // Query is >50% of name - very strong signal
|
||||||
|
} else if (prominence >= 0.3) {
|
||||||
|
score += 12; // Query is 30-50% of name - strong signal
|
||||||
|
} else {
|
||||||
|
score += 8; // Query in name but less prominent
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
score += 5; // Single word match
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if query matches at a word boundary in text
|
/// Check if query matches at a word boundary in text
|
||||||
|
/// Handles both single-word and multi-word queries
|
||||||
bool _matchesWordBoundary(String text, String query) {
|
bool _matchesWordBoundary(String text, String query) {
|
||||||
|
// For multi-word queries, check if query appears at start of a word in text
|
||||||
|
// e.g., "The Louis Theroux Podcast" contains "louis theroux" at word boundary
|
||||||
|
if (query.contains(' ')) {
|
||||||
|
// Check if text starts with query or contains query after whitespace
|
||||||
|
if (text.startsWith(query)) return true;
|
||||||
|
if (text.contains(' $query')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For single-word queries, check individual words
|
||||||
final words = text.split(RegExp(r'\s+'));
|
final words = text.split(RegExp(r'\s+'));
|
||||||
for (final word in words) {
|
for (final word in words) {
|
||||||
if (word.startsWith(query)) return true;
|
if (word.startsWith(query)) return true;
|
||||||
@@ -758,6 +954,8 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
List<MediaItem> tracks,
|
List<MediaItem> tracks,
|
||||||
List<MediaItem> playlists,
|
List<MediaItem> playlists,
|
||||||
List<MediaItem> audiobooks,
|
List<MediaItem> audiobooks,
|
||||||
|
List<MediaItem> radios,
|
||||||
|
List<MediaItem> podcasts,
|
||||||
) {
|
) {
|
||||||
final items = <_ListItem>[];
|
final items = <_ListItem>[];
|
||||||
final query = _searchController.text;
|
final query = _searchController.text;
|
||||||
@@ -786,6 +984,14 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
final score = _calculateRelevanceScore(audiobook, query);
|
final score = _calculateRelevanceScore(audiobook, query);
|
||||||
scoredItems.add(_ListItem.audiobook(audiobook, relevanceScore: score));
|
scoredItems.add(_ListItem.audiobook(audiobook, relevanceScore: score));
|
||||||
}
|
}
|
||||||
|
for (final radio in radios) {
|
||||||
|
final score = _calculateRelevanceScore(radio, query);
|
||||||
|
scoredItems.add(_ListItem.radio(radio, relevanceScore: score));
|
||||||
|
}
|
||||||
|
for (final podcast in podcasts) {
|
||||||
|
final score = _calculateRelevanceScore(podcast, query);
|
||||||
|
scoredItems.add(_ListItem.podcast(podcast, relevanceScore: score));
|
||||||
|
}
|
||||||
|
|
||||||
final crossRefArtists = _extractCrossReferencedArtists(
|
final crossRefArtists = _extractCrossReferencedArtists(
|
||||||
query,
|
query,
|
||||||
@@ -832,6 +1038,18 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filter == 'radios' && radios.isNotEmpty) {
|
||||||
|
for (final radio in radios) {
|
||||||
|
items.add(_ListItem.radio(radio));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter == 'podcasts' && podcasts.isNotEmpty) {
|
||||||
|
for (final podcast in podcasts) {
|
||||||
|
items.add(_ListItem.podcast(podcast));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -848,17 +1066,21 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
case 'tracks': return l10n.tracks;
|
case 'tracks': return l10n.tracks;
|
||||||
case 'playlists': return l10n.playlists;
|
case 'playlists': return l10n.playlists;
|
||||||
case 'audiobooks': return l10n.audiobooks;
|
case 'audiobooks': return l10n.audiobooks;
|
||||||
|
case 'radios': return l10n.radio;
|
||||||
case 'podcasts': return l10n.podcasts;
|
case 'podcasts': return l10n.podcasts;
|
||||||
default: return filter;
|
default: return filter;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ClipRRect(
|
// No ClipRRect here - parent container handles clipping with rounded corners
|
||||||
borderRadius: BorderRadius.circular(8),
|
// Wrap in ValueListenableBuilder for efficient rebuilds on filter change
|
||||||
child: Row(
|
return ValueListenableBuilder<String>(
|
||||||
|
valueListenable: _activeFilterNotifier,
|
||||||
|
builder: (context, activeFilter, _) {
|
||||||
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: filters.map((filter) {
|
children: filters.map((filter) {
|
||||||
final isSelected = _activeFilter == filter;
|
final isSelected = activeFilter == filter;
|
||||||
return Material(
|
return Material(
|
||||||
// Use theme-aware colors for light/dark mode support
|
// Use theme-aware colors for light/dark mode support
|
||||||
color: isSelected
|
color: isSelected
|
||||||
@@ -866,13 +1088,12 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
: colorScheme.surfaceVariant.withOpacity(0.6),
|
: colorScheme.surfaceVariant.withOpacity(0.6),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() {
|
// Only update ValueNotifier - no setState needed
|
||||||
_activeFilter = filter;
|
_activeFilterNotifier.value = filter;
|
||||||
});
|
|
||||||
_animateToFilter(filter);
|
_animateToFilter(filter);
|
||||||
},
|
},
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
getLabel(filter),
|
getLabel(filter),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -887,7 +1108,8 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -905,7 +1127,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildArtistTile(Artist artist) {
|
Widget _buildArtistTile(Artist artist, {bool showType = false}) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final imageUrl = maProvider.getImageUrl(artist, size: 256);
|
final imageUrl = maProvider.getImageUrl(artist, size: 256);
|
||||||
@@ -934,7 +1156,12 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: showType
|
||||||
|
? Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [_buildTypePill('artist', colorScheme)],
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
S.of(context)!.artist,
|
S.of(context)!.artist,
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
),
|
),
|
||||||
@@ -960,9 +1187,6 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final imageUrl = maProvider.getImageUrl(album, size: 128);
|
final imageUrl = maProvider.getImageUrl(album, size: 128);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final subtitleText = showType
|
|
||||||
? '${album.artistsString} • ${S.of(context)!.albumSingular}'
|
|
||||||
: album.artistsString;
|
|
||||||
|
|
||||||
// Use 'search' suffix to avoid hero tag conflicts with library cards
|
// Use 'search' suffix to avoid hero tag conflicts with library cards
|
||||||
const heroSuffix = '_search';
|
const heroSuffix = '_search';
|
||||||
@@ -1010,8 +1234,23 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
tag: HeroTags.artistName + albumId + heroSuffix,
|
tag: HeroTags.artistName + albumId + heroSuffix,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
|
child: showType
|
||||||
|
? Row(
|
||||||
|
children: [
|
||||||
|
_buildTypePill('album', colorScheme),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
subtitleText,
|
album.artistsString,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
album.artistsString,
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -1042,9 +1281,6 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
? maProvider.getImageUrl(track.album!, size: 128)
|
? maProvider.getImageUrl(track.album!, size: 128)
|
||||||
: null;
|
: null;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final subtitleText = showType
|
|
||||||
? '${track.artistsString} • ${S.of(context)!.trackSingular}'
|
|
||||||
: track.artistsString;
|
|
||||||
final trackId = track.uri ?? track.itemId;
|
final trackId = track.uri ?? track.itemId;
|
||||||
final isExpanded = _expandedTrackId == trackId;
|
final isExpanded = _expandedTrackId == trackId;
|
||||||
|
|
||||||
@@ -1080,8 +1316,23 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: showType
|
||||||
subtitleText,
|
? Row(
|
||||||
|
children: [
|
||||||
|
_buildTypePill('track', colorScheme),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
track.artistsString,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
track.artistsString,
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -1198,9 +1449,6 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final imageUrl = maProvider.getImageUrl(playlist, size: 128);
|
final imageUrl = maProvider.getImageUrl(playlist, size: 128);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final subtitleText = showType
|
|
||||||
? (playlist.owner != null ? '${playlist.owner} • ${S.of(context)!.playlist}' : S.of(context)!.playlist)
|
|
||||||
: (playlist.owner ?? S.of(context)!.playlist);
|
|
||||||
|
|
||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
@@ -1231,8 +1479,25 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: showType
|
||||||
subtitleText,
|
? Row(
|
||||||
|
children: [
|
||||||
|
_buildTypePill('playlist', colorScheme),
|
||||||
|
if (playlist.owner != null) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
playlist.owner!,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
playlist.owner ?? S.of(context)!.playlist,
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -1264,9 +1529,6 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
final imageUrl = maProvider.getImageUrl(audiobook, size: 128);
|
final imageUrl = maProvider.getImageUrl(audiobook, size: 128);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final authorText = audiobook.authors?.map((a) => a.name).join(', ') ?? S.of(context)!.unknownAuthor;
|
final authorText = audiobook.authors?.map((a) => a.name).join(', ') ?? S.of(context)!.unknownAuthor;
|
||||||
final subtitleText = showType
|
|
||||||
? '$authorText • ${S.of(context)!.audiobookSingular}'
|
|
||||||
: authorText;
|
|
||||||
|
|
||||||
// Use 'search' suffix to avoid hero tag conflicts with library cards
|
// Use 'search' suffix to avoid hero tag conflicts with library cards
|
||||||
const heroSuffix = '_search';
|
const heroSuffix = '_search';
|
||||||
@@ -1310,8 +1572,23 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: showType
|
||||||
subtitleText,
|
? Row(
|
||||||
|
children: [
|
||||||
|
_buildTypePill('audiobook', colorScheme),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
authorText,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
authorText,
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -1340,6 +1617,166 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildRadioTile(MediaItem radio, {bool showType = false}) {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final imageUrl = maProvider.getImageUrl(radio, size: 128);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: ListTile(
|
||||||
|
key: ValueKey(radio.uri ?? radio.itemId),
|
||||||
|
leading: Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
image: imageUrl != null
|
||||||
|
? DecorationImage(
|
||||||
|
image: CachedNetworkImageProvider(imageUrl),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: imageUrl == null
|
||||||
|
? Icon(Icons.radio_rounded, color: colorScheme.onSurfaceVariant)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
radio.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: showType
|
||||||
|
? Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [_buildTypePill('radio', colorScheme)],
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
S.of(context)!.radio,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
onTap: () => _playRadioStation(radio),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPodcastTile(MediaItem podcast, {bool showType = false}) {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final imageUrl = maProvider.getPodcastImageUrl(podcast, size: 128);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// Use 'search' suffix to avoid hero tag conflicts with library cards
|
||||||
|
const heroSuffix = '_search';
|
||||||
|
final podcastId = podcast.uri ?? podcast.itemId;
|
||||||
|
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: ListTile(
|
||||||
|
key: ValueKey(podcastId),
|
||||||
|
leading: Hero(
|
||||||
|
tag: HeroTags.podcastCover + podcastId + heroSuffix,
|
||||||
|
child: Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: imageUrl != null
|
||||||
|
? ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 256,
|
||||||
|
memCacheHeight: 256,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => Icon(Icons.podcasts_rounded, color: colorScheme.onSurfaceVariant),
|
||||||
|
errorWidget: (_, __, ___) => Icon(Icons.podcasts_rounded, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(Icons.podcasts_rounded, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
podcast.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: showType
|
||||||
|
? Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [_buildTypePill('podcast', colorScheme)],
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
S.of(context)!.podcasts,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
// Update adaptive colors before navigation
|
||||||
|
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
FadeSlidePageRoute(
|
||||||
|
child: PodcastDetailScreen(
|
||||||
|
podcast: podcast,
|
||||||
|
heroTagSuffix: 'search',
|
||||||
|
initialImageUrl: imageUrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _playRadioStation(MediaItem station) async {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
if (maProvider.selectedPlayer == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await maProvider.api?.playRadioStation(
|
||||||
|
maProvider.selectedPlayer!.playerId,
|
||||||
|
station,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(S.of(context)!.playingRadioStation(station.name)),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to play radio station: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _playTrack(Track track) async {
|
Future<void> _playTrack(Track track) async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
@@ -1541,4 +1978,123 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
final seconds = duration.inSeconds % 60;
|
final seconds = duration.inSeconds % 60;
|
||||||
return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a colored type pill for media type identification in search results
|
||||||
|
Widget _buildTypePill(String type, ColorScheme colorScheme) {
|
||||||
|
Color backgroundColor;
|
||||||
|
Color textColor;
|
||||||
|
String label;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'track':
|
||||||
|
backgroundColor = Colors.blue.shade100;
|
||||||
|
textColor = Colors.blue.shade800;
|
||||||
|
label = S.of(context)!.trackSingular;
|
||||||
|
break;
|
||||||
|
case 'album':
|
||||||
|
backgroundColor = Colors.purple.shade100;
|
||||||
|
textColor = Colors.purple.shade800;
|
||||||
|
label = S.of(context)!.albumSingular;
|
||||||
|
break;
|
||||||
|
case 'artist':
|
||||||
|
backgroundColor = Colors.pink.shade100;
|
||||||
|
textColor = Colors.pink.shade800;
|
||||||
|
label = S.of(context)!.artist;
|
||||||
|
break;
|
||||||
|
case 'playlist':
|
||||||
|
backgroundColor = Colors.teal.shade100;
|
||||||
|
textColor = Colors.teal.shade800;
|
||||||
|
label = S.of(context)!.playlist;
|
||||||
|
break;
|
||||||
|
case 'audiobook':
|
||||||
|
backgroundColor = Colors.orange.shade100;
|
||||||
|
textColor = Colors.orange.shade800;
|
||||||
|
label = S.of(context)!.audiobookSingular;
|
||||||
|
break;
|
||||||
|
case 'radio':
|
||||||
|
backgroundColor = Colors.red.shade100;
|
||||||
|
textColor = Colors.red.shade800;
|
||||||
|
label = S.of(context)!.radio;
|
||||||
|
break;
|
||||||
|
case 'podcast':
|
||||||
|
backgroundColor = Colors.green.shade100;
|
||||||
|
textColor = Colors.green.shade800;
|
||||||
|
label = S.of(context)!.podcastSingular;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
backgroundColor = colorScheme.surfaceVariant;
|
||||||
|
textColor = colorScheme.onSurfaceVariant;
|
||||||
|
label = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For dark mode, use darker variants
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
if (isDark) {
|
||||||
|
switch (type) {
|
||||||
|
case 'track':
|
||||||
|
backgroundColor = Colors.blue.shade900.withOpacity(0.5);
|
||||||
|
textColor = Colors.blue.shade200;
|
||||||
|
break;
|
||||||
|
case 'album':
|
||||||
|
backgroundColor = Colors.purple.shade900.withOpacity(0.5);
|
||||||
|
textColor = Colors.purple.shade200;
|
||||||
|
break;
|
||||||
|
case 'artist':
|
||||||
|
backgroundColor = Colors.pink.shade900.withOpacity(0.5);
|
||||||
|
textColor = Colors.pink.shade200;
|
||||||
|
break;
|
||||||
|
case 'playlist':
|
||||||
|
backgroundColor = Colors.teal.shade900.withOpacity(0.5);
|
||||||
|
textColor = Colors.teal.shade200;
|
||||||
|
break;
|
||||||
|
case 'audiobook':
|
||||||
|
backgroundColor = Colors.orange.shade900.withOpacity(0.5);
|
||||||
|
textColor = Colors.orange.shade200;
|
||||||
|
break;
|
||||||
|
case 'radio':
|
||||||
|
backgroundColor = Colors.red.shade900.withOpacity(0.5);
|
||||||
|
textColor = Colors.red.shade200;
|
||||||
|
break;
|
||||||
|
case 'podcast':
|
||||||
|
backgroundColor = Colors.green.shade900.withOpacity(0.5);
|
||||||
|
textColor = Colors.green.shade200;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fast settling physics for horizontal page swipes.
|
||||||
|
/// Reduces the time the page animation takes to settle, so vertical scrolling
|
||||||
|
/// within the page becomes responsive 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: 50, // Lower mass = faster movement
|
||||||
|
stiffness: 500, // Higher stiffness = snappier
|
||||||
|
damping: 1.0, // Critical damping for no overshoot
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,6 +498,253 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<MediaItem>> getRadioStations({
|
||||||
|
int? limit,
|
||||||
|
int? offset,
|
||||||
|
bool? favoriteOnly,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await _sendCommand(
|
||||||
|
'music/radios/library_items',
|
||||||
|
args: {
|
||||||
|
if (limit != null) 'limit': limit,
|
||||||
|
if (offset != null) 'offset': offset,
|
||||||
|
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final items = response['items'] as List<dynamic>? ?? response['result'] as List<dynamic>?;
|
||||||
|
if (items == null) return [];
|
||||||
|
|
||||||
|
return items
|
||||||
|
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error getting radio stations: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search for radio stations globally (across all providers like TuneIn)
|
||||||
|
Future<List<MediaItem>> searchRadioStations(String query, {int limit = 25}) async {
|
||||||
|
try {
|
||||||
|
_logger.log('📻 Searching radio stations for: $query');
|
||||||
|
final response = await _sendCommand(
|
||||||
|
'music/search',
|
||||||
|
args: {
|
||||||
|
'search_query': query,
|
||||||
|
'media_types': ['radio'],
|
||||||
|
'limit': limit,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = response['result'] as Map<String, dynamic>?;
|
||||||
|
if (result == null) {
|
||||||
|
_logger.log('📻 Radio search: no result');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Radio results might be under 'radios' or 'radio' key
|
||||||
|
final radios = (result['radios'] as List<dynamic>?) ??
|
||||||
|
(result['radio'] as List<dynamic>?) ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
_logger.log('📻 Radio search found ${radios.length} results');
|
||||||
|
|
||||||
|
return radios
|
||||||
|
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('📻 Error searching radio stations: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ PODCASTS ============
|
||||||
|
|
||||||
|
/// Get all podcasts from the library
|
||||||
|
Future<List<MediaItem>> getPodcasts({
|
||||||
|
int? limit,
|
||||||
|
int? offset,
|
||||||
|
bool? favoriteOnly,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
_logger.log('🎙️ Getting podcasts...');
|
||||||
|
final response = await _sendCommand(
|
||||||
|
'music/podcasts/library_items',
|
||||||
|
args: {
|
||||||
|
if (limit != null) 'limit': limit,
|
||||||
|
if (offset != null) 'offset': offset,
|
||||||
|
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final items = response['items'] as List<dynamic>? ?? response['result'] as List<dynamic>?;
|
||||||
|
if (items == null) {
|
||||||
|
_logger.log('🎙️ Podcasts: result is null');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('🎙️ Found ${items.length} podcasts');
|
||||||
|
|
||||||
|
return items
|
||||||
|
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('🎙️ Error getting podcasts: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get episodes for a specific podcast
|
||||||
|
Future<List<MediaItem>> getPodcastEpisodes(String podcastId, {
|
||||||
|
String? provider,
|
||||||
|
int? limit,
|
||||||
|
int? offset,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
_logger.log('🎙️ Getting episodes for podcast: $podcastId, provider: $provider');
|
||||||
|
|
||||||
|
// Use the correct endpoint: music/podcasts/podcast_episodes
|
||||||
|
final response = await _sendCommand(
|
||||||
|
'music/podcasts/podcast_episodes',
|
||||||
|
args: {
|
||||||
|
'item_id': podcastId,
|
||||||
|
if (provider != null) 'provider_instance_id_or_domain': provider,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
_logger.log('🎙️ Response keys: ${response.keys.toList()}');
|
||||||
|
|
||||||
|
// Response should be a list of episodes
|
||||||
|
final episodes = response['result'] as List<dynamic>?;
|
||||||
|
if (episodes != null && episodes.isNotEmpty) {
|
||||||
|
_logger.log('🎙️ Found ${episodes.length} episodes');
|
||||||
|
return episodes
|
||||||
|
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('🎙️ No episodes found for podcast');
|
||||||
|
return [];
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('🎙️ Error getting podcast episodes: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get best available image URL for a podcast
|
||||||
|
/// Falls back to first episode's image if podcast image is unavailable or low quality
|
||||||
|
Future<String?> getPodcastCoverUrl(MediaItem podcast, {int size = 1024}) async {
|
||||||
|
// First try the podcast's own image
|
||||||
|
final podcastImage = getImageUrl(podcast, size: size);
|
||||||
|
|
||||||
|
// Check if podcast has images in metadata
|
||||||
|
final images = podcast.metadata?['images'] as List<dynamic>?;
|
||||||
|
if (images != null && images.isNotEmpty) {
|
||||||
|
// Podcast has images, use them
|
||||||
|
return podcastImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No podcast image, try to get first episode's image
|
||||||
|
try {
|
||||||
|
_logger.log('🎙️ No podcast cover, fetching episode cover for: ${podcast.name}');
|
||||||
|
final episodes = await getPodcastEpisodes(
|
||||||
|
podcast.itemId,
|
||||||
|
provider: podcast.provider,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (episodes.isNotEmpty) {
|
||||||
|
final firstEpisode = episodes.first;
|
||||||
|
final episodeImage = getImageUrl(firstEpisode, size: size);
|
||||||
|
if (episodeImage != null) {
|
||||||
|
_logger.log('🎙️ Using episode cover for podcast: ${podcast.name}');
|
||||||
|
return episodeImage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('🎙️ Error fetching episode cover: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to podcast image even if it might be low quality
|
||||||
|
return podcastImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse browse results into MediaItem episodes
|
||||||
|
List<MediaItem> _parseBrowseEpisodes(List<dynamic> items) {
|
||||||
|
final episodes = <MediaItem>[];
|
||||||
|
for (final item in items) {
|
||||||
|
if (item is Map<String, dynamic>) {
|
||||||
|
final name = item['name'] as String? ??
|
||||||
|
item['label'] as String? ??
|
||||||
|
'Episode';
|
||||||
|
final uri = item['uri'] as String? ?? item['path'] as String?;
|
||||||
|
final itemId = item['item_id'] as String? ?? uri ?? '';
|
||||||
|
final provider = item['provider'] as String? ?? 'library';
|
||||||
|
|
||||||
|
// Parse media type from string
|
||||||
|
final mediaTypeStr = item['media_type'] as String?;
|
||||||
|
MediaType mediaType = MediaType.track;
|
||||||
|
if (mediaTypeStr != null) {
|
||||||
|
mediaType = MediaType.values.firstWhere(
|
||||||
|
(e) => e.name == mediaTypeStr || e.name == mediaTypeStr.toLowerCase(),
|
||||||
|
orElse: () => MediaType.track,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get duration if available
|
||||||
|
Duration? duration;
|
||||||
|
final durationVal = item['duration'];
|
||||||
|
if (durationVal is int) {
|
||||||
|
duration = Duration(seconds: durationVal);
|
||||||
|
} else if (durationVal is double) {
|
||||||
|
duration = Duration(seconds: durationVal.toInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
episodes.add(MediaItem(
|
||||||
|
itemId: itemId,
|
||||||
|
provider: provider,
|
||||||
|
name: name,
|
||||||
|
uri: uri,
|
||||||
|
metadata: item['metadata'] as Map<String, dynamic>?,
|
||||||
|
mediaType: mediaType,
|
||||||
|
duration: duration,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return episodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play a podcast episode
|
||||||
|
Future<void> playPodcastEpisode(String playerId, MediaItem episode) async {
|
||||||
|
try {
|
||||||
|
// Try episode.uri first, then construct from itemId
|
||||||
|
String uri;
|
||||||
|
if (episode.uri != null && episode.uri!.isNotEmpty) {
|
||||||
|
uri = episode.uri!;
|
||||||
|
} else {
|
||||||
|
// Construct URI based on media type
|
||||||
|
uri = 'library://podcast_episode/${episode.itemId}';
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('🎙️ Playing podcast episode: ${episode.name}');
|
||||||
|
_logger.log('🎙️ Episode URI: $uri');
|
||||||
|
_logger.log('🎙️ Episode itemId: ${episode.itemId}');
|
||||||
|
|
||||||
|
await _sendCommand(
|
||||||
|
'player_queues/play_media',
|
||||||
|
args: {
|
||||||
|
'queue_id': playerId,
|
||||||
|
'media': [uri],
|
||||||
|
'option': 'replace',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('🎙️ Error playing podcast episode: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<Audiobook>> getAudiobooks({
|
Future<List<Audiobook>> getAudiobooks({
|
||||||
int? limit,
|
int? limit,
|
||||||
int? offset,
|
int? offset,
|
||||||
@@ -691,6 +938,26 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Play a radio station
|
||||||
|
Future<void> playRadioStation(String playerId, MediaItem station) async {
|
||||||
|
try {
|
||||||
|
final uri = station.uri ?? 'library://radio/${station.itemId}';
|
||||||
|
_logger.log('📻 Playing radio station: ${station.name} with URI: $uri');
|
||||||
|
|
||||||
|
await _sendCommand(
|
||||||
|
'player_queues/play_media',
|
||||||
|
args: {
|
||||||
|
'queue_id': playerId,
|
||||||
|
'media': [uri],
|
||||||
|
'option': 'replace',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error playing radio station: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get full audiobook details including chapters
|
/// Get full audiobook details including chapters
|
||||||
Future<Audiobook?> getAudiobookDetails(String provider, String itemId) async {
|
Future<Audiobook?> getAudiobookDetails(String provider, String itemId) async {
|
||||||
try {
|
try {
|
||||||
@@ -1399,7 +1666,7 @@ class MusicAssistantAPI {
|
|||||||
|
|
||||||
final result = searchResponse['result'] as Map<String, dynamic>?;
|
final result = searchResponse['result'] as Map<String, dynamic>?;
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'radios': []};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse results from the search response
|
// Parse results from the search response
|
||||||
@@ -1428,6 +1695,16 @@ class MusicAssistantAPI {
|
|||||||
.toList() ??
|
.toList() ??
|
||||||
[];
|
[];
|
||||||
|
|
||||||
|
final radios = (result['radios'] as List<dynamic>?)
|
||||||
|
?.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
final podcasts = (result['podcasts'] as List<dynamic>?)
|
||||||
|
?.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
|
||||||
// Deduplicate results by name
|
// Deduplicate results by name
|
||||||
// MA returns both library items and provider items for the same content
|
// MA returns both library items and provider items for the same content
|
||||||
// Prefer library items (provider='library') as they have all provider mappings
|
// Prefer library items (provider='library') as they have all provider mappings
|
||||||
@@ -1437,18 +1714,55 @@ class MusicAssistantAPI {
|
|||||||
'tracks': _deduplicateResults(tracks),
|
'tracks': _deduplicateResults(tracks),
|
||||||
'playlists': _deduplicateResults(playlists),
|
'playlists': _deduplicateResults(playlists),
|
||||||
'audiobooks': _deduplicateResults(audiobooks),
|
'audiobooks': _deduplicateResults(audiobooks),
|
||||||
|
'radios': _deduplicateResults(radios),
|
||||||
|
'podcasts': _deduplicateResults(podcasts),
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Error searching: $e');
|
_logger.log('Error searching: $e');
|
||||||
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'radios': [], 'podcasts': []};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
).catchError((e) {
|
).catchError((e) {
|
||||||
_logger.log('Error searching after retries: $e');
|
_logger.log('Error searching after retries: $e');
|
||||||
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'radios': [], 'podcasts': []};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Search for podcasts globally (across all providers)
|
||||||
|
Future<List<MediaItem>> searchPodcasts(String query, {int limit = 25}) async {
|
||||||
|
try {
|
||||||
|
_logger.log('🎙️ Searching podcasts for: $query');
|
||||||
|
final response = await _sendCommand(
|
||||||
|
'music/search',
|
||||||
|
args: {
|
||||||
|
'search_query': query,
|
||||||
|
'media_types': ['podcast'],
|
||||||
|
'limit': limit,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = response['result'] as Map<String, dynamic>?;
|
||||||
|
if (result == null) {
|
||||||
|
_logger.log('🎙️ Podcast search: no result');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Podcast results might be under 'podcasts' or 'podcast' key
|
||||||
|
final podcasts = (result['podcasts'] as List<dynamic>?) ??
|
||||||
|
(result['podcast'] as List<dynamic>?) ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
_logger.log('🎙️ Podcast search found ${podcasts.length} results');
|
||||||
|
|
||||||
|
return podcasts
|
||||||
|
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('🎙️ Error searching podcasts: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Deduplicate search results by name (case-insensitive)
|
/// Deduplicate search results by name (case-insensitive)
|
||||||
/// Prefers library items (provider='library') over provider-specific items
|
/// Prefers library items (provider='library') over provider-specific items
|
||||||
/// since library items have complete provider mappings
|
/// since library items have complete provider mappings
|
||||||
@@ -2620,6 +2934,57 @@ class MusicAssistantAPI {
|
|||||||
return '$baseUrl/imageproxy?provider=${Uri.encodeComponent(provider ?? "")}&size=$size&fmt=jpeg&path=${Uri.encodeComponent(imagePath)}';
|
return '$baseUrl/imageproxy?provider=${Uri.encodeComponent(provider ?? "")}&size=$size&fmt=jpeg&path=${Uri.encodeComponent(imagePath)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== iTunes Artwork Lookup ====================
|
||||||
|
|
||||||
|
/// Search iTunes for a podcast and return high-res artwork URL
|
||||||
|
/// Returns null if not found or on error
|
||||||
|
Future<String?> getITunesPodcastArtwork(String podcastName) async {
|
||||||
|
try {
|
||||||
|
final searchTerm = Uri.encodeComponent(podcastName);
|
||||||
|
final url = 'https://itunes.apple.com/search?term=$searchTerm&entity=podcast&limit=1';
|
||||||
|
|
||||||
|
final client = HttpClient();
|
||||||
|
client.connectionTimeout = const Duration(seconds: 10);
|
||||||
|
|
||||||
|
final request = await client.getUrl(Uri.parse(url));
|
||||||
|
final response = await request.close();
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
_logger.log('🎨 iTunes search failed for "$podcastName": ${response.statusCode}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = await response.transform(utf8.decoder).join();
|
||||||
|
final json = jsonDecode(body) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
final results = json['results'] as List<dynamic>?;
|
||||||
|
if (results == null || results.isEmpty) {
|
||||||
|
_logger.log('🎨 No iTunes results for "$podcastName"');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final podcast = results.first as Map<String, dynamic>;
|
||||||
|
var artworkUrl = podcast['artworkUrl600'] as String?;
|
||||||
|
|
||||||
|
if (artworkUrl == null) {
|
||||||
|
artworkUrl = podcast['artworkUrl100'] as String?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artworkUrl != null) {
|
||||||
|
// Use 800x800 - good quality without being excessive (was 1400x1400)
|
||||||
|
artworkUrl = artworkUrl
|
||||||
|
.replaceAll('600x600', '800x800')
|
||||||
|
.replaceAll('100x100', '800x800');
|
||||||
|
_logger.log('🎨 Found iTunes artwork for "$podcastName": $artworkUrl');
|
||||||
|
}
|
||||||
|
|
||||||
|
return artworkUrl;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('🎨 iTunes lookup error for "$podcastName": $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Authentication Methods ====================
|
// ==================== Authentication Methods ====================
|
||||||
|
|
||||||
/// Authenticate the WebSocket session with a token
|
/// Authenticate the WebSocket session with a token
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ class SettingsService {
|
|||||||
static const String _keyLibrarySeriesViewMode = 'library_series_view_mode'; // 'grid2', 'grid3', 'list'
|
static const String _keyLibrarySeriesViewMode = 'library_series_view_mode'; // 'grid2', 'grid3', 'list'
|
||||||
static const String _keySeriesAudiobooksSortOrder = 'series_audiobooks_sort_order'; // 'alpha' or 'year'
|
static const String _keySeriesAudiobooksSortOrder = 'series_audiobooks_sort_order'; // 'alpha' or 'year'
|
||||||
static const String _keySeriesAudiobooksViewMode = 'series_audiobooks_view_mode'; // 'grid2', 'grid3', 'list'
|
static const String _keySeriesAudiobooksViewMode = 'series_audiobooks_view_mode'; // 'grid2', 'grid3', 'list'
|
||||||
|
static const String _keyLibraryRadioViewMode = 'library_radio_view_mode'; // 'grid2', 'grid3', 'list'
|
||||||
|
static const String _keyLibraryPodcastsViewMode = 'library_podcasts_view_mode'; // 'grid2', 'grid3', 'list'
|
||||||
|
|
||||||
// Audiobookshelf Direct Integration Settings
|
// Audiobookshelf Direct Integration Settings
|
||||||
static const String _keyAbsServerUrl = 'abs_server_url';
|
static const String _keyAbsServerUrl = 'abs_server_url';
|
||||||
@@ -81,6 +83,9 @@ class SettingsService {
|
|||||||
static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players
|
static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players
|
||||||
static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen
|
static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen
|
||||||
|
|
||||||
|
// Podcast Cover Cache (iTunes URLs for high-res artwork)
|
||||||
|
static const String _keyPodcastCoverCache = 'podcast_cover_cache';
|
||||||
|
|
||||||
static Future<String?> getServerUrl() async {
|
static Future<String?> getServerUrl() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
return prefs.getString(_keyServerUrl);
|
return prefs.getString(_keyServerUrl);
|
||||||
@@ -621,6 +626,26 @@ class SettingsService {
|
|||||||
await prefs.setString(_keyLibrarySeriesViewMode, mode);
|
await prefs.setString(_keyLibrarySeriesViewMode, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<String> getLibraryRadioViewMode() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyLibraryRadioViewMode) ?? 'list';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setLibraryRadioViewMode(String mode) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyLibraryRadioViewMode, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String> getLibraryPodcastsViewMode() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyLibraryPodcastsViewMode) ?? 'grid2';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setLibraryPodcastsViewMode(String mode) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyLibraryPodcastsViewMode, mode);
|
||||||
|
}
|
||||||
|
|
||||||
// View Mode Settings - Series Audiobooks (series detail screen)
|
// View Mode Settings - Series Audiobooks (series detail screen)
|
||||||
static Future<String> getSeriesAudiobooksSortOrder() async {
|
static Future<String> getSeriesAudiobooksSortOrder() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -786,4 +811,33 @@ class SettingsService {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool(_keyHasCompletedOnboarding, completed);
|
await prefs.setBool(_keyHasCompletedOnboarding, completed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Podcast Cover Cache (iTunes URLs for high-res artwork)
|
||||||
|
// Stored as JSON: {"podcastId": "itunesUrl", ...}
|
||||||
|
|
||||||
|
/// Get cached podcast cover URLs (iTunes high-res)
|
||||||
|
static Future<Map<String, String>> getPodcastCoverCache() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final json = prefs.getString(_keyPodcastCoverCache);
|
||||||
|
if (json == null) return {};
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(json) as Map<String, dynamic>;
|
||||||
|
return decoded.map((key, value) => MapEntry(key, value as String));
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save podcast cover cache
|
||||||
|
static Future<void> setPodcastCoverCache(Map<String, String> cache) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyPodcastCoverCache, jsonEncode(cache));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a single podcast cover to cache (for incremental updates)
|
||||||
|
static Future<void> addPodcastCoverToCache(String podcastId, String url) async {
|
||||||
|
final cache = await getPodcastCoverCache();
|
||||||
|
cache[podcastId] = url;
|
||||||
|
await setPodcastCoverCache(cache);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
@@ -13,12 +14,16 @@ class AlbumCard extends StatefulWidget {
|
|||||||
final Album album;
|
final Album album;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final String? heroTagSuffix;
|
final String? heroTagSuffix;
|
||||||
|
/// Image decode size in pixels. Defaults to 256.
|
||||||
|
/// Use smaller values (e.g., 128) for list views, larger for grids.
|
||||||
|
final int? imageCacheSize;
|
||||||
|
|
||||||
const AlbumCard({
|
const AlbumCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.album,
|
required this.album,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.heroTagSuffix,
|
this.heroTagSuffix,
|
||||||
|
this.imageCacheSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -30,6 +35,10 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
bool _triedFallback = false;
|
bool _triedFallback = false;
|
||||||
bool _maImageFailed = false;
|
bool _maImageFailed = false;
|
||||||
String? _cachedMaImageUrl;
|
String? _cachedMaImageUrl;
|
||||||
|
Timer? _fallbackTimer;
|
||||||
|
|
||||||
|
/// Delay before fetching fallback images to avoid requests during fast scroll
|
||||||
|
static const _fallbackDelay = Duration(milliseconds: 200);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -37,6 +46,12 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
_initFallbackImage();
|
_initFallbackImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_fallbackTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
void _initFallbackImage() {
|
void _initFallbackImage() {
|
||||||
// Check if MA has an image after first build, then fetch fallback if needed
|
// Check if MA has an image after first build, then fetch fallback if needed
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -47,6 +62,16 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
|
|
||||||
if (maImageUrl == null && !_triedFallback) {
|
if (maImageUrl == null && !_triedFallback) {
|
||||||
_triedFallback = true;
|
_triedFallback = true;
|
||||||
|
_scheduleFallbackFetch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedule fallback fetch with delay to avoid requests during fast scroll
|
||||||
|
void _scheduleFallbackFetch() {
|
||||||
|
_fallbackTimer?.cancel();
|
||||||
|
_fallbackTimer = Timer(_fallbackDelay, () {
|
||||||
|
if (mounted) {
|
||||||
_fetchFallbackImage();
|
_fetchFallbackImage();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -69,7 +94,7 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
if (!_triedFallback && !_maImageFailed) {
|
if (!_triedFallback && !_maImageFailed) {
|
||||||
_maImageFailed = true;
|
_maImageFailed = true;
|
||||||
_triedFallback = true;
|
_triedFallback = true;
|
||||||
_fetchFallbackImage();
|
_scheduleFallbackFetch();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +111,9 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
// Use fallback if MA image failed or wasn't available
|
// Use fallback if MA image failed or wasn't available
|
||||||
final imageUrl = (_maImageFailed || maImageUrl == null) ? _fallbackImageUrl : maImageUrl;
|
final imageUrl = (_maImageFailed || maImageUrl == null) ? _fallbackImageUrl : maImageUrl;
|
||||||
|
|
||||||
|
// PERF: Use appropriate cache size based on display size
|
||||||
|
final cacheSize = widget.imageCacheSize ?? 256;
|
||||||
|
|
||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: widget.onTap ?? () {
|
onTap: widget.onTap ?? () {
|
||||||
@@ -118,8 +146,8 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
memCacheWidth: 256,
|
memCacheWidth: cacheSize,
|
||||||
memCacheHeight: 256,
|
memCacheHeight: cacheSize,
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (context, url) => const SizedBox(),
|
placeholder: (context, url) => const SizedBox(),
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
|||||||
return ScrollConfiguration(
|
return ScrollConfiguration(
|
||||||
behavior: const _StretchScrollBehavior(),
|
behavior: const _StretchScrollBehavior(),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
itemCount: _albums.length,
|
itemCount: _albums.length,
|
||||||
@@ -135,7 +136,9 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
|||||||
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final album = _albums[index];
|
final album = _albums[index];
|
||||||
return Container(
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4.0),
|
||||||
|
child: Container(
|
||||||
key: ValueKey(album.uri ?? album.itemId),
|
key: ValueKey(album.uri ?? album.itemId),
|
||||||
width: cardWidth,
|
width: cardWidth,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||||
@@ -143,6 +146,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
|||||||
album: album,
|
album: album,
|
||||||
heroTagSuffix: widget.heroTagSuffix,
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -32,6 +33,10 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
|
|||||||
String? _fallbackImageUrl;
|
String? _fallbackImageUrl;
|
||||||
bool _triedFallback = false;
|
bool _triedFallback = false;
|
||||||
bool _maImageFailed = false;
|
bool _maImageFailed = false;
|
||||||
|
Timer? _fallbackTimer;
|
||||||
|
|
||||||
|
/// Delay before fetching fallback images to avoid requests during fast scroll
|
||||||
|
static const _fallbackDelay = Duration(milliseconds: 200);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -39,6 +44,12 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
|
|||||||
_loadImage();
|
_loadImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_fallbackTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadImage() async {
|
Future<void> _loadImage() async {
|
||||||
final provider = context.read<MusicAssistantProvider>();
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
@@ -56,14 +67,23 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to external sources (MA returned null)
|
// Fallback to external sources (MA returned null) - with delay
|
||||||
|
_scheduleFallbackFetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedule fallback fetch with delay to avoid requests during fast scroll
|
||||||
|
void _scheduleFallbackFetch() {
|
||||||
|
if (_triedFallback) return;
|
||||||
|
_triedFallback = true;
|
||||||
|
_fallbackTimer?.cancel();
|
||||||
|
_fallbackTimer = Timer(_fallbackDelay, () {
|
||||||
|
if (mounted) {
|
||||||
_fetchFallbackImage();
|
_fetchFallbackImage();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _fetchFallbackImage() async {
|
Future<void> _fetchFallbackImage() async {
|
||||||
if (_triedFallback) return;
|
|
||||||
_triedFallback = true;
|
|
||||||
|
|
||||||
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
|
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
|
||||||
_fallbackImageUrl = fallbackUrl;
|
_fallbackImageUrl = fallbackUrl;
|
||||||
|
|
||||||
@@ -79,7 +99,7 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
|
|||||||
// When MA image fails to load, try Deezer fallback
|
// When MA image fails to load, try Deezer fallback
|
||||||
if (!_maImageFailed) {
|
if (!_maImageFailed) {
|
||||||
_maImageFailed = true;
|
_maImageFailed = true;
|
||||||
_fetchFallbackImage();
|
_scheduleFallbackFetch();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +120,9 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
|
|||||||
width: widget.radius * 2,
|
width: widget.radius * 2,
|
||||||
height: widget.radius * 2,
|
height: widget.radius * 2,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
// PERF: Use imageSize for memory cache to reduce decode overhead
|
||||||
|
memCacheWidth: widget.imageSize,
|
||||||
|
memCacheHeight: widget.imageSize,
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (context, url) => Container(
|
placeholder: (context, url) => Container(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
@@ -14,14 +15,16 @@ class ArtistCard extends StatefulWidget {
|
|||||||
final Artist artist;
|
final Artist artist;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final String? heroTagSuffix;
|
final String? heroTagSuffix;
|
||||||
final double? imageSize;
|
/// Image decode size in pixels. Defaults to 256.
|
||||||
|
/// Use smaller values (e.g., 128) for list views, larger for grids.
|
||||||
|
final int? imageCacheSize;
|
||||||
|
|
||||||
const ArtistCard({
|
const ArtistCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.artist,
|
required this.artist,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.heroTagSuffix,
|
this.heroTagSuffix,
|
||||||
this.imageSize,
|
this.imageCacheSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -34,6 +37,10 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
bool _triedFallback = false;
|
bool _triedFallback = false;
|
||||||
bool _maImageFailed = false;
|
bool _maImageFailed = false;
|
||||||
String? _cachedMaImageUrl;
|
String? _cachedMaImageUrl;
|
||||||
|
Timer? _fallbackTimer;
|
||||||
|
|
||||||
|
/// Delay before fetching fallback images to avoid requests during fast scroll
|
||||||
|
static const _fallbackDelay = Duration(milliseconds: 200);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -42,6 +49,12 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
_initFallbackImage();
|
_initFallbackImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_fallbackTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
void _initFallbackImage() {
|
void _initFallbackImage() {
|
||||||
// We'll check if MA has an image after first build, then fetch fallback if needed
|
// We'll check if MA has an image after first build, then fetch fallback if needed
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -52,7 +65,17 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
|
|
||||||
if (maImageUrl == null && !_triedFallback) {
|
if (maImageUrl == null && !_triedFallback) {
|
||||||
_triedFallback = true;
|
_triedFallback = true;
|
||||||
_logger.debug('No MA image for "${widget.artist.name}", trying fallback', context: 'ArtistCard');
|
_logger.debug('No MA image for "${widget.artist.name}", scheduling fallback', context: 'ArtistCard');
|
||||||
|
_scheduleFallbackFetch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedule fallback fetch with delay to avoid requests during fast scroll
|
||||||
|
void _scheduleFallbackFetch() {
|
||||||
|
_fallbackTimer?.cancel();
|
||||||
|
_fallbackTimer = Timer(_fallbackDelay, () {
|
||||||
|
if (mounted) {
|
||||||
_fetchFallbackImage();
|
_fetchFallbackImage();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -63,8 +86,8 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
if (!_triedFallback && !_maImageFailed) {
|
if (!_triedFallback && !_maImageFailed) {
|
||||||
_maImageFailed = true;
|
_maImageFailed = true;
|
||||||
_triedFallback = true;
|
_triedFallback = true;
|
||||||
_logger.debug('MA image failed for "${widget.artist.name}", trying fallback', context: 'ArtistCard');
|
_logger.debug('MA image failed for "${widget.artist.name}", scheduling fallback', context: 'ArtistCard');
|
||||||
_fetchFallbackImage();
|
_scheduleFallbackFetch();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +104,9 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
// Use fallback if MA image failed or wasn't available
|
// Use fallback if MA image failed or wasn't available
|
||||||
final imageUrl = (_maImageFailed || maImageUrl == null) ? _fallbackImageUrl : maImageUrl;
|
final imageUrl = (_maImageFailed || maImageUrl == null) ? _fallbackImageUrl : maImageUrl;
|
||||||
|
|
||||||
|
// PERF: Use appropriate cache size based on display size
|
||||||
|
final cacheSize = widget.imageCacheSize ?? 256;
|
||||||
|
|
||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: widget.onTap ?? () {
|
onTap: widget.onTap ?? () {
|
||||||
@@ -115,8 +141,8 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
memCacheWidth: 256,
|
memCacheWidth: cacheSize,
|
||||||
memCacheHeight: 256,
|
memCacheHeight: cacheSize,
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (context, url) => const SizedBox(),
|
placeholder: (context, url) => const SizedBox(),
|
||||||
@@ -163,6 +189,7 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
style: textTheme.titleSmall?.copyWith(
|
style: textTheme.titleSmall?.copyWith(
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
|
height: 1.15,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1143,6 +1143,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
|
|
||||||
_handleHorizontalDragEnd(details, maProvider);
|
_handleHorizontalDragEnd(details, maProvider);
|
||||||
},
|
},
|
||||||
|
onPowerToggle: () => maProvider.togglePower(selectedPlayer.playerId),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1694,9 +1695,11 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: titleWidth,
|
width: titleWidth,
|
||||||
child: Text(
|
child: Text(
|
||||||
// Always show artist/author (was showing "Now Playing" when device reveal visible)
|
// Always show artist/author/podcast name (was showing "Now Playing" when device reveal visible)
|
||||||
maProvider.isPlayingAudiobook
|
maProvider.isPlayingAudiobook
|
||||||
? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor)
|
? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor)
|
||||||
|
: maProvider.isPlayingPodcast
|
||||||
|
? (maProvider.currentPodcastName ?? S.of(context)!.podcasts)
|
||||||
: currentTrack.artistsString,
|
: currentTrack.artistsString,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: textColor.withOpacity(t > 0.5 ? 0.7 : MiniPlayerLayout.secondaryTextOpacity),
|
color: textColor.withOpacity(t > 0.5 ? 0.7 : MiniPlayerLayout.secondaryTextOpacity),
|
||||||
@@ -1734,7 +1737,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
// Album name OR Chapter name (expanded only)
|
// Album name OR Chapter name (expanded only)
|
||||||
// GPU PERF: Use color alpha instead of Opacity widget
|
// GPU PERF: Use color alpha instead of Opacity widget
|
||||||
// For audiobooks: show current chapter; for music: show album
|
// For audiobooks: show current chapter; for music: show album
|
||||||
if (t > 0.3 && (currentTrack.album != null || maProvider.isPlayingAudiobook))
|
// For podcasts: hide album line since podcast name is already shown in artist position
|
||||||
|
if (t > 0.3 && (currentTrack.album != null || maProvider.isPlayingAudiobook) && !maProvider.isPlayingPodcast)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: contentPadding,
|
left: contentPadding,
|
||||||
right: contentPadding,
|
right: contentPadding,
|
||||||
@@ -1883,11 +1887,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Playback controls - with slide animation when collapsed
|
// Playback controls - with slide animation when collapsed
|
||||||
|
// Use skip 30s controls for audiobooks and podcasts
|
||||||
Positioned(
|
Positioned(
|
||||||
left: t > 0.5 ? 0 : null,
|
left: t > 0.5 ? 0 : null,
|
||||||
right: t > 0.5 ? 0 : collapsedControlsRight - miniPlayerSlideOffset,
|
right: t > 0.5 ? 0 : collapsedControlsRight - miniPlayerSlideOffset,
|
||||||
top: controlsTop,
|
top: controlsTop,
|
||||||
child: maProvider.isPlayingAudiobook
|
child: (maProvider.isPlayingAudiobook || maProvider.isPlayingPodcast)
|
||||||
? _buildAudiobookControls(
|
? _buildAudiobookControls(
|
||||||
maProvider: maProvider,
|
maProvider: maProvider,
|
||||||
selectedPlayer: selectedPlayer,
|
selectedPlayer: selectedPlayer,
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// A scrollbar that shows a letter popup when dragging, for fast navigation
|
||||||
|
/// through alphabetically sorted lists.
|
||||||
|
class LetterScrollbar extends StatefulWidget {
|
||||||
|
/// The scrollable child widget (ListView, GridView, etc.)
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
/// The scroll controller for the child
|
||||||
|
final ScrollController controller;
|
||||||
|
|
||||||
|
/// List of items to extract letters from (must be sorted alphabetically)
|
||||||
|
/// Each item's first character is used to determine the letter
|
||||||
|
final List<String> items;
|
||||||
|
|
||||||
|
/// Callback when user taps/drags to a specific index
|
||||||
|
final void Function(int index)? onScrollToIndex;
|
||||||
|
|
||||||
|
/// Callback when drag state changes (for disabling scroll-to-hide)
|
||||||
|
final void Function(bool isDragging)? onDragStateChanged;
|
||||||
|
|
||||||
|
const LetterScrollbar({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
required this.controller,
|
||||||
|
required this.items,
|
||||||
|
this.onScrollToIndex,
|
||||||
|
this.onDragStateChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LetterScrollbar> createState() => _LetterScrollbarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||||
|
bool _isDragging = false;
|
||||||
|
String _currentLetter = '';
|
||||||
|
double _dragPosition = 0;
|
||||||
|
double _scrollFraction = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
widget.controller.addListener(_onScrollChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.controller.removeListener(_onScrollChanged);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScrollChanged() {
|
||||||
|
if (!_isDragging && widget.controller.hasClients) {
|
||||||
|
final position = widget.controller.position;
|
||||||
|
if (position.maxScrollExtent > 0) {
|
||||||
|
final newFraction = (position.pixels / position.maxScrollExtent).clamp(0.0, 1.0);
|
||||||
|
// PERF: Only rebuild if fraction changed by more than 1% to reduce rebuilds
|
||||||
|
if ((newFraction - _scrollFraction).abs() > 0.01) {
|
||||||
|
setState(() {
|
||||||
|
_scrollFraction = newFraction;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a map of letter -> first index for that letter
|
||||||
|
Map<String, int> get _letterIndexMap {
|
||||||
|
final map = <String, int>{};
|
||||||
|
for (int i = 0; i < widget.items.length; i++) {
|
||||||
|
final item = widget.items[i];
|
||||||
|
if (item.isNotEmpty) {
|
||||||
|
final letter = item[0].toUpperCase();
|
||||||
|
if (!map.containsKey(letter)) {
|
||||||
|
map[letter] = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getLetterAtPosition(double position, double maxHeight) {
|
||||||
|
if (widget.items.isEmpty || maxHeight <= 0) return '';
|
||||||
|
|
||||||
|
// Calculate which item index corresponds to this position
|
||||||
|
final fraction = (position / maxHeight).clamp(0.0, 1.0);
|
||||||
|
final index = (fraction * (widget.items.length - 1)).round();
|
||||||
|
|
||||||
|
if (index >= 0 && index < widget.items.length) {
|
||||||
|
final item = widget.items[index];
|
||||||
|
if (item.isNotEmpty) {
|
||||||
|
return item[0].toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scrollToLetter(String letter) {
|
||||||
|
final letterMap = _letterIndexMap;
|
||||||
|
if (letterMap.containsKey(letter)) {
|
||||||
|
final index = letterMap[letter]!;
|
||||||
|
if (widget.onScrollToIndex != null) {
|
||||||
|
widget.onScrollToIndex!(index);
|
||||||
|
} else {
|
||||||
|
// Estimate scroll position based on index
|
||||||
|
// This is approximate - works better with fixed height items
|
||||||
|
final scrollController = widget.controller;
|
||||||
|
if (scrollController.hasClients) {
|
||||||
|
final maxScroll = scrollController.position.maxScrollExtent;
|
||||||
|
final fraction = index / widget.items.length;
|
||||||
|
final targetScroll = (fraction * maxScroll).clamp(0.0, maxScroll);
|
||||||
|
|
||||||
|
// Update scroll fraction immediately for smooth thumb tracking
|
||||||
|
setState(() {
|
||||||
|
_scrollFraction = fraction;
|
||||||
|
});
|
||||||
|
|
||||||
|
scrollController.jumpTo(targetScroll);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDragStart(DragStartDetails details) {
|
||||||
|
setState(() {
|
||||||
|
_isDragging = true;
|
||||||
|
_dragPosition = details.localPosition.dy;
|
||||||
|
});
|
||||||
|
widget.onDragStateChanged?.call(true);
|
||||||
|
_updateLetterAndScroll(details.localPosition.dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDragUpdate(DragUpdateDetails details) {
|
||||||
|
setState(() {
|
||||||
|
_dragPosition = details.localPosition.dy;
|
||||||
|
});
|
||||||
|
_updateLetterAndScroll(details.localPosition.dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDragEnd(DragEndDetails details) {
|
||||||
|
setState(() {
|
||||||
|
_isDragging = false;
|
||||||
|
});
|
||||||
|
widget.onDragStateChanged?.call(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateLetterAndScroll(double position) {
|
||||||
|
final renderBox = context.findRenderObject() as RenderBox?;
|
||||||
|
if (renderBox != null) {
|
||||||
|
final height = renderBox.size.height;
|
||||||
|
final letter = _getLetterAtPosition(position, height);
|
||||||
|
if (letter.isNotEmpty && letter != _currentLetter) {
|
||||||
|
setState(() {
|
||||||
|
_currentLetter = letter;
|
||||||
|
});
|
||||||
|
_scrollToLetter(letter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// The scrollable content
|
||||||
|
widget.child,
|
||||||
|
|
||||||
|
// The draggable scrollbar area (right edge) - wider grab area
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 32, // Wider grab area
|
||||||
|
child: GestureDetector(
|
||||||
|
onVerticalDragStart: _handleDragStart,
|
||||||
|
onVerticalDragUpdate: _handleDragUpdate,
|
||||||
|
onVerticalDragEnd: _handleDragEnd,
|
||||||
|
onTapDown: (details) {
|
||||||
|
_handleDragStart(DragStartDetails(
|
||||||
|
localPosition: details.localPosition,
|
||||||
|
globalPosition: details.globalPosition,
|
||||||
|
));
|
||||||
|
},
|
||||||
|
onTapUp: (_) {
|
||||||
|
setState(() {
|
||||||
|
_isDragging = false;
|
||||||
|
});
|
||||||
|
widget.onDragStateChanged?.call(false);
|
||||||
|
},
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final trackHeight = constraints.maxHeight;
|
||||||
|
final thumbHeight = _isDragging ? 60.0 : 40.0;
|
||||||
|
final availableTrack = trackHeight - thumbHeight;
|
||||||
|
final thumbTop = availableTrack * _scrollFraction;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// Thumb that tracks scroll position - use AnimatedPositioned for smooth motion
|
||||||
|
AnimatedPositioned(
|
||||||
|
duration: const Duration(milliseconds: 50),
|
||||||
|
curve: Curves.linear,
|
||||||
|
top: thumbTop,
|
||||||
|
right: 4,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 100),
|
||||||
|
width: _isDragging ? 6 : 4,
|
||||||
|
height: thumbHeight,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _isDragging
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurface.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// The letter popup bubble
|
||||||
|
if (_isDragging && _currentLetter.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
right: 48,
|
||||||
|
top: _dragPosition - 28,
|
||||||
|
child: Material(
|
||||||
|
elevation: 4,
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
_currentLetter,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ class DeviceSelectorBar extends StatelessWidget {
|
|||||||
final GestureDragStartCallback? onHorizontalDragStart;
|
final GestureDragStartCallback? onHorizontalDragStart;
|
||||||
final GestureDragUpdateCallback? onHorizontalDragUpdate;
|
final GestureDragUpdateCallback? onHorizontalDragUpdate;
|
||||||
final GestureDragEndCallback? onHorizontalDragEnd;
|
final GestureDragEndCallback? onHorizontalDragEnd;
|
||||||
|
final VoidCallback? onPowerToggle;
|
||||||
|
|
||||||
const DeviceSelectorBar({
|
const DeviceSelectorBar({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -31,6 +32,7 @@ class DeviceSelectorBar extends StatelessWidget {
|
|||||||
this.onHorizontalDragStart,
|
this.onHorizontalDragStart,
|
||||||
this.onHorizontalDragUpdate,
|
this.onHorizontalDragUpdate,
|
||||||
this.onHorizontalDragEnd,
|
this.onHorizontalDragEnd,
|
||||||
|
this.onPowerToggle,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -68,6 +70,8 @@ class DeviceSelectorBar extends StatelessWidget {
|
|||||||
width: width,
|
width: width,
|
||||||
slideOffset: slideOffset,
|
slideOffset: slideOffset,
|
||||||
isHint: swipeHint != null,
|
isHint: swipeHint != null,
|
||||||
|
onPowerToggle: onPowerToggle,
|
||||||
|
isPoweredOn: selectedPlayer.powered,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -8,17 +8,20 @@ class MiniPlayerLayout {
|
|||||||
static const double textLeftOffset = 10.0; // Gap between art and text
|
static const double textLeftOffset = 10.0; // Gap between art and text
|
||||||
static const double textLeft = artSize + textLeftOffset; // 82px
|
static const double textLeft = artSize + textLeftOffset; // 82px
|
||||||
// 3-line layout: track, artist, player
|
// 3-line layout: track, artist, player
|
||||||
static const double primaryTop = 7.0;
|
static const double primaryTop = 5.0; // raised slightly from 7
|
||||||
static const double secondaryTop = 27.0;
|
static const double secondaryTop = 26.0; // raised by 1
|
||||||
static const double tertiaryTop = 46.0;
|
static const double tertiaryTop = 46.0;
|
||||||
// 2-line layout (centered): player name, hint
|
// 2-line layout (evenly spaced): player name, hint
|
||||||
static const double primaryTop2Line = 16.0;
|
// Height 72 / 3 = 24px spacing. Line 1 center at 24, Line 2 center at 48
|
||||||
static const double secondaryTop2Line = 36.0;
|
static const double primaryTop2Line = 14.0; // 24 - (18/2) = 15, adjusted to 14
|
||||||
|
static const double secondaryTop2Line = 40.0; // raised slightly from 42
|
||||||
static const double textRightPadding = 12.0;
|
static const double textRightPadding = 12.0;
|
||||||
|
static const double powerButtonSize = 40.0; // Power button tap area
|
||||||
static const double iconSize = 28.0;
|
static const double iconSize = 28.0;
|
||||||
static const double iconOpacity = 0.4;
|
static const double iconOpacity = 0.4;
|
||||||
static const double secondaryTextOpacity = 0.6;
|
static const double secondaryTextOpacity = 0.6;
|
||||||
static const double primaryFontSize = 16.0;
|
static const double primaryFontSize = 16.0; // 3-line playing mode
|
||||||
|
static const double primaryFontSize2Line = 18.0; // larger for 2-line off mode
|
||||||
static const double secondaryFontSize = 14.0;
|
static const double secondaryFontSize = 14.0;
|
||||||
static const double tertiaryFontSize = 14.0;
|
static const double tertiaryFontSize = 14.0;
|
||||||
static const FontWeight primaryFontWeight = FontWeight.w500;
|
static const FontWeight primaryFontWeight = FontWeight.w500;
|
||||||
@@ -67,6 +70,12 @@ class MiniPlayerContent extends StatelessWidget {
|
|||||||
/// Progress value 0.0 to 1.0 (only used if showProgress is true)
|
/// Progress value 0.0 to 1.0 (only used if showProgress is true)
|
||||||
final double progress;
|
final double progress;
|
||||||
|
|
||||||
|
/// Callback for power button tap (only shown in 2-line mode when provided)
|
||||||
|
final VoidCallback? onPowerToggle;
|
||||||
|
|
||||||
|
/// Whether the player is currently powered on
|
||||||
|
final bool isPoweredOn;
|
||||||
|
|
||||||
const MiniPlayerContent({
|
const MiniPlayerContent({
|
||||||
super.key,
|
super.key,
|
||||||
required this.primaryText,
|
required this.primaryText,
|
||||||
@@ -81,24 +90,35 @@ class MiniPlayerContent extends StatelessWidget {
|
|||||||
this.isHint = false,
|
this.isHint = false,
|
||||||
this.showProgress = false,
|
this.showProgress = false,
|
||||||
this.progress = 0.0,
|
this.progress = 0.0,
|
||||||
|
this.onPowerToggle,
|
||||||
|
this.isPoweredOn = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final hasSecondaryLine = secondaryText != null && secondaryText!.isNotEmpty;
|
final hasSecondaryLine = secondaryText != null && secondaryText!.isNotEmpty;
|
||||||
final hasTertiaryLine = tertiaryText != null && tertiaryText!.isNotEmpty;
|
final hasTertiaryLine = tertiaryText != null && tertiaryText!.isNotEmpty;
|
||||||
|
final is2LineMode = !hasTertiaryLine;
|
||||||
|
final showPowerButton = is2LineMode && onPowerToggle != null;
|
||||||
final slidePixels = slideOffset * width;
|
final slidePixels = slideOffset * width;
|
||||||
|
|
||||||
// Use 3-line layout when tertiary exists, 2-line centered otherwise
|
// Use 3-line layout when tertiary exists, 2-line centered otherwise
|
||||||
final primaryTop = hasTertiaryLine ? MiniPlayerLayout.primaryTop : MiniPlayerLayout.primaryTop2Line;
|
final primaryTop = hasTertiaryLine ? MiniPlayerLayout.primaryTop : MiniPlayerLayout.primaryTop2Line;
|
||||||
final secondaryTop = hasTertiaryLine ? MiniPlayerLayout.secondaryTop : MiniPlayerLayout.secondaryTop2Line;
|
final secondaryTop = hasTertiaryLine ? MiniPlayerLayout.secondaryTop : MiniPlayerLayout.secondaryTop2Line;
|
||||||
|
|
||||||
// Calculate text width (leaves room for right padding)
|
// Calculate right padding (extra space for power button in 2-line mode)
|
||||||
final textWidth = width - MiniPlayerLayout.textLeft - MiniPlayerLayout.textRightPadding;
|
final rightPadding = showPowerButton
|
||||||
|
? MiniPlayerLayout.powerButtonSize + 8.0
|
||||||
|
: MiniPlayerLayout.textRightPadding;
|
||||||
|
|
||||||
// Darkened background for icon area (matches DeviceSelectorBar)
|
// Darkened background for icon area (matches DeviceSelectorBar)
|
||||||
final iconAreaBackground = Color.lerp(backgroundColor, Colors.black, 0.15)!;
|
final iconAreaBackground = Color.lerp(backgroundColor, Colors.black, 0.15)!;
|
||||||
|
|
||||||
|
// Font size for primary text (larger in 2-line mode)
|
||||||
|
final primaryFontSize = is2LineMode
|
||||||
|
? MiniPlayerLayout.primaryFontSize2Line
|
||||||
|
: MiniPlayerLayout.primaryFontSize;
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: width,
|
width: width,
|
||||||
height: MiniPlayerLayout.height,
|
height: MiniPlayerLayout.height,
|
||||||
@@ -142,13 +162,13 @@ class MiniPlayerContent extends StatelessWidget {
|
|||||||
left: MiniPlayerLayout.textLeft + slidePixels,
|
left: MiniPlayerLayout.textLeft + slidePixels,
|
||||||
top: hasSecondaryLine
|
top: hasSecondaryLine
|
||||||
? primaryTop
|
? primaryTop
|
||||||
: (MiniPlayerLayout.height - MiniPlayerLayout.primaryFontSize) / 2,
|
: (MiniPlayerLayout.height - primaryFontSize) / 2,
|
||||||
right: MiniPlayerLayout.textRightPadding - slidePixels,
|
right: rightPadding - slidePixels,
|
||||||
child: Text(
|
child: Text(
|
||||||
primaryText,
|
primaryText,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: textColor,
|
color: textColor,
|
||||||
fontSize: MiniPlayerLayout.primaryFontSize,
|
fontSize: primaryFontSize,
|
||||||
fontWeight: MiniPlayerLayout.primaryFontWeight,
|
fontWeight: MiniPlayerLayout.primaryFontWeight,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -161,7 +181,7 @@ class MiniPlayerContent extends StatelessWidget {
|
|||||||
Positioned(
|
Positioned(
|
||||||
left: MiniPlayerLayout.textLeft + slidePixels,
|
left: MiniPlayerLayout.textLeft + slidePixels,
|
||||||
top: secondaryTop,
|
top: secondaryTop,
|
||||||
right: MiniPlayerLayout.textRightPadding - slidePixels,
|
right: rightPadding - slidePixels,
|
||||||
child: isHint
|
child: isHint
|
||||||
? Row(
|
? Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -212,6 +232,27 @@ class MiniPlayerContent extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Power button (only for 2-line mode when callback provided)
|
||||||
|
// Matches PlayerCard power button style
|
||||||
|
if (showPowerButton)
|
||||||
|
Positioned(
|
||||||
|
right: 8.0 - slidePixels,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Center(
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: onPowerToggle,
|
||||||
|
icon: Icon(
|
||||||
|
Icons.power_settings_new_rounded,
|
||||||
|
color: isPoweredOn ? textColor : textColor.withOpacity(0.5),
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: ensemble
|
name: ensemble
|
||||||
description: A minimalistic Music Assistant client built with Flutter
|
description: A minimalistic Music Assistant client built with Flutter
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.7.4-beta+36
|
version: 2.8.0-beta+37
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|||||||
Reference in New Issue
Block a user