Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
277020616f | ||
|
|
cd43ad0868 | ||
|
|
98175719bf | ||
|
|
e5274ca72e | ||
|
|
6a78405dc1 | ||
|
|
49e8bbbb30 | ||
|
|
beeebeb996 | ||
|
|
49077b9858 | ||
|
|
9dbcf9971f | ||
|
|
c5bcc509e0 | ||
|
|
2f933da413 | ||
|
|
2fdd3b4286 | ||
|
|
779810674b | ||
|
|
0dc069b071 | ||
|
|
ca7b5d8d35 | ||
|
|
b15de9775f | ||
|
|
5fcfe1d3b3 | ||
|
|
6a8a4972dd | ||
|
|
73a23594cf | ||
|
|
02d7ea12cb | ||
|
|
601dd8ac7a |
@@ -29,6 +29,9 @@ class HeroTags {
|
||||
/// Podcast cover hero tag prefix
|
||||
static const String podcastCover = 'podcast_cover_';
|
||||
|
||||
/// Podcast title hero tag prefix
|
||||
static const String podcastTitle = 'podcast_title_';
|
||||
|
||||
/// Playlist cover hero tag prefix
|
||||
static const String playlistCover = 'playlist_cover_';
|
||||
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
"english": "Englisch",
|
||||
"german": "Deutsch",
|
||||
"spanish": "Spanisch",
|
||||
"french": "Französisch",
|
||||
|
||||
"noTracksInPlaylist": "Keine Songs in Wiedergabeliste",
|
||||
"sortAlphabetically": "Alphabetisch sortieren",
|
||||
|
||||
@@ -261,6 +261,11 @@
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"favoritePodcasts": "Favorite Podcasts",
|
||||
"@favoritePodcasts": {
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"searchMusic": "Search music...",
|
||||
"@searchMusic": {
|
||||
"description": "Search placeholder text"
|
||||
@@ -1055,6 +1060,11 @@
|
||||
"description": "Spanish language"
|
||||
},
|
||||
|
||||
"french": "French",
|
||||
"@french": {
|
||||
"description": "French language"
|
||||
},
|
||||
|
||||
"noTracksInPlaylist": "No tracks in playlist",
|
||||
"sortAlphabetically": "Sort alphabetically",
|
||||
"sortByYear": "Sort by year",
|
||||
@@ -1074,6 +1084,7 @@
|
||||
"showFavoriteTracks": "Show a row of your favorite tracks",
|
||||
"showFavoritePlaylists": "Show a row of your favorite playlists",
|
||||
"showFavoriteRadioStations": "Show a row of your favorite radio stations",
|
||||
"showFavoritePodcasts": "Show a row of your favorite podcasts",
|
||||
"extractColorsFromArtwork": "Extract colors from album and artist artwork",
|
||||
"chooseHomeScreenRows": "Choose which rows to display on the home screen",
|
||||
"addedToFavorites": "Added to favorites",
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
"english": "Inglés",
|
||||
"german": "Alemán",
|
||||
"spanish": "Español",
|
||||
"french": "Francés",
|
||||
"noTracksInPlaylist": "No hay canciones en la lista",
|
||||
"sortAlphabetically": "Ordenar alfabéticamente",
|
||||
"sortByYear": "Ordenar por año",
|
||||
|
||||
+1400
File diff suppressed because it is too large
Load Diff
@@ -274,6 +274,29 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
],
|
||||
supportedLocales: S.supportedLocales,
|
||||
locale: localeProvider.locale,
|
||||
// Fallback to English when locale is not supported
|
||||
localeListResolutionCallback: (locales, supportedLocales) {
|
||||
// If user has set a specific locale, try to use it
|
||||
if (localeProvider.locale != null) {
|
||||
for (final supported in supportedLocales) {
|
||||
if (supported.languageCode == localeProvider.locale!.languageCode) {
|
||||
return supported;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Try to match system locales
|
||||
if (locales != null) {
|
||||
for (final locale in locales) {
|
||||
for (final supported in supportedLocales) {
|
||||
if (supported.languageCode == locale.languageCode) {
|
||||
return supported;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback to English (not German)
|
||||
return const Locale('en');
|
||||
},
|
||||
themeMode: themeProvider.themeMode,
|
||||
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
|
||||
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
||||
|
||||
@@ -31,6 +31,10 @@ class LocaleProvider extends ChangeNotifier {
|
||||
return 'English';
|
||||
case 'de':
|
||||
return 'Deutsch';
|
||||
case 'es':
|
||||
return 'Español';
|
||||
case 'fr':
|
||||
return 'Français';
|
||||
case null:
|
||||
return 'System';
|
||||
default:
|
||||
|
||||
@@ -2839,6 +2839,18 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get favorite podcasts from the library
|
||||
Future<List<MediaItem>> getFavoritePodcasts() async {
|
||||
if (_api == null) return [];
|
||||
try {
|
||||
final podcasts = await _api!.getPodcasts(favoriteOnly: true);
|
||||
return filterByProvider(podcasts);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch favorite podcasts: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AUDIOBOOK HOME SCREEN ROWS
|
||||
// ============================================================================
|
||||
|
||||
@@ -4,6 +4,9 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../widgets/common/empty_state.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import 'playlist_details_screen.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
@@ -104,34 +107,55 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
// Unique suffix for this context to avoid hero tag conflicts
|
||||
const heroSuffix = '_playlists';
|
||||
|
||||
return RepaintBoundary(
|
||||
child: ListTile(
|
||||
key: ValueKey(playlist.itemId),
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant,
|
||||
leading: Hero(
|
||||
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
image: CachedNetworkImageProvider(imageUrl),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 96,
|
||||
memCacheHeight: 96,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox(),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: imageUrl == null
|
||||
? Icon(Icons.playlist_play_rounded, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
playlist.name,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
title: Hero(
|
||||
tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + heroSuffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
playlist.name,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
playlist.trackCount != null
|
||||
@@ -147,13 +171,14 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
? const Icon(Icons.favorite, color: Colors.red, size: 20)
|
||||
: null,
|
||||
onTap: () {
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PlaylistDetailsScreen(
|
||||
FadeSlidePageRoute(
|
||||
child: PlaylistDetailsScreen(
|
||||
playlist: playlist,
|
||||
provider: playlist.provider,
|
||||
itemId: playlist.itemId,
|
||||
heroTagSuffix: 'playlists',
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import '../widgets/audiobook_row.dart';
|
||||
import '../widgets/series_row.dart';
|
||||
import '../widgets/playlist_row.dart';
|
||||
import '../widgets/radio_station_row.dart';
|
||||
import '../widgets/podcast_row.dart';
|
||||
import '../widgets/common/disconnected_state.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
@@ -39,6 +40,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
bool _showFavoriteTracks = false;
|
||||
bool _showFavoritePlaylists = false;
|
||||
bool _showFavoriteRadioStations = false;
|
||||
bool _showFavoritePodcasts = false;
|
||||
// Audiobook rows (default off)
|
||||
bool _showContinueListeningAudiobooks = false;
|
||||
bool _showDiscoverAudiobooks = false;
|
||||
@@ -49,19 +51,89 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
// Track if we had empty favorites on first load (to know when to refresh)
|
||||
bool _hadEmptyFavoritesOnLoad = false;
|
||||
SyncStatus? _lastSyncStatus;
|
||||
int _lastArtistCount = 0;
|
||||
MusicAssistantProvider? _providerListeningTo;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_loadSettings();
|
||||
// Listen to sync completion to refresh favorite rows when data becomes available
|
||||
SyncService.instance.addListener(_onSyncChanged);
|
||||
_lastSyncStatus = SyncService.instance.status;
|
||||
_checkInitialFavoriteState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
SyncService.instance.removeListener(_onSyncChanged);
|
||||
// Clean up provider listener if attached
|
||||
_providerListeningTo?.removeListener(_onProviderChanged);
|
||||
_providerListeningTo = null;
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Check if favorites are empty on initial load (so we know to refresh after sync)
|
||||
void _checkInitialFavoriteState() {
|
||||
// Schedule after first frame to ensure provider is accessible
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
_lastArtistCount = provider.artists.length;
|
||||
// Check if artists list is empty - favorites will also be empty
|
||||
if (_lastArtistCount == 0) {
|
||||
_hadEmptyFavoritesOnLoad = true;
|
||||
_logger.log('📋 Home: favorites empty on load, will refresh after sync');
|
||||
// Also listen to provider for when cache loads (before sync completes)
|
||||
_providerListeningTo = provider;
|
||||
provider.addListener(_onProviderChanged);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Called when MusicAssistantProvider changes (for cache load detection)
|
||||
void _onProviderChanged() {
|
||||
if (!mounted || !_hadEmptyFavoritesOnLoad || _providerListeningTo == null) return;
|
||||
final currentCount = _providerListeningTo!.artists.length;
|
||||
// If artists went from 0 to non-zero, refresh
|
||||
if (_lastArtistCount == 0 && currentCount > 0) {
|
||||
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
|
||||
_hadEmptyFavoritesOnLoad = false;
|
||||
_providerListeningTo!.removeListener(_onProviderChanged);
|
||||
_providerListeningTo = null;
|
||||
setState(() {
|
||||
_refreshKey = UniqueKey();
|
||||
});
|
||||
}
|
||||
_lastArtistCount = currentCount;
|
||||
}
|
||||
|
||||
/// Called when SyncService status changes
|
||||
void _onSyncChanged() {
|
||||
final newStatus = SyncService.instance.status;
|
||||
// When sync completes and we had empty favorites, refresh to show new data
|
||||
if (_lastSyncStatus == SyncStatus.syncing &&
|
||||
newStatus == SyncStatus.completed &&
|
||||
_hadEmptyFavoritesOnLoad) {
|
||||
_logger.log('🔄 Home: sync completed, refreshing favorite rows');
|
||||
_hadEmptyFavoritesOnLoad = false;
|
||||
// Clean up provider listener if still attached
|
||||
_providerListeningTo?.removeListener(_onProviderChanged);
|
||||
_providerListeningTo = null;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_refreshKey = UniqueKey();
|
||||
});
|
||||
}
|
||||
}
|
||||
_lastSyncStatus = newStatus;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// Reload settings when app resumes (coming back from settings)
|
||||
@@ -79,6 +151,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
|
||||
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
|
||||
final showFavPodcasts = await SettingsService.getShowFavoritePodcasts();
|
||||
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
||||
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
|
||||
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
|
||||
@@ -93,6 +166,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
_showFavoriteTracks = showFavTracks;
|
||||
_showFavoritePlaylists = showFavPlaylists;
|
||||
_showFavoriteRadioStations = showFavRadio;
|
||||
_showFavoritePodcasts = showFavPodcasts;
|
||||
_showContinueListeningAudiobooks = showContAudiobooks;
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
@@ -246,6 +320,34 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
}
|
||||
|
||||
|
||||
/// Count how many rows are currently enabled
|
||||
int _countEnabledRows() {
|
||||
int count = 0;
|
||||
for (final rowId in _homeRowOrder) {
|
||||
if (_isRowEnabled(rowId)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Check if a specific row is enabled
|
||||
bool _isRowEnabled(String rowId) {
|
||||
switch (rowId) {
|
||||
case 'recent-albums': return _showRecentAlbums;
|
||||
case 'discover-artists': return _showDiscoverArtists;
|
||||
case 'discover-albums': return _showDiscoverAlbums;
|
||||
case 'continue-listening': return _showContinueListeningAudiobooks;
|
||||
case 'discover-audiobooks': return _showDiscoverAudiobooks;
|
||||
case 'discover-series': return _showDiscoverSeries;
|
||||
case 'favorite-albums': return _showFavoriteAlbums;
|
||||
case 'favorite-artists': return _showFavoriteArtists;
|
||||
case 'favorite-tracks': return _showFavoriteTracks;
|
||||
case 'favorite-playlists': return _showFavoritePlaylists;
|
||||
case 'favorite-radio-stations': return _showFavoriteRadioStations;
|
||||
case 'favorite-podcasts': return _showFavoritePodcasts;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildConnectedView(
|
||||
BuildContext context, MusicAssistantProvider provider) {
|
||||
// Use LayoutBuilder to get available screen height
|
||||
@@ -254,7 +356,14 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
// 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 rowHeight = availableHeight / 3;
|
||||
|
||||
// Account for margins between rows (2px each)
|
||||
// Only adjust for margins when ≤3 rows (so they fit exactly without scroll)
|
||||
// For 4+ rows, use original calculation so 4th row stays hidden (scrollable)
|
||||
const marginSize = 2.0;
|
||||
final enabledRows = _countEnabledRows();
|
||||
final marginsInView = enabledRows > 1 && enabledRows <= 3 ? (enabledRows - 1) * marginSize : 0.0;
|
||||
final rowHeight = (availableHeight - marginsInView) / 3;
|
||||
|
||||
// Use Android 12+ stretch overscroll effect
|
||||
return NotificationListener<ScrollNotification>(
|
||||
@@ -296,6 +405,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
for (final rowId in _homeRowOrder) {
|
||||
final widget = _buildRowWidget(rowId, provider, rowHeight);
|
||||
if (widget != null) {
|
||||
// Add spacing between rows (not before first row)
|
||||
if (rows.isNotEmpty) {
|
||||
rows.add(const SizedBox(height: 2.0));
|
||||
}
|
||||
rows.add(widget);
|
||||
}
|
||||
}
|
||||
@@ -402,6 +515,15 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
heroTagSuffix: 'home',
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'favorite-podcasts':
|
||||
if (!_showFavoritePodcasts) return null;
|
||||
return PodcastRow(
|
||||
key: const ValueKey('favorite-podcasts'),
|
||||
title: S.of(context)!.favoritePodcasts,
|
||||
loadPodcasts: () => provider.getFavoritePodcasts(),
|
||||
heroTagSuffix: 'home',
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3367,51 +3367,76 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
// Unique suffix for list view context
|
||||
const heroSuffix = '_library_list';
|
||||
|
||||
return RepaintBoundary(
|
||||
child: ListTile(
|
||||
key: ValueKey(playlist.itemId),
|
||||
leading: 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,
|
||||
leading: Hero(
|
||||
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 96,
|
||||
memCacheHeight: 96,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox(),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: imageUrl == null
|
||||
? Icon(Icons.playlist_play_rounded, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
playlist.name,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
title: Hero(
|
||||
tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + heroSuffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
playlist.name,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
playlist.trackCount != null
|
||||
? '${playlist.trackCount} ${l10n.tracks}'
|
||||
: playlist.owner ?? l10n.playlist,
|
||||
style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
playlist.trackCount != null
|
||||
? '${playlist.trackCount} ${l10n.tracks}'
|
||||
: playlist.owner ?? l10n.playlist,
|
||||
style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: playlist.favorite == true
|
||||
? const Icon(Icons.favorite, color: Colors.red, size: 20)
|
||||
: null,
|
||||
onTap: () {
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PlaylistDetailsScreen(
|
||||
FadeSlidePageRoute(
|
||||
child: PlaylistDetailsScreen(
|
||||
playlist: playlist,
|
||||
provider: playlist.provider,
|
||||
itemId: playlist.itemId,
|
||||
heroTagSuffix: 'library_list',
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -3426,73 +3451,92 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PlaylistDetailsScreen(
|
||||
playlist: playlist,
|
||||
provider: playlist.provider,
|
||||
itemId: playlist.itemId,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
placeholder: (_, __) => const SizedBox(),
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: 48,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: 48,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
// Unique suffix for grid view context
|
||||
const heroSuffix = '_library_grid';
|
||||
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
child: PlaylistDetailsScreen(
|
||||
playlist: playlist,
|
||||
heroTagSuffix: 'library_grid',
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
playlist.name,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Hero(
|
||||
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox(),
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: 48,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: 48,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
playlist.trackCount != null
|
||||
? '${playlist.trackCount} ${l10n.tracks}'
|
||||
: playlist.owner ?? l10n.playlist,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
const SizedBox(height: 8),
|
||||
Hero(
|
||||
tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + heroSuffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
playlist.name,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
Text(
|
||||
playlist.trackCount != null
|
||||
? '${playlist.trackCount} ${l10n.tracks}'
|
||||
: playlist.owner ?? l10n.playlist,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+185
-67
@@ -137,6 +137,13 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
double _lastVerticalScrollOffset = 0;
|
||||
static const double _scrollThreshold = 10.0;
|
||||
|
||||
// Filter chip position tracking for animated sliding highlight
|
||||
final Map<String, GlobalKey> _filterKeys = {};
|
||||
final Map<String, double> _filterWidths = {};
|
||||
final Map<String, double> _filterPositions = {};
|
||||
double _highlightLeft = 0;
|
||||
double _highlightWidth = 80; // Default width until measured
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -185,6 +192,39 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure filter chip positions and update highlight
|
||||
void _measureFilterPositions() {
|
||||
final filters = _getAvailableFilters();
|
||||
double left = 0;
|
||||
|
||||
for (final filter in filters) {
|
||||
final key = _filterKeys[filter];
|
||||
if (key?.currentContext != null) {
|
||||
final box = key!.currentContext!.findRenderObject() as RenderBox?;
|
||||
if (box != null && box.hasSize) {
|
||||
_filterWidths[filter] = box.size.width;
|
||||
_filterPositions[filter] = left;
|
||||
left += box.size.width;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update highlight position for the active filter
|
||||
void _updateHighlightPosition(String activeFilter) {
|
||||
_measureFilterPositions();
|
||||
|
||||
final position = _filterPositions[activeFilter];
|
||||
final width = _filterWidths[activeFilter];
|
||||
|
||||
if (position != null && width != null) {
|
||||
setState(() {
|
||||
_highlightLeft = position;
|
||||
_highlightWidth = width;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll filter bar to keep active filter visible
|
||||
/// Only scrolls when the filter would be obscured (off-screen)
|
||||
/// Uses post-frame callback to avoid interfering with PageView animation
|
||||
@@ -200,30 +240,35 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
// If all filters fit on screen, don't scroll at all
|
||||
if (maxScroll <= 0) return;
|
||||
|
||||
// Approximate width per filter chip (padding + text)
|
||||
const chipWidth = 80.0;
|
||||
const horizontalPadding = 16.0;
|
||||
final filters = _getAvailableFilters();
|
||||
if (filterIndex < 0 || filterIndex >= filters.length) return;
|
||||
|
||||
final filter = filters[filterIndex];
|
||||
|
||||
// Update highlight position
|
||||
_updateHighlightPosition(filter);
|
||||
|
||||
// Use measured positions if available, fallback to estimate
|
||||
final chipLeft = _filterPositions[filter] ?? (filterIndex * 80.0);
|
||||
final chipWidth = _filterWidths[filter] ?? 80.0;
|
||||
final chipRight = chipLeft + chipWidth;
|
||||
|
||||
final currentScroll = _filterScrollController.offset;
|
||||
final viewportWidth = _filterScrollController.position.viewportDimension;
|
||||
|
||||
// Calculate the left and right edges of the active filter chip
|
||||
final chipLeft = (filterIndex * chipWidth);
|
||||
final chipRight = chipLeft + chipWidth;
|
||||
|
||||
// Calculate what's currently visible in the viewport
|
||||
final visibleLeft = currentScroll;
|
||||
final visibleRight = currentScroll + viewportWidth - (horizontalPadding * 2);
|
||||
final visibleRight = currentScroll + viewportWidth;
|
||||
|
||||
// Only scroll if the chip is actually obscured
|
||||
double? targetOffset;
|
||||
|
||||
if (chipRight > visibleRight) {
|
||||
// Chip is cut off on the right - scroll right just enough to show it
|
||||
targetOffset = chipRight - viewportWidth + (horizontalPadding * 2);
|
||||
// Chip is cut off on the right - scroll right to show it with padding
|
||||
targetOffset = chipRight - viewportWidth + 8;
|
||||
} else if (chipLeft < visibleLeft) {
|
||||
// Chip is cut off on the left - scroll left just enough to show it
|
||||
targetOffset = chipLeft;
|
||||
// Chip is cut off on the left - scroll left to show it with padding
|
||||
targetOffset = chipLeft - 8;
|
||||
}
|
||||
|
||||
// Only animate if we need to scroll
|
||||
@@ -934,7 +979,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
return items;
|
||||
}
|
||||
|
||||
/// Build joined segmented filter selector (like library media type selector)
|
||||
/// Build joined segmented filter selector with animated sliding highlight
|
||||
Widget _buildFilterSelector(ColorScheme colorScheme) {
|
||||
final filters = _getAvailableFilters();
|
||||
final l10n = S.of(context)!;
|
||||
@@ -953,42 +998,93 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// No ClipRRect here - parent container handles clipping with rounded corners
|
||||
// Ensure GlobalKeys exist for all filters
|
||||
for (final filter in filters) {
|
||||
_filterKeys.putIfAbsent(filter, () => GlobalKey());
|
||||
}
|
||||
|
||||
// Wrap in ValueListenableBuilder for efficient rebuilds on filter change
|
||||
return ValueListenableBuilder<String>(
|
||||
valueListenable: _activeFilterNotifier,
|
||||
builder: (context, activeFilter, _) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: filters.map((filter) {
|
||||
final isSelected = activeFilter == filter;
|
||||
return Material(
|
||||
// Use theme-aware colors for light/dark mode support
|
||||
color: isSelected
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceVariant.withOpacity(0.6),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// Only update ValueNotifier - no setState needed
|
||||
_activeFilterNotifier.value = filter;
|
||||
_animateToFilter(filter);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
child: Text(
|
||||
getLabel(filter),
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant.withOpacity(0.8),
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
// Measure positions after build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_measureFilterPositions();
|
||||
// Update highlight on first build or when filters change
|
||||
final position = _filterPositions[activeFilter];
|
||||
final width = _filterWidths[activeFilter];
|
||||
if (position != null && width != null &&
|
||||
(_highlightLeft != position || _highlightWidth != width)) {
|
||||
setState(() {
|
||||
_highlightLeft = position;
|
||||
_highlightWidth = width;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
final selectedIndex = filters.indexOf(activeFilter);
|
||||
final isFirstTab = selectedIndex == 0;
|
||||
final isLastTab = selectedIndex == filters.length - 1;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Animated sliding highlight
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
left: _highlightLeft + (isFirstTab ? 0 : 2),
|
||||
width: _highlightWidth - (isFirstTab ? 0 : 2) - (isLastTab ? 0 : 2),
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
// Filter buttons row (transparent, on top of highlight)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: filters.map((filter) {
|
||||
final isSelected = activeFilter == filter;
|
||||
return GestureDetector(
|
||||
key: _filterKeys[filter],
|
||||
onTap: () {
|
||||
_activeFilterNotifier.value = filter;
|
||||
_animateToFilter(filter);
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
child: Text(
|
||||
getLabel(filter),
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant.withOpacity(0.8),
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1566,37 +1662,58 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
final isExpanded = _expandedPlaylistId == playlistId;
|
||||
final isInLib = _isInLibrary(playlist);
|
||||
|
||||
// Unique suffix for search context
|
||||
const heroSuffix = '_search';
|
||||
|
||||
return RepaintBoundary(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
key: ValueKey(playlistId),
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant,
|
||||
leading: Hero(
|
||||
tag: HeroTags.playlistCover + playlistId + heroSuffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
image: CachedNetworkImageProvider(imageUrl),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 96,
|
||||
memCacheHeight: 96,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox(),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.queue_music_rounded,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.queue_music_rounded,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: imageUrl == null
|
||||
? Icon(Icons.queue_music_rounded, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
playlist.name,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
title: Hero(
|
||||
tag: HeroTags.playlistTitle + playlistId + heroSuffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
playlist.name,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: showType
|
||||
? Row(
|
||||
@@ -1631,13 +1748,14 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
if (isExpanded) {
|
||||
setState(() => _expandedPlaylistId = null);
|
||||
} else {
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PlaylistDetailsScreen(
|
||||
FadeSlidePageRoute(
|
||||
child: PlaylistDetailsScreen(
|
||||
playlist: playlist,
|
||||
provider: playlist.provider,
|
||||
itemId: playlist.itemId,
|
||||
heroTagSuffix: 'search',
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _showFavoriteTracks = false;
|
||||
bool _showFavoritePlaylists = false;
|
||||
bool _showFavoriteRadioStations = false;
|
||||
bool _showFavoritePodcasts = false;
|
||||
// Audiobook home rows (default off)
|
||||
bool _showContinueListeningAudiobooks = false;
|
||||
bool _showDiscoverAudiobooks = false;
|
||||
@@ -75,6 +76,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
|
||||
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
|
||||
final showFavPodcasts = await SettingsService.getShowFavoritePodcasts();
|
||||
|
||||
// Load audiobook home row settings
|
||||
final showContinueAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
||||
@@ -115,6 +117,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_showFavoriteTracks = showFavTracks;
|
||||
_showFavoritePlaylists = showFavPlaylists;
|
||||
_showFavoriteRadioStations = showFavRadio;
|
||||
_showFavoritePodcasts = showFavPodcasts;
|
||||
_showContinueListeningAudiobooks = showContinueAudiobooks;
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
@@ -163,6 +166,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return {'title': s.favoritePlaylists, 'subtitle': s.showFavoritePlaylists};
|
||||
case 'favorite-radio-stations':
|
||||
return {'title': s.favoriteRadioStations, 'subtitle': s.showFavoriteRadioStations};
|
||||
case 'favorite-podcasts':
|
||||
return {'title': s.favoritePodcasts, 'subtitle': s.showFavoritePodcasts};
|
||||
default:
|
||||
return {'title': rowId, 'subtitle': ''};
|
||||
}
|
||||
@@ -193,6 +198,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return _showFavoritePlaylists;
|
||||
case 'favorite-radio-stations':
|
||||
return _showFavoriteRadioStations;
|
||||
case 'favorite-podcasts':
|
||||
return _showFavoritePodcasts;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -246,6 +253,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_showFavoriteRadioStations = value;
|
||||
SettingsService.setShowFavoriteRadioStations(value);
|
||||
break;
|
||||
case 'favorite-podcasts':
|
||||
_showFavoritePodcasts = value;
|
||||
SettingsService.setShowFavoritePodcasts(value);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -673,6 +684,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Navigator.pop(dialogContext);
|
||||
},
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: const Text('Français'),
|
||||
value: 'fr',
|
||||
groupValue: localeProvider.locale?.languageCode,
|
||||
onChanged: (value) {
|
||||
localeProvider.setLocale(const Locale('fr'));
|
||||
Navigator.pop(dialogContext);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2561,6 +2561,23 @@ class MusicAssistantAPI {
|
||||
return _eventStreams['media_item_deleted']!.stream;
|
||||
}
|
||||
|
||||
/// Stream of queue_updated events (for queue metadata changes)
|
||||
Stream<Map<String, dynamic>> get queueUpdatedEvents {
|
||||
if (!_eventStreams.containsKey('queue_updated')) {
|
||||
_eventStreams['queue_updated'] = StreamController<Map<String, dynamic>>.broadcast();
|
||||
}
|
||||
return _eventStreams['queue_updated']!.stream;
|
||||
}
|
||||
|
||||
/// Stream of queue_items_updated events (for queue item add/remove/reorder)
|
||||
/// This is critical for keeping queue UI in sync, especially in radio mode
|
||||
Stream<Map<String, dynamic>> get queueItemsUpdatedEvents {
|
||||
if (!_eventStreams.containsKey('queue_items_updated')) {
|
||||
_eventStreams['queue_items_updated'] = StreamController<Map<String, dynamic>>.broadcast();
|
||||
}
|
||||
return _eventStreams['queue_items_updated']!.stream;
|
||||
}
|
||||
|
||||
/// Register this device as a player with retry logic
|
||||
/// CRITICAL: This creates a player config in MA's settings.json
|
||||
/// The server expects: player_id and player_name
|
||||
|
||||
@@ -37,6 +37,7 @@ class SettingsService {
|
||||
static const String _keyShowFavoriteTracks = 'show_favorite_tracks';
|
||||
static const String _keyShowFavoritePlaylists = 'show_favorite_playlists';
|
||||
static const String _keyShowFavoriteRadioStations = 'show_favorite_radio_stations';
|
||||
static const String _keyShowFavoritePodcasts = 'show_favorite_podcasts';
|
||||
static const String _keyShowOnlyArtistsWithAlbums = 'show_only_artists_with_albums'; // Library artists filter
|
||||
static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs
|
||||
|
||||
@@ -53,6 +54,7 @@ class SettingsService {
|
||||
'favorite-tracks',
|
||||
'favorite-playlists',
|
||||
'favorite-radio-stations',
|
||||
'favorite-podcasts',
|
||||
];
|
||||
|
||||
// View Mode Settings
|
||||
@@ -517,6 +519,16 @@ class SettingsService {
|
||||
await prefs.setBool(_keyShowFavoriteRadioStations, show);
|
||||
}
|
||||
|
||||
static Future<bool> getShowFavoritePodcasts() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyShowFavoritePodcasts) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> setShowFavoritePodcasts(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowFavoritePodcasts, show);
|
||||
}
|
||||
|
||||
// Library Artists Filter - show only artists that have albums in library
|
||||
static Future<bool> getShowOnlyArtistsWithAlbums() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -535,7 +547,17 @@ class SettingsService {
|
||||
if (json != null) {
|
||||
try {
|
||||
final List<dynamic> decoded = jsonDecode(json);
|
||||
return decoded.cast<String>();
|
||||
final savedOrder = decoded.cast<String>();
|
||||
|
||||
// Add any new rows from defaultHomeRowOrder that aren't in saved order
|
||||
// This ensures new row types appear for existing users
|
||||
final missingRows = defaultHomeRowOrder
|
||||
.where((row) => !savedOrder.contains(row))
|
||||
.toList();
|
||||
if (missingRows.isNotEmpty) {
|
||||
return [...savedOrder, ...missingRows];
|
||||
}
|
||||
return savedOrder;
|
||||
} catch (_) {
|
||||
return List.from(defaultHomeRowOrder);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
|
||||
// Progress tracking - uses PositionTracker stream as single source of truth
|
||||
StreamSubscription<Duration>? _positionSubscription;
|
||||
// Queue items subscription - refreshes queue when MA pushes updates (e.g., radio mode adds tracks)
|
||||
StreamSubscription<Map<String, dynamic>>? _queueItemsSubscription;
|
||||
final ValueNotifier<int> _progressNotifier = ValueNotifier<int>(0);
|
||||
final ValueNotifier<double?> _seekPositionNotifier = ValueNotifier<double?>(null);
|
||||
|
||||
@@ -254,6 +256,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
// Subscribe to position tracker stream - single source of truth for playback position
|
||||
_subscribeToPositionTracker();
|
||||
|
||||
// Subscribe to queue_items_updated events from Music Assistant
|
||||
// This keeps queue in sync when MA pushes changes (e.g., radio mode adds tracks)
|
||||
_subscribeToQueueEvents();
|
||||
|
||||
// Auto-refresh queue when panel is open
|
||||
_queuePanelController.addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
@@ -317,6 +323,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
_slideController.dispose();
|
||||
_slideOffsetNotifier.dispose();
|
||||
_positionSubscription?.cancel();
|
||||
_queueItemsSubscription?.cancel();
|
||||
_queueRefreshTimer?.cancel();
|
||||
_volumePrecisionTimer?.cancel();
|
||||
_progressNotifier.dispose();
|
||||
@@ -474,6 +481,26 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
});
|
||||
}
|
||||
|
||||
void _subscribeToQueueEvents() {
|
||||
_queueItemsSubscription?.cancel();
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final api = maProvider.api;
|
||||
if (api == null) return;
|
||||
|
||||
// Subscribe to queue_items_updated events from Music Assistant
|
||||
// This keeps queue UI in sync when MA adds/removes items (e.g., radio mode)
|
||||
_queueItemsSubscription = api.queueItemsUpdatedEvents.listen((event) {
|
||||
if (!mounted) return;
|
||||
final playerId = event['queue_id'] as String?;
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
// Only refresh if the event is for our current player
|
||||
if (playerId != null && selectedPlayer != null && playerId == selectedPlayer.playerId) {
|
||||
debugPrint('QueuePanel: Received queue_items_updated event, refreshing queue');
|
||||
_loadQueue();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadQueue() async {
|
||||
if (_isLoadingQueue) return;
|
||||
|
||||
|
||||
@@ -138,15 +138,27 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
}
|
||||
|
||||
void _handleDelete(QueueItem item, int index) async {
|
||||
// Remove from local list - Dismissible handles the animation
|
||||
// Store item for potential rollback
|
||||
final deletedItem = item;
|
||||
final deletedIndex = index;
|
||||
|
||||
// Optimistic update - remove from local list
|
||||
setState(() {
|
||||
_items.removeAt(index);
|
||||
});
|
||||
|
||||
// Call API
|
||||
// Call API with error handling
|
||||
final playerId = widget.queue?.playerId;
|
||||
if (playerId != null) {
|
||||
widget.maProvider.api?.queueCommandDeleteItem(playerId, item.queueItemId);
|
||||
try {
|
||||
await widget.maProvider.api?.queueCommandDeleteItem(playerId, item.queueItemId);
|
||||
debugPrint('QueuePanel: Delete successful for ${item.track.name}');
|
||||
} catch (e) {
|
||||
debugPrint('QueuePanel: Error deleting queue item: $e');
|
||||
// Rollback: re-insert the item or refresh from server
|
||||
// Refresh is safer as queue may have changed
|
||||
widget.onRefresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,21 +347,35 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
if (playerId != null) {
|
||||
try {
|
||||
await widget.maProvider.api?.queueCommandMoveItem(playerId, item.queueItemId, posShift);
|
||||
debugPrint('QueuePanel: Move API call completed');
|
||||
debugPrint('QueuePanel: Move API call completed successfully');
|
||||
// Allow updates again after a delay for server state to propagate
|
||||
_pendingReorderTimer = Timer(const Duration(milliseconds: 2000), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_pendingReorder = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('QueuePanel: Move API error: $e');
|
||||
// Clear pending state immediately on error
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_pendingReorder = false;
|
||||
});
|
||||
}
|
||||
// Refresh queue from server to get correct state
|
||||
widget.onRefresh();
|
||||
}
|
||||
} else {
|
||||
debugPrint('QueuePanel: playerId is null, cannot move');
|
||||
}
|
||||
// Allow updates again after a delay for server state to propagate
|
||||
_pendingReorderTimer = Timer(const Duration(milliseconds: 2000), () {
|
||||
// Clear pending state
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_pendingReorder = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,8 +42,8 @@ class PlaylistCard extends StatelessWidget {
|
||||
FadeSlidePageRoute(
|
||||
child: PlaylistDetailsScreen(
|
||||
playlist: playlist,
|
||||
provider: playlist.provider,
|
||||
itemId: playlist.itemId,
|
||||
heroTagSuffix: heroTagSuffix,
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import 'playlist_card.dart';
|
||||
|
||||
class PlaylistRow extends StatefulWidget {
|
||||
@@ -30,6 +31,8 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
|
||||
bool _isLoading = true;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
static final _logger = DebugLogger();
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@@ -86,75 +89,109 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
// Don't render if empty and done loading
|
||||
if (_playlists.isEmpty && !_isLoading) {
|
||||
return const SizedBox.shrink();
|
||||
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
|
||||
// Only show loading if we have no data at all
|
||||
if (_playlists.isEmpty && _isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
if (_playlists.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No playlists found',
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate card dimensions
|
||||
// Default: 44 for title, remaining for content (artwork + 2 lines of text)
|
||||
final rowHeight = widget.rowHeight ?? 237.0;
|
||||
final titleHeight = 44.0;
|
||||
final contentHeight = rowHeight - titleHeight;
|
||||
// Artwork is square, text takes ~50px (8 padding + title + owner)
|
||||
final artworkSize = contentHeight - 50;
|
||||
final cardWidth = artworkSize;
|
||||
// Card layout: square artwork + text below (same as AlbumRow)
|
||||
// Text area: 8px gap + ~18px title + ~18px owner = ~44px
|
||||
const textAreaHeight = 44.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||
|
||||
return RepaintBoundary(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section title
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
return ScrollConfiguration(
|
||||
behavior: const _StretchScrollBehavior(),
|
||||
child: ListView.builder(
|
||||
clipBehavior: Clip.none,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: _playlists.length,
|
||||
itemExtent: itemExtent,
|
||||
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||
itemBuilder: (context, index) {
|
||||
final playlist = _playlists[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Container(
|
||||
key: ValueKey(playlist.uri ?? playlist.itemId),
|
||||
width: cardWidth,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||
child: PlaylistCard(
|
||||
playlist: playlist,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
imageCacheSize: 256,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Horizontal list of playlists
|
||||
SizedBox(
|
||||
height: contentHeight,
|
||||
child: _isLoading && _playlists.isEmpty
|
||||
? Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: _playlists.length,
|
||||
cacheExtent: 500,
|
||||
addAutomaticKeepAlives: false,
|
||||
itemBuilder: (context, index) {
|
||||
final playlist = _playlists[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: SizedBox(
|
||||
width: cardWidth,
|
||||
child: PlaylistCard(
|
||||
playlist: playlist,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
imageCacheSize: 256,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_logger.startBuild('PlaylistRow:${widget.title}');
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// 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
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
child: SizedBox(
|
||||
height: totalHeight,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildContent(contentHeight, colorScheme),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
_logger.endBuild('PlaylistRow:${widget.title}');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect
|
||||
class _StretchScrollBehavior extends ScrollBehavior {
|
||||
const _StretchScrollBehavior();
|
||||
|
||||
@override
|
||||
Widget buildOverscrollIndicator(
|
||||
BuildContext context, Widget child, ScrollableDetails details) {
|
||||
return StretchingOverscrollIndicator(
|
||||
axisDirection: details.direction,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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 '../screens/podcast_detail_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
|
||||
class PodcastCard extends StatelessWidget {
|
||||
final MediaItem podcast;
|
||||
final VoidCallback? onTap;
|
||||
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 PodcastCard({
|
||||
super.key,
|
||||
required this.podcast,
|
||||
this.onTap,
|
||||
this.heroTagSuffix,
|
||||
this.imageCacheSize,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
// Use provider's getPodcastImageUrl which includes iTunes cache
|
||||
final imageUrl = maProvider.getPodcastImageUrl(podcast, size: 256);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
|
||||
|
||||
// PERF: Use appropriate cache size based on display size
|
||||
final cacheSize = imageCacheSize ?? 256;
|
||||
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: onTap ?? () {
|
||||
// Update adaptive colors immediately on tap
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
child: PodcastDetailScreen(
|
||||
podcast: podcast,
|
||||
heroTagSuffix: heroTagSuffix,
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Podcast artwork - square with rounded corners
|
||||
AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Hero(
|
||||
tag: HeroTags.podcastCover + (podcast.uri ?? podcast.itemId) + suffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
child: Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
errorWidget: (context, url, error) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Podcast title
|
||||
Hero(
|
||||
tag: HeroTags.podcastTitle + (podcast.uri ?? podcast.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
podcast.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Podcast author (if available)
|
||||
Text(
|
||||
_getAuthor(podcast),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Extract author from podcast metadata
|
||||
String _getAuthor(MediaItem podcast) {
|
||||
final metadata = podcast.metadata;
|
||||
if (metadata == null) return '';
|
||||
|
||||
// Try different metadata fields for author
|
||||
final author = metadata['author'] ??
|
||||
metadata['artist'] ??
|
||||
metadata['owner'] ?? '';
|
||||
return author.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import 'podcast_card.dart';
|
||||
|
||||
class PodcastRow extends StatefulWidget {
|
||||
final String title;
|
||||
final Future<List<MediaItem>> Function() loadPodcasts;
|
||||
final String? heroTagSuffix;
|
||||
final double? rowHeight;
|
||||
/// Optional: synchronous getter for cached data (for instant display)
|
||||
final List<MediaItem>? Function()? getCachedPodcasts;
|
||||
|
||||
const PodcastRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.loadPodcasts,
|
||||
this.heroTagSuffix,
|
||||
this.rowHeight,
|
||||
this.getCachedPodcasts,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PodcastRow> createState() => _PodcastRowState();
|
||||
}
|
||||
|
||||
class _PodcastRowState extends State<PodcastRow> with AutomaticKeepAliveClientMixin {
|
||||
List<MediaItem> _podcasts = [];
|
||||
bool _isLoading = true;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
static final _logger = DebugLogger();
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Get cached data synchronously BEFORE first build (no spinner flash)
|
||||
final cached = widget.getCachedPodcasts?.call();
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
_podcasts = cached;
|
||||
_isLoading = false;
|
||||
}
|
||||
_loadPodcasts();
|
||||
}
|
||||
|
||||
Future<void> _loadPodcasts() async {
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// Load fresh data (always update - fresh data may have images that cached data lacks)
|
||||
try {
|
||||
final freshPodcasts = await widget.loadPodcasts();
|
||||
if (mounted && freshPodcasts.isNotEmpty) {
|
||||
setState(() {
|
||||
_podcasts = freshPodcasts;
|
||||
_isLoading = false;
|
||||
});
|
||||
// Pre-cache images for smooth hero animations
|
||||
_precachePodcastImages(freshPodcasts);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure - keep showing cached data
|
||||
}
|
||||
|
||||
if (mounted && _isLoading) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-cache podcast images so hero animations are smooth on first tap
|
||||
void _precachePodcastImages(List<MediaItem> podcasts) {
|
||||
if (!mounted) return;
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// Only precache first ~10 visible items to avoid excessive network/memory use
|
||||
final podcastsToCache = podcasts.take(10);
|
||||
|
||||
for (final podcast in podcastsToCache) {
|
||||
// Use getPodcastImageUrl which includes iTunes cache
|
||||
final imageUrl = maProvider.getPodcastImageUrl(podcast, size: 256);
|
||||
if (imageUrl != null) {
|
||||
// Use CachedNetworkImageProvider to warm the cache
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
context,
|
||||
).catchError((_) {
|
||||
// Silently ignore precache errors
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
|
||||
// Only show loading if we have no data at all
|
||||
if (_podcasts.isEmpty && _isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_podcasts.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No podcasts found',
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Card layout: square artwork + text below (same as AlbumRow)
|
||||
// Text area: 8px gap + ~18px title + ~18px author = ~44px
|
||||
const textAreaHeight = 44.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||
|
||||
return ScrollConfiguration(
|
||||
behavior: const _StretchScrollBehavior(),
|
||||
child: ListView.builder(
|
||||
clipBehavior: Clip.none,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: _podcasts.length,
|
||||
itemExtent: itemExtent,
|
||||
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||
itemBuilder: (context, index) {
|
||||
final podcast = _podcasts[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Container(
|
||||
key: ValueKey(podcast.uri ?? podcast.itemId),
|
||||
width: cardWidth,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||
child: PodcastCard(
|
||||
podcast: podcast,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
imageCacheSize: 256,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_logger.startBuild('PodcastRow:${widget.title}');
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// 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
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
child: SizedBox(
|
||||
height: totalHeight,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildContent(contentHeight, colorScheme),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
_logger.endBuild('PodcastRow:${widget.title}');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect
|
||||
class _StretchScrollBehavior extends ScrollBehavior {
|
||||
const _StretchScrollBehavior();
|
||||
|
||||
@override
|
||||
Widget buildOverscrollIndicator(
|
||||
BuildContext context, Widget child, ScrollableDetails details) {
|
||||
return StretchingOverscrollIndicator(
|
||||
axisDirection: details.direction,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -80,19 +80,23 @@ class RadioStationCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Radio station name - centered for circular images
|
||||
Hero(
|
||||
tag: HeroTags.radioTitle + (radioStation.uri ?? radioStation.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
radioStation.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
// Radio station name - fixed height container so image size is consistent
|
||||
SizedBox(
|
||||
height: 36, // Fixed height for 2 lines of text
|
||||
child: Hero(
|
||||
tag: HeroTags.radioTitle + (radioStation.uri ?? radioStation.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
radioStation.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import 'radio_station_card.dart';
|
||||
|
||||
class RadioStationRow extends StatefulWidget {
|
||||
@@ -30,6 +31,8 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
|
||||
bool _isLoading = true;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
static final _logger = DebugLogger();
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@@ -86,75 +89,105 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
// Don't render if empty and done loading
|
||||
if (_radioStations.isEmpty && !_isLoading) {
|
||||
return const SizedBox.shrink();
|
||||
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
|
||||
// Only show loading if we have no data at all
|
||||
if (_radioStations.isEmpty && _isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
if (_radioStations.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No radio stations found',
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate card dimensions - similar to ArtistRow for circular items
|
||||
// Default: 44 for title, remaining for content (circular image + name)
|
||||
final rowHeight = widget.rowHeight ?? 207.0;
|
||||
final titleHeight = 44.0;
|
||||
final contentHeight = rowHeight - titleHeight;
|
||||
// Circular artwork, text takes ~40px (8 padding + name lines)
|
||||
final artworkSize = contentHeight - 40;
|
||||
final cardWidth = artworkSize;
|
||||
// Card layout: circle image + name below (same as ArtistRow)
|
||||
// Text area: 8px gap + ~36px for 2-line name = ~44px
|
||||
const textAreaHeight = 44.0;
|
||||
final imageSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = imageSize; // Card width = image width (circle)
|
||||
final itemExtent = cardWidth + 16; // width + horizontal margins
|
||||
|
||||
return RepaintBoundary(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section title
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
return ScrollConfiguration(
|
||||
behavior: const _StretchScrollBehavior(),
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: _radioStations.length,
|
||||
itemExtent: itemExtent,
|
||||
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||
itemBuilder: (context, index) {
|
||||
final station = _radioStations[index];
|
||||
return Container(
|
||||
key: ValueKey(station.uri ?? station.itemId),
|
||||
width: cardWidth,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: RadioStationCard(
|
||||
radioStation: station,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
imageCacheSize: 256,
|
||||
),
|
||||
),
|
||||
// Horizontal list of radio stations
|
||||
SizedBox(
|
||||
height: contentHeight,
|
||||
child: _isLoading && _radioStations.isEmpty
|
||||
? Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: _radioStations.length,
|
||||
cacheExtent: 500,
|
||||
addAutomaticKeepAlives: false,
|
||||
itemBuilder: (context, index) {
|
||||
final station = _radioStations[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: SizedBox(
|
||||
width: cardWidth,
|
||||
child: RadioStationCard(
|
||||
radioStation: station,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
imageCacheSize: 256,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_logger.startBuild('RadioStationRow:${widget.title}');
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// 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
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
child: SizedBox(
|
||||
height: totalHeight,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildContent(contentHeight, colorScheme),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
_logger.endBuild('RadioStationRow:${widget.title}');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect
|
||||
class _StretchScrollBehavior extends ScrollBehavior {
|
||||
const _StretchScrollBehavior();
|
||||
|
||||
@override
|
||||
Widget buildOverscrollIndicator(
|
||||
BuildContext context, Widget child, ScrollableDetails details) {
|
||||
return StretchingOverscrollIndicator(
|
||||
axisDirection: details.direction,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 2.8.6-beta+44
|
||||
version: 2.8.7-beta+45
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
Reference in New Issue
Block a user