Compare commits

...
37 Commits
Author SHA1 Message Date
Your NameandClaude Opus 4.5 f7378e7a25 Fix favorites not persisting after navigation
- Replace invalid 'music/album' API command with 'music/item_by_uri'
- Use album URI to fetch fresh favorite status on album screen load
- Invalidate home cache when favorite is toggled so home screen
  shows updated status

Fixes issue where favorites would appear in search/library but
not persist on home screen due to stale cached data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 10:23:32 +00:00
Your NameandClaude Opus 4.5 9e045b1bd4 Fix favorites status not persisting after navigation
Fetch fresh album details on screen load to get current favorite
status from MA, instead of relying on potentially stale cached data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 10:04:12 +00:00
Your NameandClaude Opus 4.5 7049128f01 Fix connection race condition on reconnect
- Add _isDisposed flag to prevent reconnects after API disposal
- Check if connection already in progress before reconnecting
- Check if already connected/authenticated before attempting reconnect
- Properly dispose old API when creating new connection
- Complete pending connection futures on dispose to prevent leaks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 09:36:46 +00:00
Your NameandClaude Opus 4.5 e5dc9cbf89 Add note about local playback on MA beta 20+
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 09:10:54 +00:00
Your NameandClaude Opus 4.5 4f2e4cc6c0 Fix favorites API to use correct MA commands
- Use music/favorites/add_item with URI format for adding
- Use music/favorites/remove_item with media_type and library_item_id for removing
- Find actual provider from provider_mappings for adding (not "library")
- Extract numeric library_item_id for removal operations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 08:23:32 +00:00
Your NameandClaude Opus 4.5 13e933484c Fix favorites: use add/remove commands instead of toggle
- The 'music/favorites/toggle' command doesn't exist in MA 2.7.0b19
- Now uses 'music/favorites/add' and 'music/favorites/remove' based on state
- Keep button background grey when selected (only icon changes to filled red heart)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 08:05:58 +00:00
Your NameandClaude Opus 4.5 533993ee4e Fix album screen queue and favorites buttons
Add to Queue button:
- Now shows device selector like "Play on" button
- User can choose which player to add album to

Favorites button:
- Fixed visual state - icon now visible when selected
- Use FilledButton.tonal for consistency with other buttons
- Use provider_mappings for correct API call
- Use API return value for accurate state
- Show error snackbar if API call fails

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 07:42:52 +00:00
Your NameandClaude Opus 4.5 1e0321bb5e Move controls section up by half play button height
Adds 36px (half of 72px play button) to move controls
further from bottom nav bar while keeping track info centered

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 07:23:52 +00:00
Your NameandClaude Opus 4.5 e8d90689de Use safe area and proper calculation for controls position
- Account for bottom safe area (notch/gesture bar)
- Calculate controls section height properly (222px total)
- Add 24px extra padding from bottom
- Track info remains centered between art and controls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 07:11:00 +00:00
Your NameandClaude Opus 4.5 69a770d548 Fix volume slider visibility - increase bottom margin
Progress bar now at 290px from bottom to fit:
- Progress bar + time labels
- Playback controls
- Volume slider with padding

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 07:06:16 +00:00
Your NameandClaude Opus 4.5 c7d6de4c9c Center track info between album art and progress bar
- Calculate total height of track info block (title + artist + album)
- Position progress bar at fixed distance from bottom (220px)
- Center track info vertically in space between art and progress bar
- Maintains proper spacing between title, artist, and album name

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 06:53:58 +00:00
Your NameandClaude Opus 4.5 d4611b1e2f Increase touch target area for progress and volume sliders
- Wrap both sliders in SizedBox with height: 48 for larger tap area
- Increase overlay radius from 14/16 to 20 for better visual feedback
- Makes it easier to tap and drag on touch screens

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 06:52:05 +00:00
Your NameandClaude Opus 4.5 4dbcd44705 Simplify metadata to Last.fm (descriptions) + Deezer (art)
- Remove Fanart.tv API integration entirely (wasn't working reliably)
- Simplify MetadataService to use only Deezer for artist images
- Remove Fanart.tv settings from UI and service
- Add missing CachedNetworkImage import in new_library_screen
- Update settings description to clarify Deezer is automatic

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 06:35:26 +00:00
Your NameandClaude Opus 4.5 c20745e207 fix: Add artist image fallback to ALL screens
Created ArtistAvatar widget that handles fallback automatically:
- Tries Music Assistant first
- Falls back to Deezer API (free, no key)
- Supports Hero tags for transitions
- Caches results

Updated screens to use ArtistAvatar:
- artist_details_screen.dart - full async image loading with fallback
- library_artists_screen.dart - uses ArtistAvatar widget
- new_library_screen.dart - uses ArtistAvatar with Hero support
- search_screen.dart - uses ArtistAvatar widget

Added getArtistImageUrlWithFallback() to MusicAssistantProvider
for screens that need manual async loading.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 06:27:48 +00:00
Your NameandClaude Opus 4.5 ceee3f667a feat: Add Deezer as primary artist image source (no API key needed)
Deezer API is:
- Completely free, no API key required
- Excellent coverage (has Kevin Ayers and many others)
- Returns high-quality images (1000x1000)

New fallback chain:
1. Deezer (free, best coverage)
2. Fanart.tv (free key, high quality when available)
3. TheAudioDB (optional)

This should fix missing artist images without requiring any API keys.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 06:10:08 +00:00
Your Name 9fe01654cb debug: Add logging to trace artist image fallback 2025-12-10 06:08:28 +00:00
Your NameandClaude Opus 4.5 a987320042 feat: Add Fanart.tv support for artist images
Changed fallback image fetching strategy:
- Added Fanart.tv API support (free registration required)
- Uses MusicBrainz to lookup artist MBID (completely free, no key)
- Fanart.tv provides high-quality artist thumbnails and backgrounds
- Removed Last.fm image fetching (deprecated, only returns blank images)
- TheAudioDB remains as fallback option

Settings:
- Added Fanart.tv API key field in settings (marked as recommended)
- Updated description to mention artist images
- Reordered fields: Last.fm (biographies), Fanart.tv (images), TheAudioDB

To get artist images for artists like Kevin Ayers:
1. Register free at fanart.tv
2. Get your personal API key
3. Enter it in Settings > Metadata APIs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 05:44:08 +00:00
Your NameandClaude Opus 4.5 393c27b937 fix: Preserve home screen data on app resume + add artist image fallback
Issue 1: Home screen rows reloading on app resume
- Changed disconnected state to only clear detail caches, not home screen data
- Added stable ValueKeys to AlbumRow/ArtistRow widgets to prevent recreation
- Home row data now persists across connection state changes

Issue 2: Missing artist artwork (e.g., Kevin Ayers)
- Added fallback image fetching from Last.fm and TheAudioDB APIs
- ArtistCard now fetches external images when Music Assistant has none
- Images are cached in MetadataService to avoid repeated API calls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 05:30:29 +00:00
Your NameandClaude Opus 4.5 829382cd68 fix: Eliminate flash during player swipe transitions
Root cause: Race condition where peek content was cleared before
the new player's content was ready to display.

Solution: Implement proper crossfade transition:
- Set _inTransition=true BEFORE animation starts
- Keep peek content visible at center position during transition
- Wait for new player's image to be cached before completing
- Only then hide peek and show main content

Changes:
- Add _precacheAndTransition() to ensure image is ready
- Add _completeTransition() for clean state cleanup
- Update peek position logic to show at center during transition
- Fix condition for showing peek content

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 21:00:30 +00:00
Your NameandClaude Opus 4.5 9720bf59f7 Fix remaining DebugLogger reference
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 20:23:08 +00:00
Your NameandClaude Opus 4.5 ebb069245a Fix peek content not showing during swipe and add image precaching
- Move _updatePeekPlayer logic inside setState to trigger UI rebuild
  when peek player changes during drag (fixes slow swipe not showing
  peek content)
- Add image precaching in _preloadPlayerTrack so artwork is ready
  instantly when swiping
- Await preload completion to ensure track data is ready before
  user can interact
- Show device icon for non-playing peek players instead of grey
  music note
- Remove debug logging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 20:15:55 +00:00
Your NameandClaude Opus 4.5 f655be1fb6 fix: Keep peek visible during non-playing player transitions
When switching to a non-playing player, keep slideOffset at 1.0 and
peekPlayer set so the DeviceSelectorBar can show the peek content
(new player name) at center position. Wait two frames before clearing
to ensure the DeviceSelectorBar has fully rendered, preventing any
flash of stale content from async updates.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 20:02:05 +00:00
Your NameandClaude Opus 4.5 9d77379584 fix: Keep peekPlayer during transition to prevent grey flash
The bug was that onSwitch callback cleared _peekPlayer immediately after
selectPlayer(), but _animateCommit needed it to remain set so that:

1. DeviceSelectorBar could show peek content during transition
2. The delayed cleanup (for non-playing players) could work properly

Now the callback only calls selectPlayer() and lets _animateCommit handle
all cleanup. This ensures peek content remains visible until the
transition is complete.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 19:34:18 +00:00
Your NameandClaude Opus 4.5 79520aefc3 debug: Add logging to trace player switch transition
Add detailed logging to understand the exact sequence of events during
player switching, particularly when going from a playing player to a
non-playing player. This will help identify the source of the grey flash.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 19:20:53 +00:00
Your NameandClaude Opus 4.5 e50c7d6cdc fix: Delay peek state cleanup when switching to non-playing player
Add 50ms delay before clearing peek state when switching to a player
without an active track. This ensures the DeviceSelectorBar has time
to render before the peek content is removed, preventing a flash of
grey placeholder during the transition.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 19:06:15 +00:00
Your NameandClaude Opus 4.5 6940c6a51f fix: Clear track when switching to non-playing player
Always set _currentTrack from cache, even when null. This prevents
showing stale track info from the previous player when switching to
a player that has no active track.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 18:52:05 +00:00
Your NameandClaude Opus 4.5 3cb534d678 fix: Seamless player switch animation - eliminate grey icon flash
Root cause: Race condition between transition animation and provider update.
When swiping to switch players, the animation would complete before the
provider's currentTrack was updated, causing a brief flash showing the
grey placeholder icon instead of the new player's artwork.

Solution:
1. In MusicAssistantProvider.selectPlayer(): Immediately set _currentTrack
   from the cache when switching players. This ensures the correct track
   data is available synchronously before any rebuild occurs.

2. In ExpandablePlayer._animateCommit(): Simplified transition logic since
   the provider now has the correct data immediately. Removed the fragile
   double-addPostFrameCallback approach that was trying to wait for the
   provider to update asynchronously.

The fix leverages the existing preloaded track cache for adjacent players,
which already contains the correct track info and artwork URLs. By using
this cached data immediately during player selection, the UI shows the
correct content without any async delay.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 18:41:15 +00:00
Your NameandClaude Opus 4.5 601ca67a21 fix(mini-player): use cached peek data during transition
Instead of generic transition cache, keep showing the actual peek content
(with _peekTrack and _peekImageUrl) at center during transition. This
preserves the track info that was visible during the swipe.

- Removed unused _transitionTrack/_transitionImageUrl variables
- _buildPeekContent now uses _peekTrack directly for track info
- Peek data not cleared until after provider updates (2 frame delay)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 17:55:12 +00:00
Your NameandClaude Opus 4.5 e9ebc3e3c3 fix(mini-player): properly hide main content during transition
Added _inTransition flag to control visibility during player switch:
- When transitioning, hide main album art/title/artist with Opacity(0)
- Show peek content at center position during transition
- Peek content uses cached transition data (track/image)
- Main content reappears after 2 frames when provider has updated

This eliminates the flash of grey/empty content during player switches.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 17:31:43 +00:00
Your NameandClaude Opus 4.5 6f4475a28d fix(mini-player): prevent flash of empty content during player switch
When swiping to switch players, there was a brief moment where the
provider hadn't updated with the new player's track info, causing a
flash of grey album art and missing track info.

Added transition cache that holds the incoming player's track/image
data until the provider catches up. The cache is cleared once the
provider has the new track data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 17:18:04 +00:00
Your NameandClaude Opus 4.5 49beea769b fix(mini-player): remove duplicate slide animation on swipe commit
The two-phase animation (slide out + slide in from opposite) was causing
a double animation effect. Simplified to just complete the slide out and
then instantly show the new player content at center position.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 16:39:42 +00:00
Your NameandClaude Opus 4.5 d06761124f feat(mini-player): smooth finger-following swipe to change player
Replace velocity-only swipe detection with real-time finger-tracking:
- Content now follows user's finger as they drag horizontally
- Peek player content slides in from the edge during drag
- Smooth spring-back animation when swipe is cancelled
- Commit animation completes the transition when threshold exceeded
- Works for both mini player with track and device selector bar

Technical changes:
- Add onHorizontalDragUpdate handler for real-time tracking
- Track peek player for preview during drag
- New _buildPeekContent() method for rendering incoming player
- Configurable thresholds: 30% width or 500px/s velocity to commit
- easeOutBack curve for satisfying snap-back feel

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 16:25:16 +00:00
Your Name eff99492fa chore: Bump version to 1.6.1 2025-12-09 08:59:03 +00:00
Your NameandClaude Opus 4.5 9dafc53670 Handle builtin_player API removal in MA 2.7.0b20+
- Detect 'Invalid command: builtin_player/register' error
- Mark builtin_player as unavailable instead of crashing
- Skip state reporting when builtin_player not available
- App continues to work with other players (Chromecast, squeezelite, etc)

Note: MA 2.7.0b20+ replaced builtin_player with Sendspin (WebRTC-based).
Full Sendspin integration would require implementing WebRTC in the app.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 06:20:39 +00:00
Your NameandClaude Opus 4.5 bfa6374657 Fix player registration race condition with auth
- Remove premature player registration from _initializeLocalPlayback()
- Player registration now only happens in _initializeAfterConnection()
- This ensures auth is complete before any player API calls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 06:05:09 +00:00
Your NameandClaude Opus 4.5 8b5fbb12b7 Fix double connection race condition on app startup
- Remove duplicate connectToServer call from main.dart
- Provider._initialize() already handles connection with auth
- main.dart now waits for provider connection instead of racing it
- Fixes auth state check failing due to stale API reference

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 05:54:44 +00:00
Your NameandClaude Opus 4.5 d62cf28530 Fix auth state handling for MA beta 22+
- Block non-auth commands when server requires auth but not yet authenticated
- Properly wait for authenticated state before initializing connection
- Fixes issue where app tried to fetch data before authentication completed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 05:45:51 +00:00
18 changed files with 1329 additions and 370 deletions
+2
View File
@@ -87,6 +87,8 @@ Ensemble supports multiple authentication methods:
- Network connectivity to your Music Assistant server
- Android device (Android 5.0+)
> **Note:** Local device playback (streaming to your phone) is not currently supported on Music Assistant beta 20+. This is due to MA replacing the built-in player with Sendspin. Support will be added once Sendspin is more stable. Remote control of other players works fine.
## About Music Assistant
Ensemble is a mobile client for [Music Assistant](https://music-assistant.io/), an open-source music library manager that integrates with various music sources and streaming providers.
+19 -18
View File
@@ -9,6 +9,7 @@ import 'services/settings_service.dart';
import 'services/audio/massiv_audio_handler.dart';
import 'services/auth/auth_manager.dart';
import 'services/debug_logger.dart';
import 'services/music_assistant_api.dart' show MAConnectionState;
import 'theme/theme_provider.dart';
import 'theme/app_theme.dart';
import 'theme/system_theme_helper.dart';
@@ -267,26 +268,26 @@ class _AppStartupState extends State<AppStartup> {
final provider = context.read<MusicAssistantProvider>();
// Only attempt connection if not already connected
if (!provider.isConnected) {
_logger.log('🚀 AppStartup: Auto-connecting to saved server: $serverUrl');
try {
// Restore auth credentials before connecting (critical for Authelia)
final savedCredentials = await SettingsService.getAuthCredentials();
if (savedCredentials != null) {
_logger.log('🚀 AppStartup: Restoring saved auth credentials');
provider.authManager.deserializeCredentials(savedCredentials);
}
// Connection is handled by MusicAssistantProvider._initialize()
// Just wait for it to complete or timeout
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
await provider.connectToServer(serverUrl);
_logger.log('🚀 AppStartup: Auto-connection successful');
} catch (e) {
_logger.log('🚀 AppStartup: Auto-connection failed: $e');
// Connection failed, but still show home screen
// User can manually reconnect from there
// Give the provider time to connect (it restores credentials and connects in _initialize)
// Check connection state periodically
for (var i = 0; i < 20; i++) {
await Future.delayed(const Duration(milliseconds: 250));
if (provider.isConnected) {
_logger.log('🚀 AppStartup: Connection established');
break;
}
} else {
_logger.log('🚀 AppStartup: Already connected');
if (provider.connectionState == MAConnectionState.error) {
_logger.log('🚀 AppStartup: Connection failed with error');
break;
}
}
if (!provider.isConnected) {
_logger.log('🚀 AppStartup: Connection still pending or failed');
}
if (mounted) {
+90 -10
View File
@@ -1,5 +1,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
import '../models/player.dart';
import '../services/music_assistant_api.dart';
@@ -10,6 +12,7 @@ import '../services/auth/auth_manager.dart';
import '../services/device_id_service.dart';
import '../services/cache_service.dart';
import '../services/local_player_service.dart';
import '../services/metadata_service.dart';
import '../constants/timings.dart';
import '../main.dart' show audioHandler;
@@ -47,6 +50,7 @@ class MusicAssistantProvider with ChangeNotifier {
// Local player state
bool _isLocalPlayerPowered = true;
bool _builtinPlayerAvailable = true; // False on MA 2.7.0b20+ (uses Sendspin instead)
StreamSubscription? _localPlayerEventSubscription;
StreamSubscription? _playerUpdatedEventSubscription;
Timer? _localPlayerStateReportTimer;
@@ -137,7 +141,8 @@ class MusicAssistantProvider with ChangeNotifier {
_serverUrl = serverUrl;
await SettingsService.setServerUrl(serverUrl);
await _api?.disconnect();
// Dispose the old API to stop any pending reconnects
_api?.dispose();
_api = MusicAssistantAPI(serverUrl, _authManager);
@@ -157,17 +162,23 @@ class MusicAssistantProvider with ChangeNotifier {
notifyListeners();
return;
}
// After authentication succeeds, authenticated state will trigger initialization
// Don't call _initializeAfterConnection() here - wait for authenticated state
return;
}
// No auth required, initialize immediately
await _initializeAfterConnection();
} else if (state == MAConnectionState.authenticated) {
_logger.log('✅ MA authentication successful');
// Now safe to initialize since we're authenticated
await _initializeAfterConnection();
} else if (state == MAConnectionState.disconnected) {
_availablePlayers = [];
_selectedPlayer = null;
_cacheService.clearAll();
// Only clear detail caches on disconnect, NOT home screen caches
// Home screen data should persist across reconnects for better UX
_cacheService.clearAllDetailCaches();
}
},
onError: (error) {
@@ -364,9 +375,8 @@ class MusicAssistantProvider with ChangeNotifier {
previousTrackSelectedPlayer();
};
if (isConnected) {
await _registerLocalPlayer();
}
// Player registration is now handled in _initializeAfterConnection()
// which runs after authentication completes (when auth is required)
}
Future<bool> _tryAdoptGhostPlayer() async {
@@ -439,7 +449,20 @@ class MusicAssistantProvider with ChangeNotifier {
}
_registrationInProgress = null;
} catch (e) {
_logger.log('❌ CRITICAL: Player registration failed: $e');
// Check if this is because builtin_player API is not available (MA 2.7.0b20+)
final errorStr = e.toString();
if (errorStr.contains('Invalid command') && errorStr.contains('builtin_player')) {
_logger.log('⚠️ Builtin player API not available (MA 2.7.0b20+ uses Sendspin)');
_logger.log('ℹ️ Local player registration skipped - use other players (Chromecast, etc)');
_builtinPlayerAvailable = false;
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.complete();
}
_registrationInProgress = null;
return; // Non-fatal, continue without local player
}
_logger.log('❌ Player registration failed: $e');
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.completeError(e);
}
@@ -461,6 +484,7 @@ class MusicAssistantProvider with ChangeNotifier {
Future<void> _reportLocalPlayerState() async {
if (_api == null) return;
if (!_builtinPlayerAvailable) return; // Skip on MA 2.7.0b20+ (no builtin_player API)
final playerId = await SettingsService.getBuiltinPlayerId();
if (playerId == null) return;
@@ -1074,7 +1098,8 @@ class MusicAssistantProvider with ChangeNotifier {
selectPlayer(playerToSelect);
}
_preloadAdjacentPlayers(preloadAll: true);
// Await preload so track data and images are ready for swipe gestures
await _preloadAdjacentPlayers(preloadAll: true);
} catch (e) {
ErrorHandler.logError('Load and select players', e);
}
@@ -1085,6 +1110,13 @@ class MusicAssistantProvider with ChangeNotifier {
SettingsService.setLastSelectedPlayerId(player.playerId);
// Immediately set currentTrack from cache to avoid flash during player switch
// This ensures the UI shows the correct track info immediately, before
// the async _updatePlayerState() completes.
// IMPORTANT: Always set from cache, even if null - this prevents showing
// stale track info when switching to a non-playing player.
_currentTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
_startPlayerStatePolling();
_preloadAdjacentPlayers();
@@ -1122,9 +1154,10 @@ class MusicAssistantProvider with ChangeNotifier {
if (prevIndex != currentIndex) playersToPreload.add(players[prevIndex]);
if (nextIndex != currentIndex) playersToPreload.add(players[nextIndex]);
for (final player in playersToPreload) {
_preloadPlayerTrack(player);
}
// Await all preloads so images are ready for next swipe
await Future.wait(
playersToPreload.map((player) => _preloadPlayerTrack(player)),
);
}
Future<void> _preloadPlayerTrack(Player player) async {
@@ -1154,6 +1187,12 @@ class MusicAssistantProvider with ChangeNotifier {
} else {
_cacheService.setCachedTrackForPlayer(player.playerId, track);
_logger.log('🔍 Preload ${player.name}: CACHED track "${track.name}"');
// Also precache the image so it's ready for swipe preview
final imageUrl = getImageUrl(track, size: 512);
if (imageUrl != null) {
_precacheImage(imageUrl);
}
}
} else {
_logger.log('🔍 Preload ${player.name}: NO TRACK - queue empty');
@@ -1167,6 +1206,34 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Precache an image URL so it loads instantly when displayed
Future<void> _precacheImage(String url) async {
try {
final imageProvider = CachedNetworkImageProvider(url);
final imageStream = imageProvider.resolve(const ImageConfiguration());
final completer = Completer<void>();
late ImageStreamListener listener;
listener = ImageStreamListener(
(ImageInfo info, bool synchronousCall) {
if (!completer.isCompleted) completer.complete();
imageStream.removeListener(listener);
},
onError: (exception, stackTrace) {
if (!completer.isCompleted) completer.completeError(exception);
imageStream.removeListener(listener);
},
);
imageStream.addListener(listener);
await completer.future.timeout(const Duration(seconds: 5), onTimeout: () {
imageStream.removeListener(listener);
});
} catch (e) {
// Silently ignore precache errors - image will load on demand
}
}
Future<void> preloadAllPlayerTracks() async {
if (_api == null) return;
@@ -1435,6 +1502,19 @@ class MusicAssistantProvider with ChangeNotifier {
return _api?.getImageUrl(item, size: size);
}
/// Get artist image URL with fallback to external sources (Deezer, Fanart.tv)
/// Returns a Future since fallback requires async API calls
Future<String?> getArtistImageUrlWithFallback(Artist artist, {int size = 256}) async {
// Try Music Assistant first
final maUrl = _api?.getImageUrl(artist, size: size);
if (maUrl != null) {
return maUrl;
}
// Fall back to external sources (Deezer, Fanart.tv, etc.)
return MetadataService.getArtistImageUrl(artist.name);
}
// ============================================================================
// PLAYER AND QUEUE MANAGEMENT
// ============================================================================
+169 -39
View File
@@ -45,6 +45,30 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
_loadTracks();
_extractColors();
_loadAlbumDescription();
_refreshFavoriteStatus();
}
Future<void> _refreshFavoriteStatus() async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
// Need a URI to fetch fresh album data
final albumUri = widget.album.uri;
if (albumUri == null || albumUri.isEmpty) {
_logger.log('Cannot refresh favorite status: album has no URI');
return;
}
try {
final freshAlbum = await maProvider.api!.getAlbumByUri(albumUri);
if (freshAlbum != null && mounted) {
setState(() {
_isFavorite = freshAlbum.favorite ?? false;
});
}
} catch (e) {
_logger.log('Error refreshing favorite status: $e');
}
}
Future<void> _extractColors() async {
@@ -74,19 +98,63 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (maProvider.api == null) return;
try {
// Toggle the state locally first
final newState = !_isFavorite;
await maProvider.api!.toggleFavorite(
'album',
widget.album.itemId,
widget.album.provider,
);
if (newState) {
// For adding: use the actual provider and itemId from provider_mappings
String actualProvider = widget.album.provider;
String actualItemId = widget.album.itemId;
if (widget.album.providerMappings != null && widget.album.providerMappings!.isNotEmpty) {
// Find a non-library provider mapping (e.g., spotify, qobuz, etc.)
final mapping = widget.album.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => widget.album.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => widget.album.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
actualItemId = mapping.itemId;
}
_logger.log('Adding to favorites: provider=$actualProvider, itemId=$actualItemId');
await maProvider.api!.addToFavorites('album', actualItemId, actualProvider);
} else {
// For removing: need the library_item_id (numeric)
// When provider is "library", itemId is the library ID as a string
// Otherwise, we need to find it from provider_mappings
int? libraryItemId;
if (widget.album.provider == 'library') {
libraryItemId = int.tryParse(widget.album.itemId);
} else if (widget.album.providerMappings != null) {
// Find the library mapping
final libraryMapping = widget.album.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.album.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
_logger.log('Error: Could not determine library_item_id for removal');
throw Exception('Could not determine library ID for this album');
}
_logger.log('Removing from favorites: libraryItemId=$libraryItemId');
await maProvider.api!.removeFromFavorites('album', libraryItemId);
}
setState(() {
_isFavorite = newState;
});
// Invalidate home cache so the home screen shows updated favorite status
maProvider.invalidateHomeCache();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -99,6 +167,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
} catch (e) {
_logger.log('Error toggling favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update favorite: $e'),
duration: const Duration(seconds: 2),
),
);
}
}
}
@@ -165,34 +241,84 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
}
Future<void> _addAlbumToQueue() async {
void _addAlbumToQueue() {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
try {
final player = maProvider.selectedPlayer;
if (player == null) {
_showError('No player selected');
return;
}
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
_logger.log('Adding album to queue on ${player.name}');
// Add all tracks to queue without clearing
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0, clearQueue: false);
_logger.log('Album added to queue on ${player.name}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Album added to queue'),
duration: Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding album to queue: $e');
_showError('Failed to add album to queue: $e');
}
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Text(
'Add album to queue on...',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
if (players.isEmpty)
const Padding(
padding: EdgeInsets.all(32.0),
child: Text('No players available'),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, playerIndex) {
final player = players[playerIndex];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
try {
_logger.log('Adding album to queue on ${player.name}');
await maProvider.playTracks(
player.playerId,
_tracks,
startIndex: 0,
clearQueue: false,
);
_logger.log('Album added to queue on ${player.name}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Album added to queue'),
duration: Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding album to queue: $e');
_showError('Failed to add album to queue: $e');
}
},
);
},
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
// Show mini player again when sheet closes
GlobalPlayerOverlay.showPlayer();
});
}
void _addTrackToQueue(BuildContext context, int index) {
@@ -523,18 +649,22 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
const SizedBox(width: 12),
// Favorite Button
Container(
SizedBox(
height: 50,
width: 50,
decoration: BoxDecoration(
color: _isFavorite ? colorScheme.errorContainer : colorScheme.surfaceVariant,
shape: BoxShape.circle,
),
child: IconButton(
child: FilledButton.tonal(
onPressed: _toggleFavorite,
icon: Icon(
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25), // Circular
),
),
child: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
color: _isFavorite ? colorScheme.error : colorScheme.onSurfaceVariant,
color: _isFavorite
? colorScheme.error
: colorScheme.onSurfaceVariant,
),
),
),
+17 -5
View File
@@ -35,6 +35,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
ColorScheme? _darkColorScheme;
bool _isDescriptionExpanded = false;
String? _artistDescription;
String? _artistImageUrl;
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@@ -42,16 +43,26 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
void initState() {
super.initState();
_loadArtistAlbums();
_extractColors();
_loadArtistImage();
_loadArtistDescription();
}
Future<void> _extractColors() async {
Future<void> _loadArtistImage() async {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(widget.artist, size: 512);
if (imageUrl == null) return;
// Get image URL with fallback to Deezer/Fanart.tv
final imageUrl = await maProvider.getArtistImageUrlWithFallback(widget.artist, size: 512);
if (mounted && imageUrl != null) {
setState(() {
_artistImageUrl = imageUrl;
});
// Extract colors after we have the image
_extractColors(imageUrl);
}
}
Future<void> _extractColors(String imageUrl) async {
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
@@ -104,7 +115,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
final themeProvider = context.watch<ThemeProvider>();
final imageUrl = maProvider.getImageUrl(widget.artist, size: 512);
// Use the loaded image URL (with fallback) instead of directly from MA
final imageUrl = _artistImageUrl;
final useAdaptiveTheme = themeProvider.adaptiveTheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
+6 -11
View File
@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart';
import '../utils/page_transitions.dart';
import '../widgets/common/empty_state.dart';
import '../widgets/artist_avatar.dart';
import 'artist_details_screen.dart';
class LibraryArtistsScreen extends StatelessWidget {
@@ -88,8 +88,6 @@ class LibraryArtistsScreen extends StatelessWidget {
Artist artist, {
Key? key,
}) {
final provider = context.read<MusicAssistantProvider>();
final imageUrl = provider.getImageUrl(artist, size: 128);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
@@ -97,14 +95,11 @@ class LibraryArtistsScreen extends StatelessWidget {
return RepaintBoundary(
child: ListTile(
key: key,
leading: CircleAvatar(
radius: 24,
backgroundColor: colorScheme.surfaceVariant,
backgroundImage: imageUrl != null ? CachedNetworkImageProvider(imageUrl) : null,
child: imageUrl == null
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
leading: ArtistAvatar(
artist: artist,
radius: 24,
imageSize: 128,
),
title: Text(
artist.name,
style: textTheme.titleMedium?.copyWith(
+4
View File
@@ -106,7 +106,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Recently played albums (with caching)
// Stable keys prevent widget recreation on parent rebuilds
AlbumRow(
key: const ValueKey('recent-albums'),
title: 'Recently Played',
loadAlbums: () => provider.getRecentAlbumsWithCache(),
),
@@ -114,6 +116,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
// Discover Artists (with caching)
ArtistRow(
key: const ValueKey('discover-artists'),
title: 'Discover Artists',
loadArtists: () => provider.getDiscoverArtistsWithCache(),
),
@@ -121,6 +124,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
// Discover Albums (with caching)
AlbumRow(
key: const ValueKey('discover-albums'),
title: 'Discover Albums',
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
),
+8 -13
View File
@@ -6,6 +6,7 @@ import '../models/media_item.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_selector.dart';
import '../widgets/album_card.dart';
import '../widgets/artist_avatar.dart';
import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart';
import '../theme/theme_provider.dart';
@@ -208,8 +209,6 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
Artist artist, {
Key? key,
}) {
final provider = context.read<MusicAssistantProvider>();
final imageUrl = provider.getImageUrl(artist, size: 128);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final suffix = '_library';
@@ -217,17 +216,15 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
return RepaintBoundary(
child: ListTile(
key: key,
leading: Hero(
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
child: CircleAvatar(
leading: ArtistAvatar(
artist: artist,
radius: 24,
backgroundColor: colorScheme.surfaceVariant,
backgroundImage: imageUrl != null ? CachedNetworkImageProvider(imageUrl) : null,
child: imageUrl == null
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
: null,
imageSize: 128,
heroTag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
onImageLoaded: (imageUrl) {
// Store for adaptive colors on tap
},
),
),
title: Hero(
tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix,
child: Material(
@@ -244,8 +241,6 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
),
),
onTap: () {
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
FadeSlidePageRoute(
+4 -8
View File
@@ -9,6 +9,7 @@ import '../services/debug_logger.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/common/empty_state.dart';
import '../widgets/common/disconnected_state.dart';
import '../widgets/artist_avatar.dart';
import 'album_details_screen.dart';
import 'artist_details_screen.dart';
@@ -389,18 +390,13 @@ class SearchScreenState extends State<SearchScreen> {
}
Widget _buildArtistTile(Artist artist) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(artist, size: 128);
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
leading: CircleAvatar(
leading: ArtistAvatar(
artist: artist,
radius: 24,
backgroundColor: colorScheme.surfaceVariant,
backgroundImage: imageUrl != null ? CachedNetworkImageProvider(imageUrl) : null,
child: imageUrl == null
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
: null,
imageSize: 128,
),
title: Text(
artist.name,
+2 -2
View File
@@ -317,7 +317,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 8),
Text(
'Add API keys to fetch artist biographies and album descriptions when Music Assistant doesn\'t have them',
'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
@@ -365,7 +365,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
controller: _audioDbApiKeyController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'TheAudioDB API Key (Premium)',
labelText: 'TheAudioDB API Key',
hintText: 'Use "2" for free tier or premium key',
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
+46
View File
@@ -6,6 +6,9 @@ class MetadataService {
// Cache to avoid repeated API calls for the same artist/album
static final Map<String, String> _cache = {};
// Cache for artist images
static final Map<String, String?> _artistImageCache = {};
/// Fetches artist biography/description with fallback chain:
/// 1. Music Assistant metadata (passed in)
/// 2. Last.fm API (if key configured)
@@ -204,8 +207,51 @@ class MetadataService {
return text.trim();
}
/// Fetches artist image URL from Deezer (free, no API key required)
/// Returns the image URL if found, null otherwise
static Future<String?> getArtistImageUrl(String artistName) async {
// Check cache first
final cacheKey = 'artistImage:$artistName';
if (_artistImageCache.containsKey(cacheKey)) {
return _artistImageCache[cacheKey];
}
// Use Deezer API (free, no key, excellent coverage)
try {
final uri = Uri.https(
'api.deezer.com',
'/search/artist',
{
'q': artistName,
'limit': '1',
},
);
final response = await http.get(uri).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final artists = data['data'] as List?;
if (artists != null && artists.isNotEmpty) {
final artist = artists[0];
// Use picture_xl for high quality, fall back to picture_medium
final imageUrl = artist['picture_xl'] ?? artist['picture_medium'] ?? artist['picture'];
_artistImageCache[cacheKey] = imageUrl;
return imageUrl;
}
}
} catch (e) {
print('⚠️ Deezer artist image error: $e');
}
// Cache the null result to avoid repeated failed lookups
_artistImageCache[cacheKey] = null;
return null;
}
/// Clears the metadata cache
static void clearCache() {
_cache.clear();
_artistImageCache.clear();
}
}
+97 -51
View File
@@ -63,15 +63,24 @@ class MusicAssistantAPI {
// Guard to prevent multiple simultaneous connection attempts
Completer<void>? _connectionInProgress;
bool _isDisposed = false;
Future<void> connect() async {
// If already connected, nothing to do
if (_currentState == MAConnectionState.connected) {
// If disposed, don't try to connect
if (_isDisposed) {
_logger.log('Connection: API disposed, skipping connect');
return;
}
// If already connected or authenticated, nothing to do
if (_currentState == MAConnectionState.connected ||
_currentState == MAConnectionState.authenticated) {
_logger.log('Connection: Already connected, skipping');
return;
}
// If connection is in progress, wait for it instead of starting another
if (_connectionInProgress != null) {
if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) {
_logger.log('Connection already in progress, waiting...');
return _connectionInProgress!.future;
}
@@ -312,14 +321,33 @@ class MusicAssistantAPI {
String command, {
Map<String, dynamic>? args,
}) async {
// Allow commands when connected, authenticating, or authenticated
final allowedStates = [
MAConnectionState.connected,
MAConnectionState.authenticating,
MAConnectionState.authenticated,
];
if (!allowedStates.contains(_currentState)) {
throw Exception('Not connected to Music Assistant server');
// Allow auth commands when connected or authenticating
// For all other commands, require authenticated state if auth is required
final isAuthCommand = command == 'auth' || command == 'auth/login';
if (isAuthCommand) {
// Auth commands can be sent when connected or authenticating
final allowedStates = [
MAConnectionState.connected,
MAConnectionState.authenticating,
];
if (!allowedStates.contains(_currentState)) {
throw Exception('Not connected to Music Assistant server');
}
} else {
// Non-auth commands require proper authentication state
if (_authRequired && !_isAuthenticated) {
throw Exception('Not authenticated to Music Assistant server');
}
final allowedStates = [
MAConnectionState.connected,
MAConnectionState.authenticating,
MAConnectionState.authenticated,
];
if (!allowedStates.contains(_currentState)) {
throw Exception('Not connected to Music Assistant server');
}
}
final messageId = _uuid.v4();
@@ -592,13 +620,14 @@ class MusicAssistantAPI {
}
}
Future<Album?> getAlbumDetails(String provider, String itemId) async {
/// Get album details by URI
/// Use the album's uri field (e.g., "library://album/123" or "spotify://album/xyz")
Future<Album?> getAlbumByUri(String uri) async {
try {
final response = await _sendCommand(
'music/album',
'music/item_by_uri',
args: {
'provider': provider,
'item_id': itemId,
'uri': uri,
},
);
@@ -607,7 +636,7 @@ class MusicAssistantAPI {
return Album.fromJson(result as Map<String, dynamic>);
} catch (e) {
_logger.log('Error getting album details: $e');
_logger.log('Error getting album by URI: $e');
return null;
}
}
@@ -730,35 +759,18 @@ class MusicAssistantAPI {
}
// Favorites
/// Toggle favorite status for any media item
Future<bool> toggleFavorite(String mediaType, String itemId, String provider) async {
try {
final response = await _sendCommand(
'music/favorites/toggle',
args: {
'media_type': mediaType,
'item_id': itemId,
'provider': provider,
},
);
final isFavorite = response['result'] as bool? ?? false;
return isFavorite;
} catch (e) {
_logger.log('Error toggling favorite: $e');
return false;
}
}
/// Mark item as favorite
/// Mark item as favorite using URI format
/// The item parameter should be a URI like "spotify://album/6XyFhbFtcRRR9peJ16em4h"
Future<void> addToFavorites(String mediaType, String itemId, String provider) async {
try {
// Build the URI for the item
final uri = '$provider://$mediaType/$itemId';
_logger.log('Adding to favorites: $uri');
await _sendCommand(
'music/favorites/add',
'music/favorites/add_item',
args: {
'media_type': mediaType,
'item_id': itemId,
'provider': provider,
'item': uri,
},
);
} catch (e) {
@@ -768,14 +780,16 @@ class MusicAssistantAPI {
}
/// Remove item from favorites
Future<void> removeFromFavorites(String mediaType, String itemId, String provider) async {
/// Requires the library_item_id (the numeric ID in the MA library)
Future<void> removeFromFavorites(String mediaType, int libraryItemId) async {
try {
_logger.log('Removing from favorites: mediaType=$mediaType, libraryItemId=$libraryItemId');
await _sendCommand(
'music/favorites/remove',
'music/favorites/remove_item',
args: {
'media_type': mediaType,
'item_id': itemId,
'provider': provider,
'library_item_id': libraryItemId,
},
);
} catch (e) {
@@ -1971,13 +1985,39 @@ class MusicAssistantAPI {
}
Future<void> _reconnect() async {
// Don't reconnect if disposed
if (_isDisposed) {
_logger.log('Reconnect: API disposed, skipping');
return;
}
// Don't reconnect if already connected or connecting
if (_currentState == MAConnectionState.connected ||
_currentState == MAConnectionState.authenticated ||
_currentState == MAConnectionState.connecting) {
_logger.log('Reconnect: Already connected/connecting, skipping');
return;
}
// Don't reconnect if another connection is in progress
if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) {
_logger.log('Reconnect: Connection already in progress, skipping');
return;
}
await Future.delayed(Timings.reconnectDelay);
if (_currentState != MAConnectionState.connected) {
try {
await connect();
} catch (e) {
_logger.log('Reconnection failed: $e');
}
// Check again after delay
if (_isDisposed ||
_currentState == MAConnectionState.connected ||
_currentState == MAConnectionState.authenticated) {
return;
}
try {
await connect();
} catch (e) {
_logger.log('Reconnection failed: $e');
}
}
@@ -1990,7 +2030,13 @@ class MusicAssistantAPI {
}
void dispose() {
_isDisposed = true;
_stopHeartbeat();
// Complete any pending connection to prevent hanging futures
if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) {
_connectionInProgress!.completeError(Exception('API disposed'));
}
_connectionInProgress = null;
disconnect();
_connectionStateController.close();
for (final stream in _eventStreams.values) {
+89
View File
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:provider/provider.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/metadata_service.dart';
/// A CircleAvatar that shows artist image with automatic fallback to Deezer/Fanart.tv
class ArtistAvatar extends StatefulWidget {
final Artist artist;
final double radius;
final int imageSize;
final String? heroTag;
final ValueChanged<String?>? onImageLoaded;
const ArtistAvatar({
super.key,
required this.artist,
this.radius = 24,
this.imageSize = 128,
this.heroTag,
this.onImageLoaded,
});
@override
State<ArtistAvatar> createState() => _ArtistAvatarState();
}
class _ArtistAvatarState extends State<ArtistAvatar> {
String? _imageUrl;
bool _triedFallback = false;
@override
void initState() {
super.initState();
_loadImage();
}
Future<void> _loadImage() async {
final provider = context.read<MusicAssistantProvider>();
// Try MA first
final maUrl = provider.getImageUrl(widget.artist, size: widget.imageSize);
if (maUrl != null) {
if (mounted) {
setState(() {
_imageUrl = maUrl;
});
widget.onImageLoaded?.call(maUrl);
}
return;
}
// Fallback to external sources
if (!_triedFallback) {
_triedFallback = true;
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
if (fallbackUrl != null && mounted) {
setState(() {
_imageUrl = fallbackUrl;
});
widget.onImageLoaded?.call(fallbackUrl);
}
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final avatar = CircleAvatar(
radius: widget.radius,
backgroundColor: colorScheme.surfaceVariant,
backgroundImage: _imageUrl != null ? CachedNetworkImageProvider(_imageUrl!) : null,
child: _imageUrl == null
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
: null,
);
if (widget.heroTag != null) {
return Hero(
tag: widget.heroTag!,
child: avatar,
);
}
return avatar;
}
}
+40 -9
View File
@@ -6,8 +6,9 @@ import '../screens/artist_details_screen.dart';
import '../constants/hero_tags.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import '../services/metadata_service.dart';
class ArtistCard extends StatelessWidget {
class ArtistCard extends StatefulWidget {
final Artist artist;
final VoidCallback? onTap;
final String? heroTagSuffix;
@@ -19,26 +20,46 @@ class ArtistCard extends StatelessWidget {
this.heroTagSuffix,
});
@override
State<ArtistCard> createState() => _ArtistCardState();
}
class _ArtistCardState extends State<ArtistCard> {
String? _fallbackImageUrl;
bool _triedFallback = false;
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.api?.getImageUrl(artist, size: 256);
final maImageUrl = maProvider.api?.getImageUrl(widget.artist, size: 256);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
// Use MA image if available, otherwise try fallback
final imageUrl = maImageUrl ?? _fallbackImageUrl;
// Try to fetch fallback image if MA doesn't have one and we haven't tried yet
if (maImageUrl == null && !_triedFallback) {
_triedFallback = true;
print('🎨 No MA image for "${widget.artist.name}", trying fallback...');
_fetchFallbackImage();
} else if (maImageUrl != null && !_triedFallback) {
print('🎨 MA has image for "${widget.artist.name}": $maImageUrl');
}
return RepaintBoundary(
child: GestureDetector(
onTap: onTap ?? () {
onTap: widget.onTap ?? () {
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
FadeSlidePageRoute(
child: ArtistDetailsScreen(
artist: artist,
heroTagSuffix: heroTagSuffix,
artist: widget.artist,
heroTagSuffix: widget.heroTagSuffix,
),
),
);
@@ -48,7 +69,7 @@ class ArtistCard extends StatelessWidget {
children: [
// Artist image - circular
Hero(
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
child: ClipOval(
child: Container(
width: 110,
@@ -73,11 +94,11 @@ class ArtistCard extends StatelessWidget {
const SizedBox(height: 12),
// Artist name
Hero(
tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix,
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
artist.name,
widget.artist.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
@@ -93,4 +114,14 @@ class ArtistCard extends StatelessWidget {
),
);
}
Future<void> _fetchFallbackImage() async {
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
print('🎨 Fallback result for "${widget.artist.name}": $fallbackUrl');
if (fallbackUrl != null && mounted) {
setState(() {
_fallbackImageUrl = fallbackUrl;
});
}
}
}
+612 -154
View File
@@ -7,7 +7,6 @@ import 'package:cached_network_image/cached_network_image.dart';
import '../constants/timings.dart';
import '../providers/music_assistant_provider.dart';
import '../models/player.dart';
import '../services/debug_logger.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import 'animated_icon_button.dart';
@@ -68,10 +67,19 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Track horizontal drag start position
double? _horizontalDragStartX;
// Slide animation for device switching
// Slide animation for device switching - now supports finger-following
late AnimationController _slideController;
double _slideOffset = 0.0; // -1 to 1, negative = sliding left, positive = sliding right
bool _isSliding = false;
bool _isDragging = false; // True while finger is actively dragging
// For peek preview - track which player we'd switch to
dynamic _peekPlayer; // The player that would be selected if swipe commits
String? _peekImageUrl; // Image URL for peek player's current track
// Flag to indicate we're in the middle of a player switch transition
// When true, we hide main content and show peek content at center
bool _inTransition = false;
@override
void initState() {
@@ -100,9 +108,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
reverseCurve: Curves.easeInCubic,
);
// Slide animation for device switching
// Slide animation for device switching - used for snap/spring animations
_slideController = AnimationController(
duration: const Duration(milliseconds: 75),
duration: const Duration(milliseconds: 250),
vsync: this,
);
@@ -239,7 +247,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2);
}
} catch (e) {
DebugLogger().log('Player: Failed to extract album colors');
// Silently ignore color extraction errors
}
}
@@ -281,6 +289,29 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
return maProvider.availablePlayers.where((p) => p.available).toList();
}
/// Get the next or previous player relative to current selection
dynamic _getAdjacentPlayer(MusicAssistantProvider maProvider, {required bool next}) {
final players = _getAvailablePlayersSorted(maProvider);
if (players.length <= 1) return null;
final selectedPlayerId = maProvider.selectedPlayer?.playerId;
final currentIndex = players.indexWhere((p) => p.playerId == selectedPlayerId);
if (currentIndex == -1) return players[0];
int adjacentIndex;
if (next) {
adjacentIndex = currentIndex >= players.length - 1 ? 0 : currentIndex + 1;
} else {
adjacentIndex = currentIndex <= 0 ? players.length - 1 : currentIndex - 1;
}
return players[adjacentIndex];
}
// Cache for peek track data
dynamic _peekTrack;
/// Cycle to the next available player (for swipe gesture)
void _cycleToNextPlayer(MusicAssistantProvider maProvider, {bool reverse = false}) {
final players = _getAvailablePlayersSorted(maProvider);
@@ -312,66 +343,251 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
});
}
/// Animate the slide transition when switching devices
/// direction: -1 = swipe left (content exits left, new enters from right)
/// 1 = swipe right (content exits right, new enters from left)
void _animateSlide(int direction, VoidCallback onComplete) {
/// Handle real-time drag updates for finger-following swipe
void _handleHorizontalDragUpdate(DragUpdateDetails details, MusicAssistantProvider maProvider, double containerWidth) {
if (_isSliding) return;
// Start tracking if not already
if (!_isDragging) {
_isDragging = true;
_peekPlayer = null;
_peekImageUrl = null;
_peekTrack = null;
}
// Calculate normalized offset (-1 to 1 range)
// Negative = dragging left (showing next player from right)
// Positive = dragging right (showing previous player from left)
final delta = details.primaryDelta ?? 0;
final normalizedDelta = delta / containerWidth;
final newSlideOffset = (_slideOffset + normalizedDelta).clamp(-1.0, 1.0);
// Update peek player based on drag direction (must be inside setState to trigger rebuild)
setState(() {
_slideOffset = newSlideOffset;
if (_slideOffset != 0) {
_updatePeekPlayerState(maProvider, _slideOffset);
}
});
}
/// Update peek player state variables (called inside setState)
void _updatePeekPlayerState(MusicAssistantProvider maProvider, double dragDirection) {
// dragDirection < 0 means swiping left (next player)
// dragDirection > 0 means swiping right (previous player)
final isNext = dragDirection < 0;
final newPeekPlayer = _getAdjacentPlayer(maProvider, next: isNext);
if (newPeekPlayer?.playerId != _peekPlayer?.playerId) {
_peekPlayer = newPeekPlayer;
// Get the peek player's current track image if available
if (_peekPlayer != null) {
_peekTrack = maProvider.getCachedTrackForPlayer(_peekPlayer.playerId);
_peekImageUrl = _peekTrack != null ? maProvider.getImageUrl(_peekTrack, size: 512) : null;
} else {
_peekTrack = null;
_peekImageUrl = null;
}
}
}
/// Handle drag end - either commit to next player or snap back
void _handleHorizontalDragEnd(DragEndDetails details, MusicAssistantProvider maProvider) {
if (!_isDragging || _isSliding) {
_isDragging = false;
return;
}
_isDragging = false;
final velocity = details.primaryVelocity ?? 0;
// Thresholds for committing the swipe
const commitThreshold = 0.3; // 30% of width
const velocityThreshold = 500.0; // px/s
final shouldCommit = _slideOffset.abs() > commitThreshold || velocity.abs() > velocityThreshold;
final direction = _slideOffset != 0 ? _slideOffset.sign : (velocity != 0 ? -velocity.sign : 0);
if (shouldCommit && _peekPlayer != null && direction != 0) {
// Commit: animate to full slide, switch player, then reset
// Note: Don't clear _peekPlayer here - let _animateCommit handle cleanup
// so the peek content can remain visible during the transition
_animateCommit(direction.toInt(), () {
HapticFeedback.mediumImpact();
maProvider.selectPlayer(_peekPlayer);
});
} else {
// Cancel: spring back to center
_animateSnapBack();
}
}
/// Animate committing to the next/previous player
void _animateCommit(int direction, VoidCallback onSwitch) {
if (_isSliding) return;
_isSliding = true;
final slideOutTarget = direction.toDouble(); // Where current content goes
final slideInStart = -direction.toDouble(); // Where new content comes from
// Mark transition BEFORE animation - this keeps peek content visible
// and hides main content to prevent any flash
_inTransition = true;
// Use easing curves for smoother feel
const outCurve = Curves.easeInCubic; // Accelerate out
const inCurve = Curves.easeOutCubic; // Decelerate in
final startOffset = _slideOffset;
final targetOffset = direction < 0 ? -1.0 : 1.0;
// Phase 1: Slide out current content
_slideOffset = 0.0;
_slideController.reset();
void animateOut() {
void animateToTarget() {
if (!mounted) return;
final curvedValue = outCurve.transform(_slideController.value);
final curvedValue = Curves.easeOutCubic.transform(_slideController.value);
setState(() {
_slideOffset = slideOutTarget * curvedValue;
_slideOffset = startOffset + (targetOffset - startOffset) * curvedValue;
});
}
void animateIn() {
if (!mounted) return;
final curvedValue = inCurve.transform(_slideController.value);
setState(() {
_slideOffset = slideInStart * (1.0 - curvedValue);
});
}
_slideController.addListener(animateOut);
_slideController.addListener(animateToTarget);
_slideController.duration = const Duration(milliseconds: 150);
_slideController.forward().then((_) {
if (!mounted) return;
_slideController.removeListener(animateOut);
_slideController.removeListener(animateToTarget);
// Switch the data
onComplete();
// Cache the peek data BEFORE switching - we'll use this to crossfade
final cachedPeekPlayer = _peekPlayer;
final cachedPeekTrack = _peekTrack;
final cachedPeekImageUrl = _peekImageUrl;
// Phase 2: Slide in new content
_slideOffset = slideInStart;
_slideController.reset();
_slideController.addListener(animateIn);
// Switch the player - this sets currentTrack from cache in the provider
onSwitch();
_slideController.forward().then((_) {
if (!mounted) return;
_slideController.removeListener(animateIn);
setState(() {
_slideOffset = 0.0;
_isSliding = false;
});
// Get the new track state
final maProvider = context.read<MusicAssistantProvider>();
final newTrack = maProvider.currentTrack;
// Move peek content to center position (slideOffset = 0) while keeping it visible.
// The main content is hidden via _inTransition flag.
// This creates a seamless visual - peek content is already at center.
setState(() {
_slideOffset = 0.0;
_isSliding = false;
// Keep _inTransition = true and peek data intact
});
if (newTrack != null) {
// Switching to a playing player - need to wait for image to be ready
// before showing main content and hiding peek
final newImageUrl = maProvider.getImageUrl(newTrack, size: 512);
if (newImageUrl != null && newImageUrl == cachedPeekImageUrl) {
// Same image - can transition immediately
_completeTransition();
} else if (newImageUrl != null) {
// Different image - precache it first, then transition
_precacheAndTransition(newImageUrl);
} else {
// No image - transition after frame
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _completeTransition();
});
}
} else {
// Switching to non-playing player - DeviceSelectorBar will show
// Wait for it to render, then complete transition
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _completeTransition();
});
});
}
_slideController.duration = const Duration(milliseconds: 250); // Reset default
});
}
/// Precache an image and then complete the transition
void _precacheAndTransition(String imageUrl) {
// Use CachedNetworkImage's cache to ensure image is ready
final imageProvider = CachedNetworkImageProvider(imageUrl);
final imageStream = imageProvider.resolve(const ImageConfiguration());
late ImageStreamListener listener;
listener = ImageStreamListener(
(ImageInfo info, bool synchronousCall) {
imageStream.removeListener(listener);
if (mounted) {
// Image is now in memory cache - safe to transition
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _completeTransition();
});
}
},
onError: (exception, stackTrace) {
imageStream.removeListener(listener);
// Even on error, complete the transition
if (mounted) _completeTransition();
},
);
imageStream.addListener(listener);
// Safety timeout - don't wait forever
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted && _inTransition) {
imageStream.removeListener(listener);
_completeTransition();
}
});
}
/// Complete the transition by hiding peek content and showing main content
void _completeTransition() {
if (!mounted) return;
setState(() {
_inTransition = false;
_peekPlayer = null;
_peekTrack = null;
_peekImageUrl = null;
});
}
/// Animate snapping back to center (cancelled swipe)
void _animateSnapBack() {
if (_isSliding) return;
_isSliding = true;
final startOffset = _slideOffset;
_slideController.reset();
void animateBack() {
if (!mounted) return;
final curvedValue = Curves.easeOutBack.transform(_slideController.value);
setState(() {
_slideOffset = startOffset * (1.0 - curvedValue);
});
}
_slideController.addListener(animateBack);
_slideController.duration = const Duration(milliseconds: 300);
_slideController.forward().then((_) {
if (!mounted) return;
_slideController.removeListener(animateBack);
setState(() {
_slideOffset = 0.0;
_isSliding = false;
_peekPlayer = null;
_peekImageUrl = null;
});
_slideController.duration = const Duration(milliseconds: 250); // Reset default
});
}
/// Legacy method - now calls new commit animation
void _animateSlide(int direction, VoidCallback onComplete) {
_animateCommit(direction, onComplete);
}
@override
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();
@@ -386,6 +602,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
return const SizedBox.shrink();
}
// Get image URL for current track
final imageUrl = currentTrack != null
? maProvider.getImageUrl(currentTrack, size: 512)
: null;
@@ -398,7 +615,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
return AnimatedBuilder(
animation: Listenable.merge([_expandAnimation, _queuePanelAnimation]),
builder: (context, _) {
// If no track is playing, show a compact device selector bar
// If no track is playing, show device selector bar
if (currentTrack == null) {
return _buildDeviceSelectorBar(context, maProvider, selectedPlayer, themeProvider);
}
@@ -457,6 +674,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
bottom: adjustedBottomOffset,
child: DeviceSelectorBar(
selectedPlayer: selectedPlayer,
peekPlayer: _peekPlayer,
hasMultiplePlayers: hasMultiplePlayers,
backgroundColor: backgroundColor,
textColor: textColor,
@@ -464,14 +682,30 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
height: _collapsedHeight,
borderRadius: _collapsedBorderRadius,
slideOffset: _slideOffset,
onHorizontalDragStart: (details) {
_horizontalDragStartX = details.globalPosition.dx;
},
onHorizontalDragUpdate: (details) {
// Check for edge dead zone
final screenWidth = MediaQuery.of(context).size.width;
final startedInDeadZone = _horizontalDragStartX != null &&
(_horizontalDragStartX! > screenWidth - _edgeDeadZone ||
_horizontalDragStartX! < _edgeDeadZone);
if (startedInDeadZone) return;
_handleHorizontalDragUpdate(details, maProvider, width);
},
onHorizontalDragEnd: (details) {
if (details.primaryVelocity != null) {
if (details.primaryVelocity! < -300) {
_cycleToNextPlayer(maProvider);
} else if (details.primaryVelocity! > 300) {
_cycleToNextPlayer(maProvider, reverse: true);
}
}
// Check for edge dead zone
final screenWidth = MediaQuery.of(context).size.width;
final startedInDeadZone = _horizontalDragStartX != null &&
(_horizontalDragStartX! > screenWidth - _edgeDeadZone ||
_horizontalDragStartX! < _edgeDeadZone);
_horizontalDragStartX = null;
if (startedInDeadZone) return;
_handleHorizontalDragEnd(details, maProvider);
},
),
);
@@ -582,8 +816,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final titleLeft = _lerpDouble(collapsedTitleLeft, expandedTitleLeft, t);
final collapsedTitleTop = (_collapsedHeight - 36) / 2; // Centered vertically (adjusted for increased track/artist gap)
final expandedTitleTop = expandedArtTop + expandedArtSize + 28;
final titleTop = _lerpDouble(collapsedTitleTop, expandedTitleTop, t);
final collapsedTitleWidth = screenSize.width - _collapsedArtSize - 150;
final expandedTitleWidth = screenSize.width - (contentPadding * 2);
@@ -603,16 +835,43 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
)..layout(maxWidth: expandedTitleWidth);
final expandedTitleHeight = titlePainter.height;
// Calculate track info block height (title + gap + artist + gap + album)
final titleToArtistGap = 12.0;
final artistToAlbumGap = 8.0;
final artistHeight = 22.0; // Approximate height for 18px font
final albumHeight = currentTrack.album != null ? 20.0 : 0.0; // Album line or nothing
final trackInfoBlockHeight = expandedTitleHeight + titleToArtistGap + artistHeight +
(currentTrack.album != null ? artistToAlbumGap + albumHeight : 0.0);
// Controls section heights (from bottom up):
// - Volume slider: 48px
// - Gap: 40px (was 88-48=40 from volumeTop calculation)
// - Controls row: ~70px (centered at expandedControlsTop)
// - Gap: 64px (from expandedControlsTop = expandedProgressTop + 64)
// - Progress bar + times: ~70px
// Total from bottom edge: ~48 + 40 + 70 + 64 = 222, plus safe area padding
// Position progress bar so controls section is anchored at bottom
final bottomSafeArea = MediaQuery.of(context).padding.bottom;
final controlsSectionHeight = 222.0; // Total height of progress + controls + volume
final playButtonHalfHeight = 36.0; // Half of the 72px play button container
final expandedProgressTop = screenSize.height - bottomSafeArea - controlsSectionHeight - 24 - playButtonHalfHeight;
// Calculate available space between art bottom and progress bar
final artBottom = expandedArtTop + expandedArtSize;
final availableSpace = expandedProgressTop - artBottom;
// Center the track info block in available space
final trackInfoTopMargin = (availableSpace - trackInfoBlockHeight) / 2;
final expandedTitleTop = artBottom + trackInfoTopMargin;
final titleTop = _lerpDouble(collapsedTitleTop, expandedTitleTop, t);
// Artist positioned dynamically based on actual title height
final collapsedArtistTop = collapsedTitleTop + 22; // Increased gap from 18 to 22
final expandedArtistTop = expandedTitleTop + expandedTitleHeight + 12; // Increased from 8 to 12
final expandedArtistTop = expandedTitleTop + expandedTitleHeight + titleToArtistGap;
final artistTop = _lerpDouble(collapsedArtistTop, expandedArtistTop, t);
// Album - subtle, below artist
final expandedAlbumTop = expandedArtistTop + 28; // Increased from 24 to 28
// Progress bar - with generous spacing
final expandedProgressTop = expandedAlbumTop + 106; // Increased from 36 to 106 (+70px)
final expandedAlbumTop = expandedArtistTop + artistHeight + artistToAlbumGap;
// Controls - main row with comfortable touch targets
final collapsedControlsRight = 8.0;
@@ -661,31 +920,41 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
onHorizontalDragStart: (details) {
_horizontalDragStartX = details.globalPosition.dx;
},
onHorizontalDragEnd: (details) {
// Ignore swipes that started near the right edge (Android back gesture zone)
onHorizontalDragUpdate: (details) {
// Only handle in collapsed mode with multiple players
if (isExpanded || !hasMultiplePlayers) return;
// Ignore drags that started in the edge dead zone
final screenWidth = MediaQuery.of(context).size.width;
final startedInDeadZone = _horizontalDragStartX != null &&
_horizontalDragStartX! > screenWidth - _edgeDeadZone;
(_horizontalDragStartX! > screenWidth - _edgeDeadZone ||
_horizontalDragStartX! < _edgeDeadZone);
if (startedInDeadZone) return;
_handleHorizontalDragUpdate(details, maProvider, collapsedWidth);
},
onHorizontalDragEnd: (details) {
// Ignore swipes that started near the edges (Android back gesture zone)
final screenWidth = MediaQuery.of(context).size.width;
final startedInDeadZone = _horizontalDragStartX != null &&
(_horizontalDragStartX! > screenWidth - _edgeDeadZone ||
_horizontalDragStartX! < _edgeDeadZone);
_horizontalDragStartX = null;
if (startedInDeadZone) return;
if (details.primaryVelocity != null) {
if (isExpanded) {
// Expanded mode: swipe to open/close queue
if (isExpanded) {
// Expanded mode: swipe to open/close queue
if (details.primaryVelocity != null) {
if (details.primaryVelocity! < -300 && !isQueuePanelOpen) {
_toggleQueuePanel();
} else if (details.primaryVelocity! > 300 && isQueuePanelOpen) {
_toggleQueuePanel();
}
} else if (hasMultiplePlayers) {
// Collapsed mode: swipe to switch devices
if (details.primaryVelocity! < -300) {
_cycleToNextPlayer(maProvider);
} else if (details.primaryVelocity! > 300) {
_cycleToNextPlayer(maProvider, reverse: true);
}
}
} else if (hasMultiplePlayers) {
// Collapsed mode: use finger-following handler
_handleHorizontalDragEnd(details, maProvider);
}
},
child: Material(
@@ -700,82 +969,115 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
child: Stack(
clipBehavior: Clip.none,
children: [
// Peek player content (shows when dragging OR during transition)
// Show when: actively dragging (slideOffset != 0) OR in transition state
// Must have peek data to render
if (t < 0.1 && _peekPlayer != null && (_slideOffset.abs() > 0.01 || _inTransition))
_buildPeekContent(
maProvider: maProvider,
peekPlayer: _peekPlayer,
peekImageUrl: _peekImageUrl,
slideOffset: _slideOffset,
containerWidth: collapsedWidth,
artSize: _collapsedArtSize,
titleLeft: collapsedTitleLeft,
titleTop: collapsedTitleTop,
titleWidth: collapsedTitleWidth,
artistTop: collapsedArtistTop,
titleFontSize: 16.0,
artistFontSize: 14.0,
textColor: textColor,
colorScheme: colorScheme,
),
// Album art - with slide animation when collapsed
// Hidden during transition to prevent flash (peek content shows instead)
Positioned(
left: artLeft + miniPlayerSlideOffset,
top: artTop,
child: Container(
width: artSize,
height: artSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(artBorderRadius),
boxShadow: t > 0.3
? [
BoxShadow(
color: Colors.black.withOpacity(0.3 * t),
blurRadius: 24 * t,
offset: Offset(0, 8 * t),
),
]
: null,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(artBorderRadius),
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: t > 0.5 ? 1024 : 256,
memCacheHeight: t > 0.5 ? 1024 : 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholderFadeInDuration: Duration.zero,
placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t),
errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t),
)
: _buildPlaceholderArt(colorScheme, t),
child: Opacity(
opacity: _inTransition && t < 0.1 ? 0.0 : 1.0,
child: Container(
width: artSize,
height: artSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(artBorderRadius),
boxShadow: t > 0.3
? [
BoxShadow(
color: Colors.black.withOpacity(0.3 * t),
blurRadius: 24 * t,
offset: Offset(0, 8 * t),
),
]
: null,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(artBorderRadius),
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: t > 0.5 ? 1024 : 256,
memCacheHeight: t > 0.5 ? 1024 : 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholderFadeInDuration: Duration.zero,
placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t),
errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t),
)
: _buildPlaceholderArt(colorScheme, t),
),
),
),
),
// Track title - with slide animation when collapsed
// Hidden during transition to prevent flash
Positioned(
left: titleLeft + miniPlayerSlideOffset,
top: titleTop,
child: SizedBox(
width: titleWidth,
child: Text(
currentTrack.name,
style: TextStyle(
color: textColor,
fontSize: titleFontSize,
fontWeight: t > 0.5 ? FontWeight.w600 : FontWeight.w500,
letterSpacing: t > 0.5 ? -0.5 : 0,
height: 1.2,
child: Opacity(
opacity: _inTransition && t < 0.1 ? 0.0 : 1.0,
child: SizedBox(
width: titleWidth,
child: Text(
currentTrack.name,
style: TextStyle(
color: textColor,
fontSize: titleFontSize,
fontWeight: t > 0.5 ? FontWeight.w600 : FontWeight.w500,
letterSpacing: t > 0.5 ? -0.5 : 0,
height: 1.2,
),
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
maxLines: t > 0.5 ? 2 : 1,
overflow: TextOverflow.ellipsis,
),
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
maxLines: t > 0.5 ? 2 : 1,
overflow: TextOverflow.ellipsis,
),
),
),
// Artist name - with slide animation when collapsed
// Hidden during transition to prevent flash
Positioned(
left: titleLeft + miniPlayerSlideOffset,
top: artistTop,
child: SizedBox(
width: titleWidth,
child: Text(
currentTrack.artistsString,
style: TextStyle(
color: textColor.withOpacity(t > 0.5 ? 0.7 : 0.6),
fontSize: artistFontSize,
fontWeight: t > 0.5 ? FontWeight.w400 : FontWeight.normal,
child: Opacity(
opacity: _inTransition && t < 0.1 ? 0.0 : 1.0,
child: SizedBox(
width: titleWidth,
child: Text(
currentTrack.artistsString,
style: TextStyle(
color: textColor.withOpacity(t > 0.5 ? 0.7 : 0.6),
fontSize: artistFontSize,
fontWeight: t > 0.5 ? FontWeight.w400 : FontWeight.normal,
),
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
@@ -819,37 +1121,40 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final currentProgress = seekPosition ?? elapsedTime.toDouble();
return Column(
children: [
SliderTheme(
data: SliderThemeData(
trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
trackShape: const RoundedRectSliderTrackShape(),
),
child: Slider(
value: currentProgress.clamp(0.0, currentTrack.duration!.inSeconds.toDouble()).toDouble(),
max: currentTrack.duration!.inSeconds.toDouble(),
onChanged: (value) => _seekPositionNotifier.value = value,
onChangeStart: (value) => _seekPositionNotifier.value = value,
onChangeEnd: (value) async {
try {
await maProvider.seek(selectedPlayer.playerId, value.round());
await Future.delayed(const Duration(milliseconds: 200));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error seeking: $e')),
);
SizedBox(
height: 48, // Increase touch target height
child: SliderTheme(
data: SliderThemeData(
trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
trackShape: const RoundedRectSliderTrackShape(),
),
child: Slider(
value: currentProgress.clamp(0.0, currentTrack.duration!.inSeconds.toDouble()).toDouble(),
max: currentTrack.duration!.inSeconds.toDouble(),
onChanged: (value) => _seekPositionNotifier.value = value,
onChangeStart: (value) => _seekPositionNotifier.value = value,
onChangeEnd: (value) async {
try {
await maProvider.seek(selectedPlayer.playerId, value.round());
await Future.delayed(const Duration(milliseconds: 200));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error seeking: $e')),
);
}
} finally {
if (mounted) {
_seekPositionNotifier.value = null;
_seekPosition = null;
}
}
} finally {
if (mounted) {
_seekPositionNotifier.value = null;
_seekPosition = null;
}
}
},
activeColor: primaryColor,
inactiveColor: primaryColor.withOpacity(0.2),
},
activeColor: primaryColor,
inactiveColor: primaryColor.withOpacity(0.2),
),
),
),
Padding(
@@ -1056,6 +1361,159 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
);
}
/// Build the peek player content that slides in from the edge during drag
Widget _buildPeekContent({
required MusicAssistantProvider maProvider,
required dynamic peekPlayer,
required String? peekImageUrl,
required double slideOffset,
required double containerWidth,
required double artSize,
required double titleLeft,
required double titleTop,
required double titleWidth,
required double artistTop,
required double titleFontSize,
required double artistFontSize,
required Color textColor,
required ColorScheme colorScheme,
}) {
// Calculate sliding position
// During transition (_inTransition = true), peek should be at center (offset 0)
// When sliding left (negative offset), peek comes from right
// When sliding right (positive offset), peek comes from left
double peekBaseOffset;
if (_inTransition && slideOffset.abs() < 0.01) {
// During transition with slideOffset at 0, show peek at center
peekBaseOffset = 0.0;
} else {
final isFromRight = slideOffset < 0;
final peekProgress = slideOffset.abs();
// Position calculation:
// From right: starts at containerWidth (off right edge), moves to 0 as progress increases
// From left: starts at -containerWidth (off left edge), moves to 0 as progress increases
peekBaseOffset = isFromRight
? containerWidth * (1 - peekProgress) // Slides in from right
: -containerWidth * (1 - peekProgress); // Slides in from left
}
// Check if peek player has a track - if not, show device info instead
final hasTrack = _peekTrack != null && peekImageUrl != null;
final peekTrackName = hasTrack ? _peekTrack!.name : (peekPlayer?.name ?? 'Unknown');
final peekArtistName = hasTrack ? (_peekTrack!.artistsString ?? '') : 'Swipe to switch device';
return Stack(
children: [
// Peek album art / device icon
Positioned(
left: peekBaseOffset,
top: 0,
child: Container(
width: artSize,
height: artSize,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
),
child: hasTrack
? CachedNetworkImage(
imageUrl: peekImageUrl!,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholderFadeInDuration: Duration.zero,
placeholder: (_, __) => _buildMiniPlaceholderArt(colorScheme),
errorWidget: (_, __, ___) => _buildMiniPlaceholderArt(colorScheme),
)
: _buildDeviceIcon(peekPlayer?.name ?? '', artSize, colorScheme),
),
),
// Peek track title / device name
Positioned(
left: titleLeft + peekBaseOffset,
top: titleTop,
child: SizedBox(
width: titleWidth,
child: Text(
peekTrackName,
style: TextStyle(
color: textColor,
fontSize: titleFontSize,
fontWeight: FontWeight.w500,
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
// Peek artist name / device hint
Positioned(
left: titleLeft + peekBaseOffset,
top: artistTop,
child: SizedBox(
width: titleWidth,
child: Text(
peekArtistName,
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: artistFontSize,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
);
}
/// Build device icon for non-playing peek player
Widget _buildDeviceIcon(String playerName, double size, ColorScheme colorScheme) {
final nameLower = playerName.toLowerCase();
IconData icon;
if (nameLower.contains('phone') || nameLower.contains('ensemble')) {
icon = Icons.phone_android_rounded;
} else if (nameLower.contains('group') || nameLower.contains('all')) {
icon = Icons.speaker_group_rounded;
} else if (nameLower.contains('tv') || nameLower.contains('television')) {
icon = Icons.tv_rounded;
} else if (nameLower.contains('cast') || nameLower.contains('chromecast')) {
icon = Icons.cast_rounded;
} else {
icon = Icons.speaker_rounded;
}
return Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
icon,
color: colorScheme.onSurfaceVariant,
size: size * 0.5,
),
),
);
}
/// Simplified placeholder for mini player peek
Widget _buildMiniPlaceholderArt(ColorScheme colorScheme) {
return Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note_rounded,
color: colorScheme.onSurfaceVariant,
size: 24,
),
);
}
Widget _buildPlaceholderArt(ColorScheme colorScheme, double t) {
return Container(
color: Color.lerp(colorScheme.surfaceVariant, const Color(0xFF2a2a2a), t),
+109 -38
View File
@@ -4,6 +4,7 @@ import '../../theme/design_tokens.dart';
/// A compact device selector bar shown when no track is playing
class DeviceSelectorBar extends StatelessWidget {
final dynamic selectedPlayer;
final dynamic peekPlayer;
final bool hasMultiplePlayers;
final Color backgroundColor;
final Color textColor;
@@ -11,11 +12,14 @@ class DeviceSelectorBar extends StatelessWidget {
final double height;
final double borderRadius;
final double slideOffset;
final GestureDragStartCallback? onHorizontalDragStart;
final GestureDragUpdateCallback? onHorizontalDragUpdate;
final GestureDragEndCallback? onHorizontalDragEnd;
const DeviceSelectorBar({
super.key,
required this.selectedPlayer,
this.peekPlayer,
required this.hasMultiplePlayers,
required this.backgroundColor,
required this.textColor,
@@ -23,12 +27,16 @@ class DeviceSelectorBar extends StatelessWidget {
required this.height,
required this.borderRadius,
required this.slideOffset,
this.onHorizontalDragStart,
this.onHorizontalDragUpdate,
this.onHorizontalDragEnd,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragStart: hasMultiplePlayers ? onHorizontalDragStart : null,
onHorizontalDragUpdate: hasMultiplePlayers ? onHorizontalDragUpdate : null,
onHorizontalDragEnd: hasMultiplePlayers ? onHorizontalDragEnd : null,
child: Material(
color: backgroundColor,
@@ -40,47 +48,56 @@ class DeviceSelectorBar extends StatelessWidget {
width: width,
height: height,
child: ClipRect(
child: Transform.translate(
offset: Offset(slideOffset * width, 0),
child: Padding(
padding: Spacing.paddingH16,
child: Row(
children: [
Icon(
_getPlayerIcon(selectedPlayer.name),
color: textColor,
size: IconSizes.md,
),
Spacing.hGap12,
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
selectedPlayer.name,
style: TextStyle(
color: textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (hasMultiplePlayers)
Text(
'Swipe to switch device',
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 12,
child: Stack(
children: [
// Peek player content (shows when dragging)
if (slideOffset.abs() > 0.01 && peekPlayer != null)
_buildPeekContent(),
// Current player content
Transform.translate(
offset: Offset(slideOffset * width, 0),
child: Padding(
padding: Spacing.paddingH16,
child: Row(
children: [
Icon(
_getPlayerIcon(selectedPlayer.name),
color: textColor,
size: IconSizes.md,
),
Spacing.hGap12,
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
selectedPlayer.name,
style: TextStyle(
color: textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
if (hasMultiplePlayers)
Text(
'Swipe to switch device',
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 12,
),
),
],
),
),
],
),
],
),
),
),
],
),
),
),
@@ -88,6 +105,60 @@ class DeviceSelectorBar extends StatelessWidget {
);
}
/// Build peek player content that slides in from the edge
Widget _buildPeekContent() {
final isFromRight = slideOffset < 0;
final peekProgress = slideOffset.abs();
// Calculate peek position - slides in as main content slides out
final peekBaseOffset = isFromRight
? width * (1 - peekProgress)
: -width * (1 - peekProgress);
return Transform.translate(
offset: Offset(peekBaseOffset, 0),
child: Padding(
padding: Spacing.paddingH16,
child: Row(
children: [
Icon(
_getPlayerIcon(peekPlayer.name),
color: textColor,
size: IconSizes.md,
),
Spacing.hGap12,
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
peekPlayer.name,
style: TextStyle(
color: textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (hasMultiplePlayers)
Text(
'Swipe to switch device',
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 12,
),
),
],
),
),
],
),
),
);
}
/// Get appropriate icon for player based on name
IconData _getPlayerIcon(String playerName) {
final nameLower = playerName.toLowerCase();
+14 -11
View File
@@ -109,17 +109,19 @@ class _VolumeControlState extends State<VolumeControl> {
},
),
Expanded(
child: SliderTheme(
data: SliderThemeData(
trackHeight: 2,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white.withOpacity(0.3),
thumbColor: Colors.white,
overlayColor: Colors.white.withOpacity(0.2),
),
child: Slider(
child: SizedBox(
height: 48, // Increase touch target height
child: SliderTheme(
data: SliderThemeData(
trackHeight: 2,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white.withOpacity(0.3),
thumbColor: Colors.white,
overlayColor: Colors.white.withOpacity(0.2),
),
child: Slider(
value: currentVolume.clamp(0.0, 1.0),
onChanged: (value) {
setState(() {
@@ -163,6 +165,7 @@ class _VolumeControlState extends State<VolumeControl> {
}
},
),
),
),
),
SizedBox(
+1 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 1.4.0+4
version: 1.6.1+8
environment:
sdk: '>=3.0.0 <4.0.0'