Compare commits

...
21 Commits
Author SHA1 Message Date
Your NameandClaude Opus 4.5 277020616f Bump version to 2.8.7-beta+45
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 16:07:45 +00:00
Your NameandClaude Opus 4.5 cd43ad0868 Fix queue sync issues and add error handling (issue #30)
- Subscribe to queue_items_updated events from Music Assistant
- Add error handling with rollback for queue delete operations
- Add error handling with rollback for queue move operations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 15:31:21 +00:00
Your NameandClaude Opus 4.5 98175719bf Add animated sliding highlight to search filter bar
- Add AnimatedPositioned highlight that slides smoothly between filters
- Match the library screen's animated pill-style highlight effect
- Use GlobalKeys to measure actual filter chip positions for accurate animation
- Fix scroll-back behavior by using measured positions instead of estimates
- The highlight animates with 300ms easeOutCubic curve matching library screen

Also fixes the scroll issue where swiping back to the beginning would leave
the last filter hidden. The scroll logic now uses actual measured positions
with proper padding instead of approximate 80px widths.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 14:32:17 +00:00
Your NameandClaude Opus 4.5 e5274ca72e Fix favorite artists not showing on startup (issue #41)
Add listeners to SyncService and MusicAssistantProvider to detect when
artists become available after initial load. When the home screen builds
with empty favorites (before cache/sync completes), it now:

- Listens for SyncService status change from syncing to completed
- Listens for MusicAssistantProvider changes when artists load from cache
- Triggers a full row refresh when artists become available

This fixes the race condition where favorite rows would load before
the library cache was populated, showing empty results until manual refresh.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:46:37 +00:00
Your Name 6a78405dc1 Revert "Add long-press to favorite on PlaylistCard"
This reverts commit 49e8bbbb30.
2026-01-11 10:10:44 +00:00
Your NameandClaude Opus 4.5 49e8bbbb30 Add long-press to favorite on PlaylistCard
Quick enhancement from Issue #43:

- Convert PlaylistCard to StatefulWidget
- Add onLongPress handler to toggle favorite
- Show animated heart overlay with elastic animation
- Display persistent favorite indicator (heart badge) when favorited
- Haptic feedback on long-press
- Snackbar confirmation with offline sync support
- Invalidate home cache after favorite change

Matches the favorite functionality in PlaylistDetailsScreen.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:55:39 +00:00
Your NameandClaude Opus 4.5 beeebeb996 Fix language fallback to English and add French translation
Issue #44: Language now correctly falls back to English instead of German
for unsupported locales (e.g., French system language before this update).

Added localeListResolutionCallback to MaterialApp that:
- Respects user's explicit locale choice
- Falls back to matching system locale if available
- Defaults to English when no match found

Added complete French (fr) translation:
- All 200+ strings translated
- Proper French accents (é, è, ê, à, etc.)
- Added to settings language picker

Updated:
- lib/main.dart: localeListResolutionCallback for fallback logic
- lib/l10n/app_fr.arb: New French translation file
- lib/l10n/app_en.arb: Added "french" string
- lib/l10n/app_de.arb: Added "french" string (Französisch)
- lib/l10n/app_es.arb: Added "french" string (Francés)
- lib/providers/locale_provider.dart: Added French display name
- lib/screens/settings_screen.dart: Added French option

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:50:41 +00:00
Your NameandClaude Opus 4.5 49077b9858 Add hero animations to playlist displays across all screens
Applied optimized hero animation pattern from AlbumCard/ArtistAvatar:

Library Playlists Screen:
- Hero cover with ClipRRect(8) + CachedNetworkImage
- Hero title with Material wrapper
- heroTagSuffix: 'playlists'

New Library Screen (List):
- Hero cover + title with memCacheWidth/Height: 96
- heroTagSuffix: 'library_list'

New Library Screen (Grid):
- Hero cover + title with memCacheWidth/Height: 256
- RepaintBoundary wrapper added
- heroTagSuffix: 'library_grid'

Search Screen:
- Hero cover + title for search results
- heroTagSuffix: 'search'

Performance optimizations applied:
- fadeInDuration/fadeOutDuration: Duration.zero
- Matching memCacheWidth/Height between source and destination
- surfaceContainerHighest background for consistency
- placeholder: SizedBox() for smooth transitions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 01:12:18 +00:00
Your NameandClaude Opus 4.5 9dbcf9971f Upgrade PlaylistDetailsScreen to match AlbumDetailsScreen quality
Major improvements to the playlist detail screen:

- Add hero animations for playlist cover and title
- Add adaptive color extraction from cover art
- Add responsive layout with proper sizing (LayoutBuilder)
- Add action button row: Play, Play On, Add to Queue, Favorite
- Add expandable track actions on long-press (radio, queue, favorite)
- Replace hardcoded colors with theme-aware colors
- Add track duration display in track list
- Add fullscreen art viewer on cover tap
- Add proper bottom padding for mini player
- Update all playlist navigation to use FadeSlidePageRoute

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 01:00:22 +00:00
Your Name c5bcc509e0 Merge feature/home-row-conformity-and-favorite-podcasts: Row conformity fixes and favorite podcasts row 2026-01-11 00:09:56 +00:00
Your NameandClaude Opus 4.5 2f933da413 Remove all debug coloring from rows and home screen
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 00:02:46 +00:00
Your NameandClaude Opus 4.5 2fdd3b4286 Add blue debug color to content area of all rows
Debug colors now show every pixel:
- Green = title padding (12px top, 8px bottom)
- Yellow = 2px margin between rows
- Blue = content area (horizontal list)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:53:42 +00:00
Your NameandClaude Opus 4.5 779810674b Add red debug bar at end of content with calculated values
Shows availableHeight, rowHeight, enabledRows, and margins to help
identify what's causing scroll overflow.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:51:36 +00:00
Your NameandClaude Opus 4.5 0dc069b071 Fix 4th row peeking by only adjusting height for ≤3 rows
Only subtract margin space when there are 3 or fewer rows.
For 4+ rows, use original calculation so 4th row title stays hidden.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:49:29 +00:00
Your NameandClaude Opus 4.5 ca7b5d8d35 Fix scroll overflow by accounting for margins in row height calculation
The row height calculation now subtracts margin space before dividing
by 3, ensuring that 3 rows + 2 margins fit exactly without scroll.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:39:19 +00:00
Your NameandClaude Opus 4.5 b15de9775f Increase margin between rows from 1px to 2px
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:27:40 +00:00
Your NameandClaude Opus 4.5 5fcfe1d3b3 Add debug colors to visualize row layout
- Green: title padding area (12px top, 8px bottom)
- Yellow: 1px margin between rows

Temporary debug visualization to help understand spacing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:19:05 +00:00
Your NameandClaude Opus 4.5 6a8a4972dd Add 1px spacing between home screen rows
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:07:40 +00:00
Your Name 73a23594cf Revert "Increase row title bottom padding from 8px to 9px"
This reverts commit 02d7ea12cb.
2026-01-10 23:06:18 +00:00
Your NameandClaude Opus 4.5 02d7ea12cb Increase row title bottom padding from 8px to 9px
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:59:25 +00:00
Your NameandClaude Opus 4.5 601dd8ac7a Add favorite podcasts row and fix home row conformity
- Add PodcastCard and PodcastRow widgets for favorite podcasts
- Fix RadioStationRow to conform to established row patterns:
  - Use titleLarge font instead of titleMedium
  - Use fromLTRB padding (16, 12, 16, 8) instead of symmetric
  - Add SizedBox height wrapper, itemExtent, StretchScrollBehavior
  - Add DebugLogger for performance tracking
- Fix RadioStationCard text clipping (height: 1.15, SizedBox wrapper)
- Fix PlaylistRow with same conformity updates as RadioStationRow
- Add getFavoritePodcasts() to provider
- Add settings support for favorite podcasts row visibility
- Add localization strings for favorite podcasts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:46:02 +00:00
25 changed files with 3580 additions and 600 deletions
+3
View File
@@ -29,6 +29,9 @@ class HeroTags {
/// Podcast cover hero tag prefix /// Podcast cover hero tag prefix
static const String podcastCover = 'podcast_cover_'; static const String podcastCover = 'podcast_cover_';
/// Podcast title hero tag prefix
static const String podcastTitle = 'podcast_title_';
/// Playlist cover hero tag prefix /// Playlist cover hero tag prefix
static const String playlistCover = 'playlist_cover_'; static const String playlistCover = 'playlist_cover_';
+1
View File
@@ -203,6 +203,7 @@
"english": "Englisch", "english": "Englisch",
"german": "Deutsch", "german": "Deutsch",
"spanish": "Spanisch", "spanish": "Spanisch",
"french": "Französisch",
"noTracksInPlaylist": "Keine Songs in Wiedergabeliste", "noTracksInPlaylist": "Keine Songs in Wiedergabeliste",
"sortAlphabetically": "Alphabetisch sortieren", "sortAlphabetically": "Alphabetisch sortieren",
+11
View File
@@ -261,6 +261,11 @@
"description": "Home screen section title" "description": "Home screen section title"
}, },
"favoritePodcasts": "Favorite Podcasts",
"@favoritePodcasts": {
"description": "Home screen section title"
},
"searchMusic": "Search music...", "searchMusic": "Search music...",
"@searchMusic": { "@searchMusic": {
"description": "Search placeholder text" "description": "Search placeholder text"
@@ -1055,6 +1060,11 @@
"description": "Spanish language" "description": "Spanish language"
}, },
"french": "French",
"@french": {
"description": "French language"
},
"noTracksInPlaylist": "No tracks in playlist", "noTracksInPlaylist": "No tracks in playlist",
"sortAlphabetically": "Sort alphabetically", "sortAlphabetically": "Sort alphabetically",
"sortByYear": "Sort by year", "sortByYear": "Sort by year",
@@ -1074,6 +1084,7 @@
"showFavoriteTracks": "Show a row of your favorite tracks", "showFavoriteTracks": "Show a row of your favorite tracks",
"showFavoritePlaylists": "Show a row of your favorite playlists", "showFavoritePlaylists": "Show a row of your favorite playlists",
"showFavoriteRadioStations": "Show a row of your favorite radio stations", "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", "extractColorsFromArtwork": "Extract colors from album and artist artwork",
"chooseHomeScreenRows": "Choose which rows to display on the home screen", "chooseHomeScreenRows": "Choose which rows to display on the home screen",
"addedToFavorites": "Added to favorites", "addedToFavorites": "Added to favorites",
+1
View File
@@ -360,6 +360,7 @@
"english": "Inglés", "english": "Inglés",
"german": "Alemán", "german": "Alemán",
"spanish": "Español", "spanish": "Español",
"french": "Francés",
"noTracksInPlaylist": "No hay canciones en la lista", "noTracksInPlaylist": "No hay canciones en la lista",
"sortAlphabetically": "Ordenar alfabéticamente", "sortAlphabetically": "Ordenar alfabéticamente",
"sortByYear": "Ordenar por año", "sortByYear": "Ordenar por año",
+1400
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -274,6 +274,29 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
], ],
supportedLocales: S.supportedLocales, supportedLocales: S.supportedLocales,
locale: localeProvider.locale, 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, themeMode: themeProvider.themeMode,
theme: AppTheme.lightTheme(colorScheme: lightColorScheme), theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme), darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
+4
View File
@@ -31,6 +31,10 @@ class LocaleProvider extends ChangeNotifier {
return 'English'; return 'English';
case 'de': case 'de':
return 'Deutsch'; return 'Deutsch';
case 'es':
return 'Español';
case 'fr':
return 'Français';
case null: case null:
return 'System'; return 'System';
default: 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 // AUDIOBOOK HOME SCREEN ROWS
// ============================================================================ // ============================================================================
+50 -25
View File
@@ -4,6 +4,9 @@ import 'package:cached_network_image/cached_network_image.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../widgets/common/empty_state.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 'playlist_details_screen.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -104,34 +107,55 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
// Unique suffix for this context to avoid hero tag conflicts
const heroSuffix = '_playlists';
return RepaintBoundary( return RepaintBoundary(
child: ListTile( child: ListTile(
key: ValueKey(playlist.itemId), key: ValueKey(playlist.itemId),
leading: Container( leading: Hero(
width: 48, tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
height: 48, child: ClipRRect(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
image: imageUrl != null child: Container(
? DecorationImage( width: 48,
image: CachedNetworkImageProvider(imageUrl), height: 48,
fit: BoxFit.cover, color: colorScheme.surfaceContainerHighest,
) child: imageUrl != null
: 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( title: Hero(
playlist.name, tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + heroSuffix,
style: textTheme.titleMedium?.copyWith( child: Material(
color: colorScheme.onSurface, color: Colors.transparent,
fontWeight: FontWeight.w500, 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( subtitle: Text(
playlist.trackCount != null playlist.trackCount != null
@@ -147,13 +171,14 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
? const Icon(Icons.favorite, color: Colors.red, size: 20) ? const Icon(Icons.favorite, color: Colors.red, size: 20)
: null, : null,
onTap: () { onTap: () {
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( FadeSlidePageRoute(
builder: (context) => PlaylistDetailsScreen( child: PlaylistDetailsScreen(
playlist: playlist, playlist: playlist,
provider: playlist.provider, heroTagSuffix: 'playlists',
itemId: playlist.itemId, initialImageUrl: imageUrl,
), ),
), ),
); );
+123 -1
View File
@@ -15,6 +15,7 @@ import '../widgets/audiobook_row.dart';
import '../widgets/series_row.dart'; import '../widgets/series_row.dart';
import '../widgets/playlist_row.dart'; import '../widgets/playlist_row.dart';
import '../widgets/radio_station_row.dart'; import '../widgets/radio_station_row.dart';
import '../widgets/podcast_row.dart';
import '../widgets/common/disconnected_state.dart'; import '../widgets/common/disconnected_state.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'search_screen.dart'; import 'search_screen.dart';
@@ -39,6 +40,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
bool _showFavoriteTracks = false; bool _showFavoriteTracks = false;
bool _showFavoritePlaylists = false; bool _showFavoritePlaylists = false;
bool _showFavoriteRadioStations = false; bool _showFavoriteRadioStations = false;
bool _showFavoritePodcasts = false;
// Audiobook rows (default off) // Audiobook rows (default off)
bool _showContinueListeningAudiobooks = false; bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false; bool _showDiscoverAudiobooks = false;
@@ -49,19 +51,89 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
@override @override
bool get wantKeepAlive => true; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_loadSettings(); _loadSettings();
// Listen to sync completion to refresh favorite rows when data becomes available
SyncService.instance.addListener(_onSyncChanged);
_lastSyncStatus = SyncService.instance.status;
_checkInitialFavoriteState();
} }
@override @override
void dispose() { void dispose() {
SyncService.instance.removeListener(_onSyncChanged);
// Clean up provider listener if attached
_providerListeningTo?.removeListener(_onProviderChanged);
_providerListeningTo = null;
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); 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 @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
// Reload settings when app resumes (coming back from settings) // 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 showFavTracks = await SettingsService.getShowFavoriteTracks();
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists(); final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
final showFavRadio = await SettingsService.getShowFavoriteRadioStations(); final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
final showFavPodcasts = await SettingsService.getShowFavoritePodcasts();
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks(); final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks(); final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries(); final showDiscSeries = await SettingsService.getShowDiscoverSeries();
@@ -93,6 +166,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_showFavoriteTracks = showFavTracks; _showFavoriteTracks = showFavTracks;
_showFavoritePlaylists = showFavPlaylists; _showFavoritePlaylists = showFavPlaylists;
_showFavoriteRadioStations = showFavRadio; _showFavoriteRadioStations = showFavRadio;
_showFavoritePodcasts = showFavPodcasts;
_showContinueListeningAudiobooks = showContAudiobooks; _showContinueListeningAudiobooks = showContAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks; _showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries; _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( Widget _buildConnectedView(
BuildContext context, MusicAssistantProvider provider) { BuildContext context, MusicAssistantProvider provider) {
// Use LayoutBuilder to get available screen height // 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 // Each row is always 1/3 of screen height
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll // 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer; 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 // Use Android 12+ stretch overscroll effect
return NotificationListener<ScrollNotification>( return NotificationListener<ScrollNotification>(
@@ -296,6 +405,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
for (final rowId in _homeRowOrder) { for (final rowId in _homeRowOrder) {
final widget = _buildRowWidget(rowId, provider, rowHeight); final widget = _buildRowWidget(rowId, provider, rowHeight);
if (widget != null) { if (widget != null) {
// Add spacing between rows (not before first row)
if (rows.isNotEmpty) {
rows.add(const SizedBox(height: 2.0));
}
rows.add(widget); rows.add(widget);
} }
} }
@@ -402,6 +515,15 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
heroTagSuffix: 'home', heroTagSuffix: 'home',
rowHeight: rowHeight, 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: default:
return null; return null;
} }
+140 -96
View File
@@ -3367,51 +3367,76 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
// Unique suffix for list view context
const heroSuffix = '_library_list';
return RepaintBoundary( return RepaintBoundary(
child: ListTile( child: ListTile(
key: ValueKey(playlist.itemId), key: ValueKey(playlist.itemId),
leading: Container( leading: Hero(
width: 48, tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
height: 48, child: ClipRRect(
decoration: BoxDecoration( borderRadius: BorderRadius.circular(8),
color: colorScheme.surfaceVariant, child: Container(
borderRadius: BorderRadius.circular(8), width: 48,
image: imageUrl != null height: 48,
? DecorationImage(image: CachedNetworkImageProvider(imageUrl), fit: BoxFit.cover) color: colorScheme.surfaceContainerHighest,
: null, 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 title: Hero(
? Icon(Icons.playlist_play_rounded, color: colorScheme.onSurfaceVariant) tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + heroSuffix,
: null, child: Material(
), color: Colors.transparent,
title: Text( child: Text(
playlist.name, playlist.name,
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface, color: colorScheme.onSurface,
fontWeight: FontWeight.w500, 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 trailing: playlist.favorite == true
? const Icon(Icons.favorite, color: Colors.red, size: 20) ? const Icon(Icons.favorite, color: Colors.red, size: 20)
: null, : null,
onTap: () { onTap: () {
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( FadeSlidePageRoute(
builder: (context) => PlaylistDetailsScreen( child: PlaylistDetailsScreen(
playlist: playlist, playlist: playlist,
provider: playlist.provider, heroTagSuffix: 'library_list',
itemId: playlist.itemId, initialImageUrl: imageUrl,
), ),
), ),
); );
@@ -3426,73 +3451,92 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return GestureDetector( // Unique suffix for grid view context
onTap: () { const heroSuffix = '_library_grid';
Navigator.push(
context, return RepaintBoundary(
MaterialPageRoute( child: GestureDetector(
builder: (context) => PlaylistDetailsScreen( onTap: () {
playlist: playlist, updateAdaptiveColorsFromImage(context, imageUrl);
provider: playlist.provider, Navigator.push(
itemId: playlist.itemId, context,
), FadeSlidePageRoute(
), child: PlaylistDetailsScreen(
); playlist: playlist,
}, heroTagSuffix: 'library_grid',
child: Column( initialImageUrl: imageUrl,
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,
),
),
), ),
), ),
), );
const SizedBox(height: 8), },
Text( child: Column(
playlist.name, crossAxisAlignment: CrossAxisAlignment.start,
style: textTheme.bodyMedium?.copyWith( children: [
color: colorScheme.onSurface, Expanded(
fontWeight: FontWeight.w500, 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, const SizedBox(height: 8),
overflow: TextOverflow.ellipsis, Hero(
), tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + heroSuffix,
Text( child: Material(
playlist.trackCount != null color: Colors.transparent,
? '${playlist.trackCount} ${l10n.tracks}' child: Text(
: playlist.owner ?? l10n.playlist, playlist.name,
style: textTheme.bodySmall?.copyWith( style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7), color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
), ),
maxLines: 1, Text(
overflow: TextOverflow.ellipsis, 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
View File
@@ -137,6 +137,13 @@ class SearchScreenState extends State<SearchScreen> {
double _lastVerticalScrollOffset = 0; double _lastVerticalScrollOffset = 0;
static const double _scrollThreshold = 10.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 @override
void initState() { void initState() {
super.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 /// Scroll filter bar to keep active filter visible
/// Only scrolls when the filter would be obscured (off-screen) /// Only scrolls when the filter would be obscured (off-screen)
/// Uses post-frame callback to avoid interfering with PageView animation /// 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 all filters fit on screen, don't scroll at all
if (maxScroll <= 0) return; if (maxScroll <= 0) return;
// Approximate width per filter chip (padding + text) final filters = _getAvailableFilters();
const chipWidth = 80.0; if (filterIndex < 0 || filterIndex >= filters.length) return;
const horizontalPadding = 16.0;
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 currentScroll = _filterScrollController.offset;
final viewportWidth = _filterScrollController.position.viewportDimension; 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 // Calculate what's currently visible in the viewport
final visibleLeft = currentScroll; final visibleLeft = currentScroll;
final visibleRight = currentScroll + viewportWidth - (horizontalPadding * 2); final visibleRight = currentScroll + viewportWidth;
// Only scroll if the chip is actually obscured // Only scroll if the chip is actually obscured
double? targetOffset; double? targetOffset;
if (chipRight > visibleRight) { if (chipRight > visibleRight) {
// Chip is cut off on the right - scroll right just enough to show it // Chip is cut off on the right - scroll right to show it with padding
targetOffset = chipRight - viewportWidth + (horizontalPadding * 2); targetOffset = chipRight - viewportWidth + 8;
} else if (chipLeft < visibleLeft) { } else if (chipLeft < visibleLeft) {
// Chip is cut off on the left - scroll left just enough to show it // Chip is cut off on the left - scroll left to show it with padding
targetOffset = chipLeft; targetOffset = chipLeft - 8;
} }
// Only animate if we need to scroll // Only animate if we need to scroll
@@ -934,7 +979,7 @@ class SearchScreenState extends State<SearchScreen> {
return items; 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) { Widget _buildFilterSelector(ColorScheme colorScheme) {
final filters = _getAvailableFilters(); final filters = _getAvailableFilters();
final l10n = S.of(context)!; 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 // Wrap in ValueListenableBuilder for efficient rebuilds on filter change
return ValueListenableBuilder<String>( return ValueListenableBuilder<String>(
valueListenable: _activeFilterNotifier, valueListenable: _activeFilterNotifier,
builder: (context, activeFilter, _) { builder: (context, activeFilter, _) {
return Row( // Measure positions after build
mainAxisSize: MainAxisSize.min, WidgetsBinding.instance.addPostFrameCallback((_) {
children: filters.map((filter) { if (mounted) {
final isSelected = activeFilter == filter; _measureFilterPositions();
return Material( // Update highlight on first build or when filters change
// Use theme-aware colors for light/dark mode support final position = _filterPositions[activeFilter];
color: isSelected final width = _filterWidths[activeFilter];
? colorScheme.primaryContainer if (position != null && width != null &&
: colorScheme.surfaceVariant.withOpacity(0.6), (_highlightLeft != position || _highlightWidth != width)) {
child: InkWell( setState(() {
onTap: () { _highlightLeft = position;
// Only update ValueNotifier - no setState needed _highlightWidth = width;
_activeFilterNotifier.value = filter; });
_animateToFilter(filter); }
}, }
child: Padding( });
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Text( final selectedIndex = filters.indexOf(activeFilter);
getLabel(filter), final isFirstTab = selectedIndex == 0;
style: TextStyle( final isLastTab = selectedIndex == filters.length - 1;
color: isSelected
? colorScheme.onPrimaryContainer return Container(
: colorScheme.onSurfaceVariant.withOpacity(0.8), decoration: BoxDecoration(
fontSize: 14, color: colorScheme.surfaceContainerHighest.withOpacity(0.5),
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, 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),
), ),
), ),
), ),
), // Filter buttons row (transparent, on top of highlight)
); Row(
}).toList(), 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 isExpanded = _expandedPlaylistId == playlistId;
final isInLib = _isInLibrary(playlist); final isInLib = _isInLibrary(playlist);
// Unique suffix for search context
const heroSuffix = '_search';
return RepaintBoundary( return RepaintBoundary(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ListTile( ListTile(
key: ValueKey(playlistId), key: ValueKey(playlistId),
leading: Container( leading: Hero(
width: 48, tag: HeroTags.playlistCover + playlistId + heroSuffix,
height: 48, child: ClipRRect(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
image: imageUrl != null child: Container(
? DecorationImage( width: 48,
image: CachedNetworkImageProvider(imageUrl), height: 48,
fit: BoxFit.cover, color: colorScheme.surfaceContainerHighest,
) child: imageUrl != null
: 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( title: Hero(
playlist.name, tag: HeroTags.playlistTitle + playlistId + heroSuffix,
style: TextStyle( child: Material(
color: colorScheme.onSurface, color: Colors.transparent,
fontWeight: FontWeight.w500, child: Text(
playlist.name,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
subtitle: showType subtitle: showType
? Row( ? Row(
@@ -1631,13 +1748,14 @@ class SearchScreenState extends State<SearchScreen> {
if (isExpanded) { if (isExpanded) {
setState(() => _expandedPlaylistId = null); setState(() => _expandedPlaylistId = null);
} else { } else {
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( FadeSlidePageRoute(
builder: (context) => PlaylistDetailsScreen( child: PlaylistDetailsScreen(
playlist: playlist, playlist: playlist,
provider: playlist.provider, heroTagSuffix: 'search',
itemId: playlist.itemId, initialImageUrl: imageUrl,
), ),
), ),
); );
+20
View File
@@ -31,6 +31,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _showFavoriteTracks = false; bool _showFavoriteTracks = false;
bool _showFavoritePlaylists = false; bool _showFavoritePlaylists = false;
bool _showFavoriteRadioStations = false; bool _showFavoriteRadioStations = false;
bool _showFavoritePodcasts = false;
// Audiobook home rows (default off) // Audiobook home rows (default off)
bool _showContinueListeningAudiobooks = false; bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false; bool _showDiscoverAudiobooks = false;
@@ -75,6 +76,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final showFavTracks = await SettingsService.getShowFavoriteTracks(); final showFavTracks = await SettingsService.getShowFavoriteTracks();
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists(); final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
final showFavRadio = await SettingsService.getShowFavoriteRadioStations(); final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
final showFavPodcasts = await SettingsService.getShowFavoritePodcasts();
// Load audiobook home row settings // Load audiobook home row settings
final showContinueAudiobooks = await SettingsService.getShowContinueListeningAudiobooks(); final showContinueAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
@@ -115,6 +117,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoriteTracks = showFavTracks; _showFavoriteTracks = showFavTracks;
_showFavoritePlaylists = showFavPlaylists; _showFavoritePlaylists = showFavPlaylists;
_showFavoriteRadioStations = showFavRadio; _showFavoriteRadioStations = showFavRadio;
_showFavoritePodcasts = showFavPodcasts;
_showContinueListeningAudiobooks = showContinueAudiobooks; _showContinueListeningAudiobooks = showContinueAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks; _showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries; _showDiscoverSeries = showDiscSeries;
@@ -163,6 +166,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
return {'title': s.favoritePlaylists, 'subtitle': s.showFavoritePlaylists}; return {'title': s.favoritePlaylists, 'subtitle': s.showFavoritePlaylists};
case 'favorite-radio-stations': case 'favorite-radio-stations':
return {'title': s.favoriteRadioStations, 'subtitle': s.showFavoriteRadioStations}; return {'title': s.favoriteRadioStations, 'subtitle': s.showFavoriteRadioStations};
case 'favorite-podcasts':
return {'title': s.favoritePodcasts, 'subtitle': s.showFavoritePodcasts};
default: default:
return {'title': rowId, 'subtitle': ''}; return {'title': rowId, 'subtitle': ''};
} }
@@ -193,6 +198,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
return _showFavoritePlaylists; return _showFavoritePlaylists;
case 'favorite-radio-stations': case 'favorite-radio-stations':
return _showFavoriteRadioStations; return _showFavoriteRadioStations;
case 'favorite-podcasts':
return _showFavoritePodcasts;
default: default:
return false; return false;
} }
@@ -246,6 +253,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoriteRadioStations = value; _showFavoriteRadioStations = value;
SettingsService.setShowFavoriteRadioStations(value); SettingsService.setShowFavoriteRadioStations(value);
break; break;
case 'favorite-podcasts':
_showFavoritePodcasts = value;
SettingsService.setShowFavoritePodcasts(value);
break;
} }
}); });
} }
@@ -673,6 +684,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
Navigator.pop(dialogContext); 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);
},
),
], ],
), ),
); );
+17
View File
@@ -2561,6 +2561,23 @@ class MusicAssistantAPI {
return _eventStreams['media_item_deleted']!.stream; 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 /// Register this device as a player with retry logic
/// CRITICAL: This creates a player config in MA's settings.json /// CRITICAL: This creates a player config in MA's settings.json
/// The server expects: player_id and player_name /// The server expects: player_id and player_name
+23 -1
View File
@@ -37,6 +37,7 @@ class SettingsService {
static const String _keyShowFavoriteTracks = 'show_favorite_tracks'; static const String _keyShowFavoriteTracks = 'show_favorite_tracks';
static const String _keyShowFavoritePlaylists = 'show_favorite_playlists'; static const String _keyShowFavoritePlaylists = 'show_favorite_playlists';
static const String _keyShowFavoriteRadioStations = 'show_favorite_radio_stations'; 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 _keyShowOnlyArtistsWithAlbums = 'show_only_artists_with_albums'; // Library artists filter
static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs
@@ -53,6 +54,7 @@ class SettingsService {
'favorite-tracks', 'favorite-tracks',
'favorite-playlists', 'favorite-playlists',
'favorite-radio-stations', 'favorite-radio-stations',
'favorite-podcasts',
]; ];
// View Mode Settings // View Mode Settings
@@ -517,6 +519,16 @@ class SettingsService {
await prefs.setBool(_keyShowFavoriteRadioStations, show); 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 // Library Artists Filter - show only artists that have albums in library
static Future<bool> getShowOnlyArtistsWithAlbums() async { static Future<bool> getShowOnlyArtistsWithAlbums() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -535,7 +547,17 @@ class SettingsService {
if (json != null) { if (json != null) {
try { try {
final List<dynamic> decoded = jsonDecode(json); 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 (_) { } catch (_) {
return List.from(defaultHomeRowOrder); return List.from(defaultHomeRowOrder);
} }
+27
View File
@@ -79,6 +79,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Progress tracking - uses PositionTracker stream as single source of truth // Progress tracking - uses PositionTracker stream as single source of truth
StreamSubscription<Duration>? _positionSubscription; 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<int> _progressNotifier = ValueNotifier<int>(0);
final ValueNotifier<double?> _seekPositionNotifier = ValueNotifier<double?>(null); 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 // Subscribe to position tracker stream - single source of truth for playback position
_subscribeToPositionTracker(); _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 // Auto-refresh queue when panel is open
_queuePanelController.addStatusListener((status) { _queuePanelController.addStatusListener((status) {
if (status == AnimationStatus.completed) { if (status == AnimationStatus.completed) {
@@ -317,6 +323,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_slideController.dispose(); _slideController.dispose();
_slideOffsetNotifier.dispose(); _slideOffsetNotifier.dispose();
_positionSubscription?.cancel(); _positionSubscription?.cancel();
_queueItemsSubscription?.cancel();
_queueRefreshTimer?.cancel(); _queueRefreshTimer?.cancel();
_volumePrecisionTimer?.cancel(); _volumePrecisionTimer?.cancel();
_progressNotifier.dispose(); _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 { Future<void> _loadQueue() async {
if (_isLoadingQueue) return; if (_isLoadingQueue) return;
+34 -8
View File
@@ -138,15 +138,27 @@ class _QueuePanelState extends State<QueuePanel> {
} }
void _handleDelete(QueueItem item, int index) async { 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(() { setState(() {
_items.removeAt(index); _items.removeAt(index);
}); });
// Call API // Call API with error handling
final playerId = widget.queue?.playerId; final playerId = widget.queue?.playerId;
if (playerId != null) { 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) { if (playerId != null) {
try { try {
await widget.maProvider.api?.queueCommandMoveItem(playerId, item.queueItemId, posShift); 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) { } catch (e) {
debugPrint('QueuePanel: Move API error: $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 { } else {
debugPrint('QueuePanel: playerId is null, cannot move'); debugPrint('QueuePanel: playerId is null, cannot move');
} // Clear pending state
// Allow updates again after a delay for server state to propagate
_pendingReorderTimer = Timer(const Duration(milliseconds: 2000), () {
if (mounted) { if (mounted) {
setState(() { setState(() {
_pendingReorder = false; _pendingReorder = false;
}); });
} }
}); }
} }
} }
+2 -2
View File
@@ -42,8 +42,8 @@ class PlaylistCard extends StatelessWidget {
FadeSlidePageRoute( FadeSlidePageRoute(
child: PlaylistDetailsScreen( child: PlaylistDetailsScreen(
playlist: playlist, playlist: playlist,
provider: playlist.provider, heroTagSuffix: heroTagSuffix,
itemId: playlist.itemId, initialImageUrl: imageUrl,
), ),
), ),
); );
+99 -62
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import 'playlist_card.dart'; import 'playlist_card.dart';
class PlaylistRow extends StatefulWidget { class PlaylistRow extends StatefulWidget {
@@ -30,6 +31,8 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
bool _isLoading = true; bool _isLoading = true;
bool _hasLoaded = false; bool _hasLoaded = false;
static final _logger = DebugLogger();
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
@@ -86,75 +89,109 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
} }
} }
@override Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
Widget build(BuildContext context) { // Only show loading if we have no data at all
super.build(context); if (_playlists.isEmpty && _isLoading) {
return const Center(child: CircularProgressIndicator());
// Don't render if empty and done loading
if (_playlists.isEmpty && !_isLoading) {
return const SizedBox.shrink();
} }
final colorScheme = Theme.of(context).colorScheme; if (_playlists.isEmpty) {
final textTheme = Theme.of(context).textTheme; return Center(
child: Text(
'No playlists found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Calculate card dimensions // Card layout: square artwork + text below (same as AlbumRow)
// Default: 44 for title, remaining for content (artwork + 2 lines of text) // Text area: 8px gap + ~18px title + ~18px owner = ~44px
final rowHeight = widget.rowHeight ?? 237.0; const textAreaHeight = 44.0;
final titleHeight = 44.0; final artworkSize = contentHeight - textAreaHeight;
final contentHeight = rowHeight - titleHeight; final cardWidth = artworkSize; // Card width = artwork width (square)
// Artwork is square, text takes ~50px (8 padding + title + owner) final itemExtent = cardWidth + 12; // width + horizontal margins
final artworkSize = contentHeight - 50;
final cardWidth = artworkSize;
return RepaintBoundary( return ScrollConfiguration(
child: Column( behavior: const _StretchScrollBehavior(),
crossAxisAlignment: CrossAxisAlignment.start, child: ListView.builder(
children: [ clipBehavior: Clip.none,
// Section title scrollDirection: Axis.horizontal,
Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0),
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), itemCount: _playlists.length,
child: Text( itemExtent: itemExtent,
widget.title, cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
style: textTheme.titleMedium?.copyWith( addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
fontWeight: FontWeight.bold, 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,
);
}
} }
+140
View File
@@ -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();
}
}
+205
View File
@@ -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,
);
}
}
+17 -13
View File
@@ -80,19 +80,23 @@ class RadioStationCard extends StatelessWidget {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Radio station name - centered for circular images // Radio station name - fixed height container so image size is consistent
Hero( SizedBox(
tag: HeroTags.radioTitle + (radioStation.uri ?? radioStation.itemId) + suffix, height: 36, // Fixed height for 2 lines of text
child: Material( child: Hero(
color: Colors.transparent, tag: HeroTags.radioTitle + (radioStation.uri ?? radioStation.itemId) + suffix,
child: Text( child: Material(
radioStation.name, color: Colors.transparent,
maxLines: 2, child: Text(
overflow: TextOverflow.ellipsis, radioStation.name,
textAlign: TextAlign.center, maxLines: 2,
style: textTheme.titleSmall?.copyWith( overflow: TextOverflow.ellipsis,
color: colorScheme.onSurface, textAlign: TextAlign.center,
fontWeight: FontWeight.w500, style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
height: 1.15,
),
), ),
), ),
), ),
+96 -63
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import 'radio_station_card.dart'; import 'radio_station_card.dart';
class RadioStationRow extends StatefulWidget { class RadioStationRow extends StatefulWidget {
@@ -30,6 +31,8 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
bool _isLoading = true; bool _isLoading = true;
bool _hasLoaded = false; bool _hasLoaded = false;
static final _logger = DebugLogger();
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
@@ -86,75 +89,105 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
} }
} }
@override Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
Widget build(BuildContext context) { // Only show loading if we have no data at all
super.build(context); if (_radioStations.isEmpty && _isLoading) {
return const Center(child: CircularProgressIndicator());
// Don't render if empty and done loading
if (_radioStations.isEmpty && !_isLoading) {
return const SizedBox.shrink();
} }
final colorScheme = Theme.of(context).colorScheme; if (_radioStations.isEmpty) {
final textTheme = Theme.of(context).textTheme; 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 // Card layout: circle image + name below (same as ArtistRow)
// Default: 44 for title, remaining for content (circular image + name) // Text area: 8px gap + ~36px for 2-line name = ~44px
final rowHeight = widget.rowHeight ?? 207.0; const textAreaHeight = 44.0;
final titleHeight = 44.0; final imageSize = contentHeight - textAreaHeight;
final contentHeight = rowHeight - titleHeight; final cardWidth = imageSize; // Card width = image width (circle)
// Circular artwork, text takes ~40px (8 padding + name lines) final itemExtent = cardWidth + 16; // width + horizontal margins
final artworkSize = contentHeight - 40;
final cardWidth = artworkSize;
return RepaintBoundary( return ScrollConfiguration(
child: Column( behavior: const _StretchScrollBehavior(),
crossAxisAlignment: CrossAxisAlignment.start, child: ListView.builder(
children: [ scrollDirection: Axis.horizontal,
// Section title padding: const EdgeInsets.symmetric(horizontal: 12.0),
Padding( itemCount: _radioStations.length,
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), itemExtent: itemExtent,
child: Text( cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
widget.title, addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
style: textTheme.titleMedium?.copyWith( addRepaintBoundaries: false, // Cards already have RepaintBoundary
fontWeight: FontWeight.bold, 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
View File
@@ -1,7 +1,7 @@
name: ensemble name: ensemble
description: A minimalistic Music Assistant client built with Flutter description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none' publish_to: 'none'
version: 2.8.6-beta+44 version: 2.8.7-beta+45
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'