Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5cefca71f | ||
|
|
f658393d30 | ||
|
|
a4554ef7d7 | ||
|
|
a078eb55e8 | ||
|
|
dfe3f26515 | ||
|
|
d3d87dceda | ||
|
|
73bb12fc03 | ||
|
|
e975e24939 | ||
|
|
707768e524 |
@@ -52,6 +52,7 @@
|
||||
"recentlyPlayed": "Zuletzt gespielt",
|
||||
"discoverArtists": "Kunstler entdecken",
|
||||
"discoverAlbums": "Alben entdecken",
|
||||
"playlistMixes": "Playlists entdecken",
|
||||
"continueListening": "Weiterhoren",
|
||||
"discoverAudiobooks": "Horbücher entdecken",
|
||||
"discoverSeries": "Serien entdecken",
|
||||
@@ -171,6 +172,7 @@
|
||||
"noArtistsFound": "Keine Künstler gefunden",
|
||||
"noTracksFound": "Keine Songs gefunden",
|
||||
"noPlaylistsFound": "Keine Wiedergabelisten gefunden",
|
||||
"noPlaylistMixesFound": "Keine Playlist-Mixes gefunden",
|
||||
"queueIsEmpty": "Warteschlange ist leer",
|
||||
"noResultsFound": "Keine Ergebnisse gefunden",
|
||||
"refresh": "Aktualisieren",
|
||||
@@ -216,6 +218,7 @@
|
||||
"showRecentlyPlayedAlbums": "Kürzlich gespielte Alben anzeigen",
|
||||
"showRandomArtists": "Zufällige Künstler zum Entdecken anzeigen",
|
||||
"showRandomAlbums": "Zufällige Alben zum Entdecken anzeigen",
|
||||
"showPlaylistMixes": "Playlist-Mixes von externen Anbietern anzeigen",
|
||||
"showAudiobooksInProgress": "Hörbücher in Bearbeitung anzeigen",
|
||||
"showRandomAudiobooks": "Zufällige Hörbücher zum Entdecken anzeigen",
|
||||
"showRandomSeries": "Zufällige Hörbuchserien zum Entdecken anzeigen",
|
||||
|
||||
@@ -221,6 +221,11 @@
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"playlistMixes": "Discover Playlists",
|
||||
"@playlistMixes": {
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"continueListening": "Continue Listening",
|
||||
"@continueListening": {
|
||||
"description": "Home screen section title"
|
||||
@@ -925,6 +930,11 @@
|
||||
"description": "Empty playlists"
|
||||
},
|
||||
|
||||
"noPlaylistMixesFound": "No playlist mixes found",
|
||||
"@noPlaylistMixesFound": {
|
||||
"description": "Empty playlist mixes"
|
||||
},
|
||||
|
||||
"queueIsEmpty": "Queue is empty",
|
||||
"@queueIsEmpty": {
|
||||
"description": "Empty queue"
|
||||
@@ -1085,6 +1095,7 @@
|
||||
"showRecentlyPlayedAlbums": "Show recently played albums",
|
||||
"showRandomArtists": "Show random artists to discover",
|
||||
"showRandomAlbums": "Show random albums to discover",
|
||||
"showPlaylistMixes": "Show playlist mixes from external providers",
|
||||
"showAudiobooksInProgress": "Show audiobooks in progress",
|
||||
"showRandomAudiobooks": "Show random audiobooks to discover",
|
||||
"showRandomSeries": "Show random audiobook series to discover",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"recentlyPlayed": "Reproducido recientemente",
|
||||
"discoverArtists": "Descubrir artistas",
|
||||
"discoverAlbums": "Descubrir álbumes",
|
||||
"playlistMixes": "Descubrir listas de reproducción",
|
||||
"continueListening": "Continuar escuchando",
|
||||
"discoverAudiobooks": "Descubrir audiolibros",
|
||||
"discoverSeries": "Descubrir series",
|
||||
@@ -320,6 +321,7 @@
|
||||
"noArtistsFound": "No se encontraron artistas",
|
||||
"noTracksFound": "No se encontraron canciones",
|
||||
"noPlaylistsFound": "No se encontraron listas de reproducción",
|
||||
"noPlaylistMixesFound": "No se encontraron mezclas de playlists",
|
||||
"queueIsEmpty": "La cola está vacía",
|
||||
"noResultsFound": "No se encontraron resultados",
|
||||
"refresh": "Actualizar",
|
||||
@@ -372,6 +374,7 @@
|
||||
"showRecentlyPlayedAlbums": "Mostrar álbumes reproducidos recientemente",
|
||||
"showRandomArtists": "Mostrar artistas aleatorios para descubrir",
|
||||
"showRandomAlbums": "Mostrar álbumes aleatorios para descubrir",
|
||||
"showPlaylistMixes": "Mostrar mezclas de playlists de proveedores externos",
|
||||
"showAudiobooksInProgress": "Mostrar audiolibros en progreso",
|
||||
"showRandomAudiobooks": "Mostrar audiolibros aleatorios para descubrir",
|
||||
"showRandomSeries": "Mostrar series de audiolibros aleatorias para descubrir",
|
||||
|
||||
@@ -221,6 +221,11 @@
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"playlistMixes": "Découvrir des playlists",
|
||||
"@playlistMixes": {
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"continueListening": "Continuer l'écoute",
|
||||
"@continueListening": {
|
||||
"description": "Home screen section title"
|
||||
@@ -915,6 +920,11 @@
|
||||
"description": "Empty playlists"
|
||||
},
|
||||
|
||||
"noPlaylistMixesFound": "Aucun mix de playlists trouve",
|
||||
"@noPlaylistMixesFound": {
|
||||
"description": "Empty playlist mixes"
|
||||
},
|
||||
|
||||
"queueIsEmpty": "La file d'attente est vide",
|
||||
"@queueIsEmpty": {
|
||||
"description": "Empty queue"
|
||||
@@ -1071,6 +1081,7 @@
|
||||
"showRecentlyPlayedAlbums": "Afficher les albums écoutes recemment",
|
||||
"showRandomArtists": "Afficher des artistes aléatoires a découvrir",
|
||||
"showRandomAlbums": "Afficher des albums aléatoires a découvrir",
|
||||
"showPlaylistMixes": "Afficher les mixes de playlists provenant de fournisseurs externes",
|
||||
"showAudiobooksInProgress": "Afficher les livres audio en cours",
|
||||
"showRandomAudiobooks": "Afficher des livres audio aléatoires a découvrir",
|
||||
"showRandomSeries": "Afficher des séries de livres audio aléatoires a découvrir",
|
||||
|
||||
@@ -359,6 +359,12 @@ abstract class S {
|
||||
/// **'Discover Albums'**
|
||||
String get discoverAlbums;
|
||||
|
||||
/// Home screen section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Discover Playlists'**
|
||||
String get playlistMixes;
|
||||
|
||||
/// Home screen section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1049,6 +1055,12 @@ abstract class S {
|
||||
/// **'No playlists found'**
|
||||
String get noPlaylistsFound;
|
||||
|
||||
/// Empty playlist mixes
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No playlist mixes found'**
|
||||
String get noPlaylistMixesFound;
|
||||
|
||||
/// Empty queue
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1301,6 +1313,12 @@ abstract class S {
|
||||
/// **'Show random albums to discover'**
|
||||
String get showRandomAlbums;
|
||||
|
||||
/// No description provided for @showPlaylistMixes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show playlist mixes from external providers'**
|
||||
String get showPlaylistMixes;
|
||||
|
||||
/// No description provided for @showAudiobooksInProgress.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -151,6 +151,9 @@ class SDe extends S {
|
||||
@override
|
||||
String get discoverAlbums => 'Alben entdecken';
|
||||
|
||||
@override
|
||||
String get playlistMixes => 'Playlists entdecken';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Weiterhoren';
|
||||
|
||||
@@ -551,6 +554,9 @@ class SDe extends S {
|
||||
@override
|
||||
String get noPlaylistsFound => 'Keine Wiedergabelisten gefunden';
|
||||
|
||||
@override
|
||||
String get noPlaylistMixesFound => 'Keine Playlist-Mixes gefunden';
|
||||
|
||||
@override
|
||||
String get queueIsEmpty => 'Warteschlange ist leer';
|
||||
|
||||
@@ -682,6 +688,10 @@ class SDe extends S {
|
||||
@override
|
||||
String get showRandomAlbums => 'Zufällige Alben zum Entdecken anzeigen';
|
||||
|
||||
@override
|
||||
String get showPlaylistMixes =>
|
||||
'Playlist-Mixes von externen Anbietern anzeigen';
|
||||
|
||||
@override
|
||||
String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen';
|
||||
|
||||
|
||||
@@ -148,6 +148,9 @@ class SEn extends S {
|
||||
@override
|
||||
String get discoverAlbums => 'Discover Albums';
|
||||
|
||||
@override
|
||||
String get playlistMixes => 'Discover Playlists';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Continue Listening';
|
||||
|
||||
@@ -546,6 +549,9 @@ class SEn extends S {
|
||||
@override
|
||||
String get noPlaylistsFound => 'No playlists found';
|
||||
|
||||
@override
|
||||
String get noPlaylistMixesFound => 'No playlist mixes found';
|
||||
|
||||
@override
|
||||
String get queueIsEmpty => 'Queue is empty';
|
||||
|
||||
@@ -676,6 +682,9 @@ class SEn extends S {
|
||||
@override
|
||||
String get showRandomAlbums => 'Show random albums to discover';
|
||||
|
||||
@override
|
||||
String get showPlaylistMixes => 'Show playlist mixes from external providers';
|
||||
|
||||
@override
|
||||
String get showAudiobooksInProgress => 'Show audiobooks in progress';
|
||||
|
||||
|
||||
@@ -152,6 +152,9 @@ class SEs extends S {
|
||||
@override
|
||||
String get discoverAlbums => 'Descubrir álbumes';
|
||||
|
||||
@override
|
||||
String get playlistMixes => 'Descubrir listas de reproducción';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Continuar escuchando';
|
||||
|
||||
@@ -554,6 +557,9 @@ class SEs extends S {
|
||||
@override
|
||||
String get noPlaylistsFound => 'No se encontraron listas de reproducción';
|
||||
|
||||
@override
|
||||
String get noPlaylistMixesFound => 'No se encontraron mezclas de playlists';
|
||||
|
||||
@override
|
||||
String get queueIsEmpty => 'La cola está vacía';
|
||||
|
||||
@@ -687,6 +693,10 @@ class SEs extends S {
|
||||
@override
|
||||
String get showRandomAlbums => 'Mostrar álbumes aleatorios para descubrir';
|
||||
|
||||
@override
|
||||
String get showPlaylistMixes =>
|
||||
'Mostrar mezclas de playlists de proveedores externos';
|
||||
|
||||
@override
|
||||
String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso';
|
||||
|
||||
|
||||
@@ -153,6 +153,9 @@ class SFr extends S {
|
||||
@override
|
||||
String get discoverAlbums => 'Découvrir des albums';
|
||||
|
||||
@override
|
||||
String get playlistMixes => 'Découvrir des playlists';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Continuer l\'écoute';
|
||||
|
||||
@@ -555,6 +558,9 @@ class SFr extends S {
|
||||
@override
|
||||
String get noPlaylistsFound => 'Aucune playlist trouvee';
|
||||
|
||||
@override
|
||||
String get noPlaylistMixesFound => 'Aucun mix de playlists trouve';
|
||||
|
||||
@override
|
||||
String get queueIsEmpty => 'La file d\'attente est vide';
|
||||
|
||||
@@ -689,6 +695,10 @@ class SFr extends S {
|
||||
@override
|
||||
String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir';
|
||||
|
||||
@override
|
||||
String get showPlaylistMixes =>
|
||||
'Afficher les mixes de playlists provenant de fournisseurs externes';
|
||||
|
||||
@override
|
||||
String get showAudiobooksInProgress => 'Afficher les livres audio en cours';
|
||||
|
||||
|
||||
+11
-2
@@ -329,8 +329,17 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
|
||||
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
||||
builder: (context, child) {
|
||||
// Wrap entire app with global player overlay
|
||||
return GlobalPlayerOverlay(child: child ?? const SizedBox.shrink());
|
||||
// Wrap entire app with global player overlay.
|
||||
// Provide an Overlay ancestor so BottomNavigationBar/Tooltip can render safely.
|
||||
return Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(
|
||||
builder: (context) => GlobalPlayerOverlay(
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
home: const AppStartup(),
|
||||
),
|
||||
|
||||
@@ -10,6 +10,29 @@ enum MediaType {
|
||||
podcastEpisode,
|
||||
}
|
||||
|
||||
/// Parse a media item JSON map into the appropriate model type.
|
||||
MediaItem parseMediaItemJson(Map<String, dynamic> json) {
|
||||
final mediaTypeStr = (json['media_type'] as String?)?.toLowerCase();
|
||||
switch (mediaTypeStr) {
|
||||
case 'artist':
|
||||
return Artist.fromJson(json);
|
||||
case 'album':
|
||||
return Album.fromJson(json);
|
||||
case 'track':
|
||||
return Track.fromJson(json);
|
||||
case 'playlist':
|
||||
return Playlist.fromJson(json);
|
||||
case 'audiobook':
|
||||
return Audiobook.fromJson(json);
|
||||
case 'radio':
|
||||
return MediaItem.fromJson(json);
|
||||
case 'podcast':
|
||||
case 'podcast_episode':
|
||||
default:
|
||||
return MediaItem.fromJson(json);
|
||||
}
|
||||
}
|
||||
|
||||
class ProviderMapping {
|
||||
final String itemId;
|
||||
final String providerDomain;
|
||||
@@ -356,6 +379,15 @@ class Playlist extends MediaItem {
|
||||
if (provider == 'library') return true;
|
||||
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = super.toJson();
|
||||
if (owner != null) json['owner'] = owner;
|
||||
if (isEditable != null) json['is_editable'] = isEditable;
|
||||
if (trackCount != null) json['track_count'] = trackCount;
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
class Chapter {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'media_item.dart';
|
||||
|
||||
class RecommendationFolder {
|
||||
final String itemId;
|
||||
final String provider;
|
||||
final String name;
|
||||
final String? uri;
|
||||
final List<MediaItem> items;
|
||||
|
||||
RecommendationFolder({
|
||||
required this.itemId,
|
||||
required this.provider,
|
||||
required this.name,
|
||||
this.uri,
|
||||
required this.items,
|
||||
});
|
||||
|
||||
factory RecommendationFolder.fromJson(Map<String, dynamic> json) {
|
||||
final itemsJson = json['items'] as List<dynamic>?;
|
||||
final parsedItems = itemsJson
|
||||
?.whereType<Map<String, dynamic>>()
|
||||
.map(parseMediaItemJson)
|
||||
.toList() ?? <MediaItem>[];
|
||||
|
||||
return RecommendationFolder(
|
||||
itemId: json['item_id']?.toString() ?? json['id']?.toString() ?? '',
|
||||
provider: json['provider'] as String? ?? 'unknown',
|
||||
name: json['name'] as String? ?? '',
|
||||
uri: json['uri'] as String?,
|
||||
items: parsedItems,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'item_id': itemId,
|
||||
'provider': provider,
|
||||
'name': name,
|
||||
if (uri != null) 'uri': uri,
|
||||
'items': items.map((item) => item.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../models/provider_instance.dart';
|
||||
import '../models/provider_manifest.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
@@ -3176,6 +3177,76 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<RecommendationFolder>> getExternalMixesWithCache({bool forceRefresh = false}) async {
|
||||
if (_cacheService.isExternalMixesCacheValid(forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached external mixes');
|
||||
return _cacheService.getCachedExternalMixes()!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedExternalMixes() ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching external mixes...');
|
||||
final recommendations = await _api!.getRecommendations();
|
||||
final mixes = _extractExternalMixes(recommendations);
|
||||
_cacheService.setCachedExternalMixes(mixes);
|
||||
return mixes;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch external mixes: $e');
|
||||
return _cacheService.getCachedExternalMixes() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
List<RecommendationFolder> _extractExternalMixes(List<RecommendationFolder> folders) {
|
||||
final result = <RecommendationFolder>[];
|
||||
|
||||
for (final folder in folders) {
|
||||
final providerFiltered = filterByProvider(folder.items);
|
||||
final filteredItems = providerFiltered
|
||||
.where((item) => item.mediaType == MediaType.playlist || item.mediaType == MediaType.radio)
|
||||
.where(_isExternalMixItem)
|
||||
.toList();
|
||||
|
||||
if (filteredItems.isEmpty) continue;
|
||||
|
||||
result.add(RecommendationFolder(
|
||||
itemId: folder.itemId,
|
||||
provider: folder.provider,
|
||||
name: folder.name,
|
||||
uri: folder.uri,
|
||||
items: _deduplicateMixes(filteredItems),
|
||||
));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool _isExternalMixItem(MediaItem item) {
|
||||
final provider = item.provider.toLowerCase();
|
||||
if (provider != 'library' && provider != 'filesystem') {
|
||||
return true;
|
||||
}
|
||||
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings != null) {
|
||||
return mappings.any((m) {
|
||||
final domain = m.providerDomain.toLowerCase();
|
||||
return domain.isNotEmpty && domain != 'library' && domain != 'filesystem';
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
List<MediaItem> _deduplicateMixes(List<MediaItem> mixes) {
|
||||
final Map<String, MediaItem> deduped = {};
|
||||
for (final mix in mixes) {
|
||||
final key = '${mix.provider}:${mix.itemId}';
|
||||
deduped.putIfAbsent(key, () => mix);
|
||||
}
|
||||
return deduped.values.toList();
|
||||
}
|
||||
|
||||
void invalidateHomeCache() {
|
||||
_cacheService.invalidateHomeCache();
|
||||
}
|
||||
@@ -3189,6 +3260,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
/// Get cached discover albums synchronously (for instant display)
|
||||
List<Album>? getCachedDiscoverAlbums() => _cacheService.getCachedDiscoverAlbums();
|
||||
|
||||
/// Get cached external mixes synchronously (for instant display)
|
||||
List<RecommendationFolder>? getCachedExternalMixes() => _cacheService.getCachedExternalMixes();
|
||||
|
||||
/// Force a full library sync (for pull-to-refresh)
|
||||
Future<void> forceLibrarySync() async {
|
||||
if (_api == null) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../widgets/series_row.dart';
|
||||
import '../widgets/playlist_row.dart';
|
||||
import '../widgets/radio_station_row.dart';
|
||||
import '../widgets/podcast_row.dart';
|
||||
import '../widgets/mixes_row.dart';
|
||||
import '../widgets/common/disconnected_state.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
@@ -34,6 +35,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
bool _showRecentAlbums = true;
|
||||
bool _showDiscoverArtists = true;
|
||||
bool _showDiscoverAlbums = true;
|
||||
bool _showExternalMixes = false;
|
||||
// Favorites rows (default off)
|
||||
bool _showFavoriteAlbums = false;
|
||||
bool _showFavoriteArtists = false;
|
||||
@@ -146,6 +148,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
final showRecent = await SettingsService.getShowRecentAlbums();
|
||||
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
|
||||
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
|
||||
final showExternalMixes = await SettingsService.getShowExternalMixes();
|
||||
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||
@@ -161,6 +164,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
_showRecentAlbums = showRecent;
|
||||
_showDiscoverArtists = showDiscArtists;
|
||||
_showDiscoverAlbums = showDiscAlbums;
|
||||
_showExternalMixes = showExternalMixes;
|
||||
_showFavoriteAlbums = showFavAlbums;
|
||||
_showFavoriteArtists = showFavArtists;
|
||||
_showFavoriteTracks = showFavTracks;
|
||||
@@ -335,6 +339,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
case 'recent-albums': return _showRecentAlbums;
|
||||
case 'discover-artists': return _showDiscoverArtists;
|
||||
case 'discover-albums': return _showDiscoverAlbums;
|
||||
case 'external-mixes': return _showExternalMixes;
|
||||
case 'continue-listening': return _showContinueListeningAudiobooks;
|
||||
case 'discover-audiobooks': return _showDiscoverAudiobooks;
|
||||
case 'discover-series': return _showDiscoverSeries;
|
||||
@@ -355,7 +360,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
builder: (context, constraints) {
|
||||
// Each row is always 1/3 of screen height
|
||||
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll
|
||||
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
|
||||
final maxHeight = constraints.maxHeight.isFinite
|
||||
? constraints.maxHeight
|
||||
: MediaQuery.of(context).size.height;
|
||||
final availableHeight = (maxHeight - BottomSpacing.withMiniPlayer).clamp(0.0, double.infinity);
|
||||
|
||||
// Account for margins between rows (2px each)
|
||||
// Only adjust for margins when ≤3 rows (so they fit exactly without scroll)
|
||||
@@ -363,7 +371,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
const marginSize = 2.0;
|
||||
final enabledRows = _countEnabledRows();
|
||||
final marginsInView = enabledRows > 1 && enabledRows <= 3 ? (enabledRows - 1) * marginSize : 0.0;
|
||||
final rowHeight = (availableHeight - marginsInView) / 3;
|
||||
final rawRowHeight = (availableHeight - marginsInView) / 3;
|
||||
final rowHeight = rawRowHeight.isFinite && rawRowHeight > 0 ? rawRowHeight : 237.0;
|
||||
|
||||
// Use Android 12+ stretch overscroll effect
|
||||
return NotificationListener<ScrollNotification>(
|
||||
@@ -446,6 +455,16 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
getCachedAlbums: () => provider.getCachedDiscoverAlbums(),
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'external-mixes':
|
||||
if (!_showExternalMixes) return null;
|
||||
return MixesRow(
|
||||
key: const ValueKey('external-mixes'),
|
||||
title: S.of(context)!.playlistMixes,
|
||||
loadMixes: () => provider.getExternalMixesWithCache(),
|
||||
getCachedMixes: () => provider.getCachedExternalMixes(),
|
||||
heroTagSuffix: 'home',
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'continue-listening':
|
||||
if (!_showContinueListeningAudiobooks) return null;
|
||||
return AudiobookRow(
|
||||
|
||||
@@ -1506,7 +1506,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
itemCount: _tabCount,
|
||||
// Use default physics - custom physics may cause issues with Flutter 3.38
|
||||
physics: const PageScrollPhysics(),
|
||||
itemBuilder: (context, index) => _buildTabAtIndex(context, l10n, index),
|
||||
itemBuilder: (context, index) => Builder(
|
||||
builder: (context) => _buildTabAtIndex(context, l10n, index),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -570,35 +570,47 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Library-only toggle with circle background
|
||||
Tooltip(
|
||||
message: S.of(context)!.libraryOnly,
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: Material(
|
||||
color: colorScheme.surfaceContainerHighest.withOpacity(0.6),
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_libraryOnly = !_libraryOnly;
|
||||
});
|
||||
if (_searchController.text.isNotEmpty) {
|
||||
_performSearch(_searchController.text);
|
||||
}
|
||||
},
|
||||
customBorder: const CircleBorder(),
|
||||
child: Icon(
|
||||
Symbols.book_2,
|
||||
fill: _libraryOnly ? 1 : 0,
|
||||
size: 22,
|
||||
color: _libraryOnly
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurface,
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final control = SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: Material(
|
||||
color: colorScheme.surfaceContainerHighest.withOpacity(0.6),
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_libraryOnly = !_libraryOnly;
|
||||
});
|
||||
if (_searchController.text.isNotEmpty) {
|
||||
_performSearch(_searchController.text);
|
||||
}
|
||||
},
|
||||
customBorder: const CircleBorder(),
|
||||
child: Icon(
|
||||
Symbols.book_2,
|
||||
fill: _libraryOnly ? 1 : 0,
|
||||
size: 22,
|
||||
color: _libraryOnly
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Avoid crash when Overlay isn't available (e.g., certain overlay contexts).
|
||||
final hasOverlay = Overlay.maybeOf(context) != null;
|
||||
if (!hasOverlay) {
|
||||
return control;
|
||||
}
|
||||
|
||||
return Tooltip(
|
||||
message: S.of(context)!.libraryOnly,
|
||||
child: control,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -26,6 +26,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _showRecentAlbums = true;
|
||||
bool _showDiscoverArtists = true;
|
||||
bool _showDiscoverAlbums = true;
|
||||
bool _showExternalMixes = false;
|
||||
// Favorites rows (default off)
|
||||
bool _showFavoriteAlbums = false;
|
||||
bool _showFavoriteArtists = false;
|
||||
@@ -68,6 +69,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final showRecent = await SettingsService.getShowRecentAlbums();
|
||||
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
|
||||
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
|
||||
final showExternalMixes = await SettingsService.getShowExternalMixes();
|
||||
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||
@@ -98,6 +100,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_showRecentAlbums = showRecent;
|
||||
_showDiscoverArtists = showDiscArtists;
|
||||
_showDiscoverAlbums = showDiscAlbums;
|
||||
_showExternalMixes = showExternalMixes;
|
||||
_showFavoriteAlbums = showFavAlbums;
|
||||
_showFavoriteArtists = showFavArtists;
|
||||
_showFavoriteTracks = showFavTracks;
|
||||
@@ -133,6 +136,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return {'title': s.discoverArtists, 'subtitle': s.showRandomArtists};
|
||||
case 'discover-albums':
|
||||
return {'title': s.discoverAlbums, 'subtitle': s.showRandomAlbums};
|
||||
case 'external-mixes':
|
||||
return {'title': s.playlistMixes, 'subtitle': s.showPlaylistMixes};
|
||||
case 'continue-listening':
|
||||
return {'title': s.continueListening, 'subtitle': s.showAudiobooksInProgress};
|
||||
case 'discover-audiobooks':
|
||||
@@ -165,6 +170,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return _showDiscoverArtists;
|
||||
case 'discover-albums':
|
||||
return _showDiscoverAlbums;
|
||||
case 'external-mixes':
|
||||
return _showExternalMixes;
|
||||
case 'continue-listening':
|
||||
return _showContinueListeningAudiobooks;
|
||||
case 'discover-audiobooks':
|
||||
@@ -204,6 +211,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_showDiscoverAlbums = value;
|
||||
SettingsService.setShowDiscoverAlbums(value);
|
||||
break;
|
||||
case 'external-mixes':
|
||||
_showExternalMixes = value;
|
||||
SettingsService.setShowExternalMixes(value);
|
||||
break;
|
||||
case 'continue-listening':
|
||||
_showContinueListeningAudiobooks = value;
|
||||
SettingsService.setShowContinueListeningAudiobooks(value);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import '../models/player.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/database_service.dart';
|
||||
@@ -20,12 +21,14 @@ class CacheService {
|
||||
List<Album>? _cachedRecentAlbums;
|
||||
List<Artist>? _cachedDiscoverArtists;
|
||||
List<Album>? _cachedDiscoverAlbums;
|
||||
List<RecommendationFolder>? _cachedExternalMixes;
|
||||
List<Audiobook>? _cachedInProgressAudiobooks;
|
||||
List<Audiobook>? _cachedDiscoverAudiobooks;
|
||||
List<AudiobookSeries>? _cachedDiscoverSeries;
|
||||
DateTime? _recentAlbumsLastFetched;
|
||||
DateTime? _discoverArtistsLastFetched;
|
||||
DateTime? _discoverAlbumsLastFetched;
|
||||
DateTime? _externalMixesLastFetched;
|
||||
DateTime? _inProgressAudiobooksLastFetched;
|
||||
DateTime? _discoverAudiobooksLastFetched;
|
||||
DateTime? _discoverSeriesLastFetched;
|
||||
@@ -118,6 +121,27 @@ class CacheService {
|
||||
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Check if external mixes cache is valid
|
||||
bool isExternalMixesCacheValid({bool forceRefresh = false}) {
|
||||
if (forceRefresh) return false;
|
||||
final now = DateTime.now();
|
||||
return _cachedExternalMixes != null &&
|
||||
_externalMixesLastFetched != null &&
|
||||
now.difference(_externalMixesLastFetched!) < Timings.homeRowCacheDuration;
|
||||
}
|
||||
|
||||
/// Get cached external mixes
|
||||
List<RecommendationFolder>? getCachedExternalMixes() => _cachedExternalMixes;
|
||||
|
||||
/// Set cached external mixes
|
||||
void setCachedExternalMixes(List<RecommendationFolder> mixes) {
|
||||
_cachedExternalMixes = mixes;
|
||||
_externalMixesLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${mixes.length} external mix shelves');
|
||||
// Persist to database for instant load on next launch
|
||||
_persistHomeRowToDatabase('external_mixes', mixes.map((m) => m.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Check if in-progress audiobooks cache is valid
|
||||
bool isInProgressAudiobooksCacheValid({bool forceRefresh = false}) {
|
||||
if (forceRefresh) return false;
|
||||
@@ -191,6 +215,7 @@ class CacheService {
|
||||
_recentAlbumsLastFetched = null;
|
||||
_discoverArtistsLastFetched = null;
|
||||
_discoverAlbumsLastFetched = null;
|
||||
_externalMixesLastFetched = null;
|
||||
_inProgressAudiobooksLastFetched = null;
|
||||
_discoverAudiobooksLastFetched = null;
|
||||
_discoverSeriesLastFetched = null;
|
||||
@@ -220,6 +245,8 @@ class CacheService {
|
||||
_discoverArtistsLastFetched = null;
|
||||
_cachedDiscoverAlbums = null;
|
||||
_discoverAlbumsLastFetched = null;
|
||||
_cachedExternalMixes = null;
|
||||
_externalMixesLastFetched = null;
|
||||
_cachedRecentAlbums = null;
|
||||
_recentAlbumsLastFetched = null;
|
||||
_searchCache.clear();
|
||||
@@ -499,6 +526,21 @@ class CacheService {
|
||||
_logger.log('⚠️ Failed to parse discover albums: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Load external mixes
|
||||
final mixesData = await DatabaseService.instance.getHomeRowCache('external_mixes');
|
||||
if (mixesData != null) {
|
||||
try {
|
||||
final items = (jsonDecode(mixesData.itemsJson) as List)
|
||||
.map((json) => RecommendationFolder.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
_cachedExternalMixes = items;
|
||||
_externalMixesLastFetched = mixesData.lastUpdated;
|
||||
_logger.log('📦 Loaded ${items.length} external mix shelves from database');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse external mixes: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error loading home rows from database: $e');
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../models/provider_instance.dart';
|
||||
import '../models/provider_manifest.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import 'debug_logger.dart';
|
||||
import 'settings_service.dart';
|
||||
import 'device_id_service.dart';
|
||||
@@ -412,7 +413,8 @@ class MusicAssistantAPI {
|
||||
args: args,
|
||||
);
|
||||
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
final items = response['result'] as List<dynamic>? ??
|
||||
response['items'] as List<dynamic>?;
|
||||
if (items == null) {
|
||||
return [];
|
||||
}
|
||||
@@ -592,6 +594,28 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ RECOMMENDATIONS ============
|
||||
|
||||
/// Get recommendation folders (mixes, recommendations, etc.)
|
||||
Future<List<RecommendationFolder>> getRecommendations() async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/recommendations',
|
||||
);
|
||||
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
if (items == null) return [];
|
||||
|
||||
return items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(RecommendationFolder.fromJson)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
_logger.log('Error getting recommendations: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============ PODCASTS ============
|
||||
|
||||
/// Get all podcasts from the library
|
||||
|
||||
@@ -94,6 +94,7 @@ class SettingsService {
|
||||
static const String _keyShowRecentAlbums = 'show_recent_albums';
|
||||
static const String _keyShowDiscoverArtists = 'show_discover_artists';
|
||||
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
|
||||
static const String _keyShowExternalMixes = 'show_external_mixes';
|
||||
static const String _keyShowContinueListeningAudiobooks = 'show_continue_listening_audiobooks';
|
||||
static const String _keyShowDiscoverAudiobooks = 'show_discover_audiobooks';
|
||||
static const String _keyShowDiscoverSeries = 'show_discover_series';
|
||||
@@ -111,6 +112,7 @@ class SettingsService {
|
||||
'recent-albums',
|
||||
'discover-artists',
|
||||
'discover-albums',
|
||||
'external-mixes',
|
||||
'continue-listening',
|
||||
'discover-audiobooks',
|
||||
'discover-series',
|
||||
@@ -391,6 +393,9 @@ class SettingsService {
|
||||
static Future<bool> getShowDiscoverAlbums() => _getBool(_keyShowDiscoverAlbums, defaultValue: true);
|
||||
static Future<void> setShowDiscoverAlbums(bool show) => _setBool(_keyShowDiscoverAlbums, show);
|
||||
|
||||
static Future<bool> getShowExternalMixes() => _getBool(_keyShowExternalMixes, defaultValue: false);
|
||||
static Future<void> setShowExternalMixes(bool show) => _setBool(_keyShowExternalMixes, show);
|
||||
|
||||
// Home Screen Audiobook Rows (default off - optional)
|
||||
static Future<bool> getShowContinueListeningAudiobooks() => _getBool(_keyShowContinueListeningAudiobooks, defaultValue: false);
|
||||
static Future<void> setShowContinueListeningAudiobooks(bool show) => _setBool(_keyShowContinueListeningAudiobooks, show);
|
||||
|
||||
@@ -114,7 +114,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
||||
|
||||
// Card layout: square artwork + text below
|
||||
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
|
||||
const textAreaHeight = 44.0;
|
||||
const textAreaHeight = 52.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||
@@ -158,7 +158,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
||||
|
||||
// Total row height includes title + content
|
||||
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
|
||||
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
|
||||
@@ -114,7 +114,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
|
||||
|
||||
// Card layout: circle image + name below
|
||||
// Text area: 8px gap + ~36px for 2-line name = ~44px
|
||||
const textAreaHeight = 44.0;
|
||||
const textAreaHeight = 52.0;
|
||||
final imageSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = imageSize; // Card width = image width (circle)
|
||||
final itemExtent = cardWidth + 16; // width + horizontal margins
|
||||
@@ -154,7 +154,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
|
||||
|
||||
// Total row height includes title + content
|
||||
final totalHeight = widget.rowHeight ?? 207.0; // Default: 44 title + 163 content
|
||||
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
|
||||
@@ -121,7 +121,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
|
||||
);
|
||||
}
|
||||
|
||||
const textAreaHeight = 44.0;
|
||||
const textAreaHeight = 52.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize;
|
||||
final itemExtent = cardWidth + 12;
|
||||
@@ -162,7 +162,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
final totalHeight = widget.rowHeight ?? 237.0;
|
||||
const titleHeight = 44.0;
|
||||
const titleHeight = 52.0;
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
|
||||
@@ -69,46 +69,54 @@ class HiResBadge extends StatelessWidget {
|
||||
final baseColor = primaryColor ?? colorScheme.primary;
|
||||
final color = Color.lerp(baseColor, Colors.white, 0.5)!;
|
||||
|
||||
return Tooltip(
|
||||
message: tooltip ?? 'Hi-Res Audio',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: color, width: 1.5),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Hi-Res',
|
||||
final content = Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: color, width: 1.5),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Hi-Res',
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 1),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: Text(
|
||||
'AUDIO',
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.surface,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 1),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: Text(
|
||||
'AUDIO',
|
||||
style: TextStyle(
|
||||
color: colorScheme.surface,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Avoid crash when Overlay isn't available (e.g., certain overlay contexts).
|
||||
final hasOverlay = Overlay.maybeOf(context) != null;
|
||||
if (!hasOverlay) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return Tooltip(
|
||||
message: tooltip ?? 'Hi-Res Audio',
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import 'playlist_card.dart';
|
||||
import 'radio_station_card.dart';
|
||||
|
||||
class MixesRow extends StatefulWidget {
|
||||
final String title;
|
||||
final Future<List<RecommendationFolder>> Function() loadMixes;
|
||||
final List<RecommendationFolder>? Function()? getCachedMixes;
|
||||
final String? heroTagSuffix;
|
||||
final double? rowHeight;
|
||||
|
||||
const MixesRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.loadMixes,
|
||||
this.getCachedMixes,
|
||||
this.heroTagSuffix,
|
||||
this.rowHeight,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MixesRow> createState() => _MixesRowState();
|
||||
}
|
||||
|
||||
class _MixesRowState extends State<MixesRow> with AutomaticKeepAliveClientMixin {
|
||||
List<RecommendationFolder> _mixes = [];
|
||||
bool _isLoading = true;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
static final _logger = DebugLogger();
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final cached = widget.getCachedMixes?.call();
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
_mixes = cached;
|
||||
_isLoading = false;
|
||||
}
|
||||
_loadMixes();
|
||||
}
|
||||
|
||||
Future<void> _loadMixes() async {
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
try {
|
||||
final freshMixes = await widget.loadMixes();
|
||||
if (mounted && freshMixes.isNotEmpty) {
|
||||
setState(() {
|
||||
_mixes = freshMixes;
|
||||
_isLoading = false;
|
||||
});
|
||||
_precacheMixImages(freshMixes);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure - keep showing cached data
|
||||
}
|
||||
|
||||
if (mounted && _isLoading) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _precacheMixImages(List<RecommendationFolder> mixes) {
|
||||
if (!mounted) return;
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
final itemsToCache = <MediaItem>[];
|
||||
for (final folder in mixes) {
|
||||
itemsToCache.addAll(folder.items);
|
||||
if (itemsToCache.length >= 10) break;
|
||||
}
|
||||
|
||||
for (final mix in itemsToCache.take(10)) {
|
||||
final imageUrl = maProvider.api?.getImageUrl(mix, size: 256);
|
||||
if (imageUrl != null) {
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
context,
|
||||
).catchError((_) => false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildShelfContent(List<MediaItem> items, double contentHeight) {
|
||||
const textAreaHeight = 52.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize;
|
||||
final itemExtent = cardWidth + 12;
|
||||
|
||||
return ScrollConfiguration(
|
||||
behavior: const _StretchScrollBehavior(),
|
||||
child: ListView.builder(
|
||||
clipBehavior: Clip.none,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: items.length,
|
||||
itemExtent: itemExtent,
|
||||
cacheExtent: 500,
|
||||
addAutomaticKeepAlives: false,
|
||||
addRepaintBoundaries: false,
|
||||
itemBuilder: (context, index) {
|
||||
final mix = items[index];
|
||||
final key = ValueKey(mix.uri ?? mix.itemId);
|
||||
|
||||
Widget card;
|
||||
if (mix is Playlist) {
|
||||
card = PlaylistCard(
|
||||
playlist: mix,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
imageCacheSize: 256,
|
||||
);
|
||||
} else if (mix.mediaType == MediaType.radio) {
|
||||
card = RadioStationCard(
|
||||
radioStation: mix,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
imageCacheSize: 256,
|
||||
);
|
||||
} else {
|
||||
card = const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Container(
|
||||
key: key,
|
||||
width: cardWidth,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||
child: card,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_logger.startBuild('MixesRow:${widget.title}');
|
||||
super.build(context);
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final baseRowHeight = widget.rowHeight ?? 237.0;
|
||||
const titleHeight = 52.0;
|
||||
final contentHeight = baseRowHeight - titleHeight;
|
||||
|
||||
final visibleFolders = _mixes.where((folder) {
|
||||
final name = folder.name.trim().toLowerCase();
|
||||
final id = folder.itemId.trim().toLowerCase();
|
||||
final isRecentlyPlayed = name == 'recently played' || id.contains('recently_played');
|
||||
return !isRecentlyPlayed;
|
||||
}).toList();
|
||||
|
||||
final result = RepaintBoundary(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_mixes.isEmpty && _isLoading)
|
||||
SizedBox(
|
||||
height: baseRowHeight - titleHeight,
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_mixes.isEmpty || visibleFolders.isEmpty)
|
||||
SizedBox(
|
||||
height: baseRowHeight - titleHeight,
|
||||
child: Center(
|
||||
child: Text(
|
||||
S.of(context)!.noPlaylistMixesFound,
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
...List<Widget>.generate(visibleFolders.length * 2 - 1, (index) {
|
||||
if (index.isOdd) return const SizedBox(height: 8.0);
|
||||
final folder = visibleFolders[index ~/ 2];
|
||||
return SizedBox(
|
||||
height: baseRowHeight,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||
child: Text(
|
||||
folder.name,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildShelfContent(folder.items, contentHeight),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
_logger.endBuild('MixesRow:${widget.title}');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class _StretchScrollBehavior extends ScrollBehavior {
|
||||
const _StretchScrollBehavior();
|
||||
|
||||
@override
|
||||
Widget buildOverscrollIndicator(
|
||||
BuildContext context, Widget child, ScrollableDetails details) {
|
||||
return StretchingOverscrollIndicator(
|
||||
axisDirection: details.direction,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,7 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
|
||||
|
||||
// Card layout: square artwork + text below (same as AlbumRow)
|
||||
// Text area: 8px gap + ~18px title + ~18px owner = ~44px
|
||||
const textAreaHeight = 44.0;
|
||||
const textAreaHeight = 52.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||
@@ -152,7 +152,7 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
|
||||
|
||||
// Total row height includes title + content (same as AlbumRow)
|
||||
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
|
||||
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
|
||||
@@ -115,7 +115,7 @@ class _PodcastRowState extends State<PodcastRow> with AutomaticKeepAliveClientMi
|
||||
|
||||
// Card layout: square artwork + text below (same as AlbumRow)
|
||||
// Text area: 8px gap + ~18px title + ~18px author = ~44px
|
||||
const textAreaHeight = 44.0;
|
||||
const textAreaHeight = 52.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||
@@ -160,7 +160,7 @@ class _PodcastRowState extends State<PodcastRow> with AutomaticKeepAliveClientMi
|
||||
|
||||
// Total row height includes title + content (same as AlbumRow)
|
||||
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
|
||||
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
|
||||
@@ -107,7 +107,7 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
|
||||
|
||||
// Card layout: circle image + name below (same as ArtistRow)
|
||||
// Text area: 8px gap + ~36px for 2-line name = ~44px
|
||||
const textAreaHeight = 44.0;
|
||||
const textAreaHeight = 52.0;
|
||||
final imageSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = imageSize; // Card width = image width (circle)
|
||||
final itemExtent = cardWidth + 16; // width + horizontal margins
|
||||
@@ -148,7 +148,7 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
|
||||
|
||||
// Total row height includes title + content (same as ArtistRow)
|
||||
final totalHeight = widget.rowHeight ?? 207.0; // Default: 44 title + 163 content
|
||||
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
|
||||
@@ -179,7 +179,7 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
||||
);
|
||||
}
|
||||
|
||||
const textAreaHeight = 44.0;
|
||||
const textAreaHeight = 52.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize;
|
||||
final itemExtent = cardWidth + 12;
|
||||
@@ -231,7 +231,7 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
final totalHeight = widget.rowHeight ?? 237.0;
|
||||
const titleHeight = 44.0;
|
||||
const titleHeight = 52.0;
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
|
||||
@@ -109,7 +109,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
|
||||
|
||||
// Card layout: square artwork + text below
|
||||
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
|
||||
const textAreaHeight = 44.0;
|
||||
const textAreaHeight = 52.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||
final itemExtent = cardWidth + 12;
|
||||
@@ -150,7 +150,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
|
||||
|
||||
// Total row height includes title + content
|
||||
final totalHeight = widget.rowHeight ?? 224.0; // Default: 44 title + 180 content
|
||||
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
|
||||
@@ -42,6 +42,7 @@ dependencies:
|
||||
package_info_plus: ^8.0.0
|
||||
device_info_plus: ^10.1.0
|
||||
share_plus: ^10.1.4
|
||||
url_launcher: ^6.3.0
|
||||
|
||||
# Audio - Local Playback
|
||||
just_audio: ^0.9.36
|
||||
|
||||
Reference in New Issue
Block a user