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
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_';
+1
View File
@@ -203,6 +203,7 @@
"english": "Englisch",
"german": "Deutsch",
"spanish": "Spanisch",
"french": "Französisch",
"noTracksInPlaylist": "Keine Songs in Wiedergabeliste",
"sortAlphabetically": "Alphabetisch sortieren",
+11
View File
@@ -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",
+1
View File
@@ -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
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,
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),
+4
View File
@@ -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
// ============================================================================
+50 -25
View File
@@ -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,
),
),
);
+123 -1
View File
@@ -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;
}
+140 -96
View File
@@ -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
View File
@@ -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,
),
),
);
+20
View File
@@ -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);
},
),
],
),
);
+17
View File
@@ -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
+23 -1
View File
@@ -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);
}
+27
View File
@@ -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;
+34 -8
View File
@@ -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;
});
}
});
}
}
}
+2 -2
View File
@@ -42,8 +42,8 @@ class PlaylistCard extends StatelessWidget {
FadeSlidePageRoute(
child: PlaylistDetailsScreen(
playlist: playlist,
provider: playlist.provider,
itemId: playlist.itemId,
heroTagSuffix: heroTagSuffix,
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 '../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,
);
}
}
+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),
// 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,
),
),
),
),
+96 -63
View File
@@ -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
View File
@@ -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'