Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
277020616f | ||
|
|
cd43ad0868 | ||
|
|
98175719bf | ||
|
|
e5274ca72e | ||
|
|
6a78405dc1 | ||
|
|
49e8bbbb30 | ||
|
|
beeebeb996 | ||
|
|
49077b9858 | ||
|
|
9dbcf9971f | ||
|
|
c5bcc509e0 | ||
|
|
2f933da413 | ||
|
|
2fdd3b4286 | ||
|
|
779810674b | ||
|
|
0dc069b071 | ||
|
|
ca7b5d8d35 | ||
|
|
b15de9775f | ||
|
|
5fcfe1d3b3 | ||
|
|
6a8a4972dd | ||
|
|
73a23594cf | ||
|
|
02d7ea12cb | ||
|
|
601dd8ac7a |
@@ -29,6 +29,9 @@ class HeroTags {
|
|||||||
/// Podcast cover hero tag prefix
|
/// 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_';
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../screens/podcast_detail_screen.dart';
|
||||||
|
import '../constants/hero_tags.dart';
|
||||||
|
import '../theme/theme_provider.dart';
|
||||||
|
import '../utils/page_transitions.dart';
|
||||||
|
|
||||||
|
class PodcastCard extends StatelessWidget {
|
||||||
|
final MediaItem podcast;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
/// Image decode size in pixels. Defaults to 256.
|
||||||
|
/// Use smaller values (e.g., 128) for list views, larger for grids.
|
||||||
|
final int? imageCacheSize;
|
||||||
|
|
||||||
|
const PodcastCard({
|
||||||
|
super.key,
|
||||||
|
required this.podcast,
|
||||||
|
this.onTap,
|
||||||
|
this.heroTagSuffix,
|
||||||
|
this.imageCacheSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
// Use provider's getPodcastImageUrl which includes iTunes cache
|
||||||
|
final imageUrl = maProvider.getPodcastImageUrl(podcast, size: 256);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
|
||||||
|
|
||||||
|
// PERF: Use appropriate cache size based on display size
|
||||||
|
final cacheSize = imageCacheSize ?? 256;
|
||||||
|
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap ?? () {
|
||||||
|
// Update adaptive colors immediately on tap
|
||||||
|
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
FadeSlidePageRoute(
|
||||||
|
child: PodcastDetailScreen(
|
||||||
|
podcast: podcast,
|
||||||
|
heroTagSuffix: heroTagSuffix,
|
||||||
|
initialImageUrl: imageUrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Podcast artwork - square with rounded corners
|
||||||
|
AspectRatio(
|
||||||
|
aspectRatio: 1.0,
|
||||||
|
child: Hero(
|
||||||
|
tag: HeroTags.podcastCover + (podcast.uri ?? podcast.itemId) + suffix,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12.0),
|
||||||
|
child: Container(
|
||||||
|
color: colorScheme.surfaceVariant,
|
||||||
|
child: imageUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: cacheSize,
|
||||||
|
memCacheHeight: cacheSize,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (context, url) => const SizedBox(),
|
||||||
|
errorWidget: (context, url, error) => Center(
|
||||||
|
child: Icon(
|
||||||
|
MdiIcons.podcast,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: Icon(
|
||||||
|
MdiIcons.podcast,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Podcast title
|
||||||
|
Hero(
|
||||||
|
tag: HeroTags.podcastTitle + (podcast.uri ?? podcast.itemId) + suffix,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Text(
|
||||||
|
podcast.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textTheme.titleSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Podcast author (if available)
|
||||||
|
Text(
|
||||||
|
_getAuthor(podcast),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract author from podcast metadata
|
||||||
|
String _getAuthor(MediaItem podcast) {
|
||||||
|
final metadata = podcast.metadata;
|
||||||
|
if (metadata == null) return '';
|
||||||
|
|
||||||
|
// Try different metadata fields for author
|
||||||
|
final author = metadata['author'] ??
|
||||||
|
metadata['artist'] ??
|
||||||
|
metadata['owner'] ?? '';
|
||||||
|
return author.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import 'podcast_card.dart';
|
||||||
|
|
||||||
|
class PodcastRow extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final Future<List<MediaItem>> Function() loadPodcasts;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
final double? rowHeight;
|
||||||
|
/// Optional: synchronous getter for cached data (for instant display)
|
||||||
|
final List<MediaItem>? Function()? getCachedPodcasts;
|
||||||
|
|
||||||
|
const PodcastRow({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.loadPodcasts,
|
||||||
|
this.heroTagSuffix,
|
||||||
|
this.rowHeight,
|
||||||
|
this.getCachedPodcasts,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PodcastRow> createState() => _PodcastRowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PodcastRowState extends State<PodcastRow> with AutomaticKeepAliveClientMixin {
|
||||||
|
List<MediaItem> _podcasts = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _hasLoaded = false;
|
||||||
|
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Get cached data synchronously BEFORE first build (no spinner flash)
|
||||||
|
final cached = widget.getCachedPodcasts?.call();
|
||||||
|
if (cached != null && cached.isNotEmpty) {
|
||||||
|
_podcasts = cached;
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
|
_loadPodcasts();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPodcasts() async {
|
||||||
|
if (_hasLoaded) return;
|
||||||
|
_hasLoaded = true;
|
||||||
|
|
||||||
|
// Load fresh data (always update - fresh data may have images that cached data lacks)
|
||||||
|
try {
|
||||||
|
final freshPodcasts = await widget.loadPodcasts();
|
||||||
|
if (mounted && freshPodcasts.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_podcasts = freshPodcasts;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
// Pre-cache images for smooth hero animations
|
||||||
|
_precachePodcastImages(freshPodcasts);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent failure - keep showing cached data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted && _isLoading) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-cache podcast images so hero animations are smooth on first tap
|
||||||
|
void _precachePodcastImages(List<MediaItem> podcasts) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
// Only precache first ~10 visible items to avoid excessive network/memory use
|
||||||
|
final podcastsToCache = podcasts.take(10);
|
||||||
|
|
||||||
|
for (final podcast in podcastsToCache) {
|
||||||
|
// Use getPodcastImageUrl which includes iTunes cache
|
||||||
|
final imageUrl = maProvider.getPodcastImageUrl(podcast, size: 256);
|
||||||
|
if (imageUrl != null) {
|
||||||
|
// Use CachedNetworkImageProvider to warm the cache
|
||||||
|
precacheImage(
|
||||||
|
CachedNetworkImageProvider(imageUrl),
|
||||||
|
context,
|
||||||
|
).catchError((_) {
|
||||||
|
// Silently ignore precache errors
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
|
||||||
|
// Only show loading if we have no data at all
|
||||||
|
if (_podcasts.isEmpty && _isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_podcasts.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No podcasts found',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card layout: square artwork + text below (same as AlbumRow)
|
||||||
|
// Text area: 8px gap + ~18px title + ~18px author = ~44px
|
||||||
|
const textAreaHeight = 44.0;
|
||||||
|
final artworkSize = contentHeight - textAreaHeight;
|
||||||
|
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||||
|
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||||
|
|
||||||
|
return ScrollConfiguration(
|
||||||
|
behavior: const _StretchScrollBehavior(),
|
||||||
|
child: ListView.builder(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
itemCount: _podcasts.length,
|
||||||
|
itemExtent: itemExtent,
|
||||||
|
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||||
|
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final podcast = _podcasts[index];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4.0),
|
||||||
|
child: Container(
|
||||||
|
key: ValueKey(podcast.uri ?? podcast.itemId),
|
||||||
|
width: cardWidth,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||||
|
child: PodcastCard(
|
||||||
|
podcast: podcast,
|
||||||
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
|
imageCacheSize: 256,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
_logger.startBuild('PodcastRow:${widget.title}');
|
||||||
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// Total row height includes title + content (same as AlbumRow)
|
||||||
|
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
|
||||||
|
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||||
|
final contentHeight = totalHeight - titleHeight;
|
||||||
|
|
||||||
|
final result = RepaintBoundary(
|
||||||
|
child: SizedBox(
|
||||||
|
height: totalHeight,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||||
|
child: Text(
|
||||||
|
widget.title,
|
||||||
|
style: textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _buildContent(contentHeight, colorScheme),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_logger.endBuild('PodcastRow:${widget.title}');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect
|
||||||
|
class _StretchScrollBehavior extends ScrollBehavior {
|
||||||
|
const _StretchScrollBehavior();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildOverscrollIndicator(
|
||||||
|
BuildContext context, Widget child, ScrollableDetails details) {
|
||||||
|
return StretchingOverscrollIndicator(
|
||||||
|
axisDirection: details.direction,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,19 +80,23 @@ class RadioStationCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user