Compare commits

..
Author SHA1 Message Date
Your NameandClaude Opus 4.5 7f871f0078 chore: release v2.7.3-beta
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 09:40:57 +00:00
Your NameandClaude Opus 4.5 573a5f6fe7 chore: bump to 2.7.3-beta+36 with shuffle queue fix
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 07:35:44 +00:00
Your NameandClaude Opus 4.5 cb87492c9c fix: shuffle toggle now correctly updates queue display
Root cause: After toggling shuffle, the queue comparison only checked
metadata (length, currentIndex, shuffle state). When the optimistic
update set shuffleEnabled=true with old items, and the server returned
shuffleEnabled=true with reordered items, the comparison showed "no change"
because both had the same shuffle state.

Changes:
- Add item reordering detection by comparing first 3 queue item IDs
- Add 150ms delay after toggle API call to ensure server finishes reordering
- Add debug logging to track reordering detection

Fixes queue display not updating when shuffle is toggled on/off.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 07:35:24 +00:00
Your Name b1b91e8dca chore: bump to 2.7.3-beta+35 with shuffle button visual fix 2025-12-30 07:14:38 +00:00
Your NameandClaude Opus 4.5 ba0f21856a fix: shuffle/repeat button visual state now updates immediately
Apply optimistic UI updates for shuffle and repeat toggles:
- Immediately update local queue state before API call
- Prevents cached queue from overwriting optimistic state
- Ensures button color changes instantly on tap

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:57:04 +00:00
Your Name 218bec9607 debug: add more shuffle state logging 2025-12-30 06:27:50 +00:00
Your Name 2d491e1ae9 chore: bump to 2.7.3-beta+33 with shuffle logging 2025-12-30 06:01:07 +00:00
Your Name 1ab51bbf25 debug: add shuffle toggle logging for diagnostics 2025-12-30 05:57:56 +00:00
Your Name 5e6da3ec2f chore: bump version to 2.7.3-beta+32 2025-12-30 05:56:32 +00:00
Your NameandClaude Opus 4.5 3894574a05 fix(#28): add shuffle_enabled parameter to toggle shuffle API
fix(#21): prevent early return on coldStart for auto-selection priority

The shuffle button was failing because the Music Assistant API requires
the shuffle_enabled boolean parameter. Added the parameter through all
layers (API → provider → widget).

The auto-selection wasn't applying Priority 2 (local player) when
"Prefer Local Player" was disabled because an early cache-valid return
was bypassing the cold start priority logic.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 05:37:30 +00:00
Your NameandClaude Opus 4.5 7fe4e553ee feat: add app_id logging for external source diagnosis
Expose app_id on Player model and log it for playing players
to help diagnose why Spotify Connect isn't detected as external source.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 05:26:27 +00:00
Your NameandClaude Opus 4.5 459eb316b2 fix: correct method name clearRemotePlaybackState
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 23:25:30 +00:00
Your NameandClaude Opus 4.5 d3d03fd4d1 fix(#27): clear stale track data when external source takes over
When a player is using an external source (Spotify Connect, TV optical,
etc.), Ensemble was showing stale MA track data instead of clearing it.

Changes:
- selectPlayer(): Clear _currentTrack for external source players
- selectPlayer(): Skip position tracker updates for external sources
- _preloadPlayerTrack(): Skip preloading for external source players
- _updatePlayerState(): Early return for external sources, clearing
  track, audiobook context, and notification
- player_selector.dart: Exclude external sources from badge count
- Auto-selection logic: Exclude external sources from "playing player"
  priority in both music_assistant_provider and player_provider

The isExternalSource flag (detecting app_id != 'music_assistant') was
already being set correctly in the Player model, but wasn't being used
throughout the app to prevent showing stale data.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 23:16:35 +00:00
Your NameandClaude Opus 4.5 8f5008b902 fix: responsive cover sizing on detail screens
Use percentage-based sizing (40-50% of screen width) with min/max
clamps to adapt covers to different screen sizes (phones vs tablets).

Updated screens:
- album_details_screen: 50% width, 200-320px
- audiobook_detail_screen: 50% width, 200-320px
- audiobook_series_screen: 50% width, 200-320px
- artist_details_screen: 40% width, 160-280px (circular)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 22:32:41 +00:00
Your NameandClaude Opus 4.5 c3acd0029e fix: correct nullable return type in device ID service
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 21:36:55 +00:00
Your NameandClaude Opus 4.5 925feb89b6 fix: use stable device ID to prevent stale players
- Replace random UUID with ANDROID_ID (survives storage wipes)
- Include MA username in player ID for multi-device support
- Auto-detect Phone vs Tablet based on screen size (600dp threshold)
- Format: ensemble_{username}_{deviceId8}
- Prevents duplicate players when clearing app storage

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 21:26:29 +00:00
Your Name 29758eb9bb chore: bump version to 2.7.2-beta 2025-12-29 18:28:52 +00:00
Your NameandClaude Opus 4.5 332de9e00c perf: search screen audit fixes
- Only rebuild when clear button visibility changes (not every keystroke)
- Use context.select for connection status to avoid unnecessary rebuilds
- Cache available filters list to avoid recalculating per build
- Use theme-aware colors for filter chips (light/dark mode support)
- Remove dead code (_buildListItems method - 88 lines)
- Use safe .whereType<T>() instead of unsafe .cast<T>()

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 18:05:49 +00:00
Your Name 6f503c8540 fix: add gap between search bar and filter bar, optimize scroll to avoid animation jank 2025-12-29 17:41:45 +00:00
Your Name 9950ff2063 fix: remove container wrapper and extra padding from filter bar 2025-12-29 17:25:49 +00:00
Your Name 4c777a93e1 fix: add solid background to filter bar container 2025-12-29 17:15:54 +00:00
Your Name a96578bbf0 fix: remove gap between filter bar and results 2025-12-29 16:54:16 +00:00
Your Name 8fa95705e1 fix: keep filter bar visible when scrolling 2025-12-29 16:34:17 +00:00
Your NameandClaude Opus 4.5 9dcca2ce54 fix: improve search filter bar scroll and layout behavior
- Filter bar only scrolls when active filter would be off-screen
- Changed from Stack overlay to Column layout
- Results no longer scroll behind filter bar
- Filter bar hides on scroll along with search bar

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 16:17:02 +00:00
Your NameandClaude Opus 4.5 4152c3d67f perf(#23): use ListView.builder for player list with many players
- Only builds visible player cards (lazy loading)
- Uses itemExtent for optimal scroll performance
- Reduces animation work from O(n) to O(visible) per frame
- Should fix choppy animation reported by shpongledsummer

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 14:42:10 +00:00
Your NameandClaude Opus 4.5 d84fb58a5c fix: only allow swipe down for dismiss, ignore swipe up
Swipe up was incorrectly moving cards down because delta negation
was inverting the direction. Now:
- Swipe up (finger up): ignored when not scrollable
- Swipe down (finger down): triggers dismiss gesture

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 13:50:57 +00:00
Your NameandClaude Opus 4.5 128d417014 feat(#23): smart scroll for player list overflow
- Add scroll when player list exceeds 80% of available screen height
- Swipe down dismisses only when scrolled to top
- When scrolled down, swipe gestures scroll the list instead
- No behavior change for small player counts (5-6 players)

This matches the common pattern used by Instagram, Apple Maps, etc.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 13:35:49 +00:00
Your NameandClaude Opus 4.5 176fa5c8b9 fix: revert player list scrollability changes
The scrollability changes were interfering with the drag-to-dismiss
gesture. For normal player counts (5-6), scrolling isn't needed.
Issue #23 (many players overflow) needs a different approach.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 13:04:38 +00:00
Your NameandClaude Opus 4.5 ccaf764f52 Add missing player_picker_sheet.dart from feature branch
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 12:45:37 +00:00
Your NameandClaude Opus 4.5 ccda231fb2 Fix GitHub issues #27, #23, add hero animations to search
## Issue Fixes

### #27 - DLNA external source detection
- Add app_id check as primary indicator for external sources
- When app_id != 'music_assistant', mark as external source
- Keeps existing URI-based detection as fallback

### #23 - Speaker selection scrollability
- Add top constraint to player overlay for max height
- Wrap player list in SingleChildScrollView with Flexible
- Enable scrolling when more than 5 players (BouncingScrollPhysics)
- Prevents player cards from overflowing on devices with many speakers

### #21 - Auto-selection debugging
- Add detailed logging when Priority 2 (local player) fails
- Log whether local player is missing from list or not available
- Log when builtinPlayerId is null

## Enhancements

### Hero animations for search results
- Add hero animations to album, artist, and audiobook tiles
- Use FadeSlidePageRoute for smooth transitions
- Use 'search' suffix for hero tags to avoid conflicts
- Update adaptive colors on navigation tap

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 12:34:13 +00:00
Your NameandClaude Opus 4.5 0f82829172 Fix action button consistency on artist details screen
- Radio button now shows player selection popup (consistent with search/album)
- Add to Queue button now shows player selection popup (was broken - did nothing without selected player)
- Removed redundant "Play On" button (Radio now provides the same functionality)
- Renamed _showPlayOnMenu to _showRadioMenu for clarity
- Removed unused _startArtistRadio method

Artist details header now has: [Radio] [Add to Queue] [Favorite]
All buttons work consistently with popup selection, matching search/album behavior.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 11:53:16 +00:00
Your NameandClaude Opus 4.5 0ccde33c85 Standardize bottom sheet implementations across all screens
- All sheets now use consistent pattern: transparent background with
  Container decoration, mainAxisSize.min, Flexible with shrinkWrap
- All sheets hide mini player before showing and restore on dismiss
- All sheets include safe area bottom padding
- Fixed album_details_screen _showPlayOnSheet (was using fixed 45% height)
- Fixed artist_details_screen _showPlayOnMenu (was missing hidePlayer/showPlayer)
- Fixed audiobook_detail_screen _showPlayOnMenu (was using fixed 45% height)
- Added safe area padding to search_screen sheets

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 11:40:49 +00:00
16 changed files with 1458 additions and 626 deletions
+20 -2
View File
@@ -16,6 +16,7 @@ class Player {
final String? syncedTo; // Player ID this player is synced to (null if leader or not synced)
final String? activeSource; // The currently active source for this player
final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active
final String? appId; // The app_id from MA - 'music_assistant' when MA is playing, else external source
Player({
required this.playerId,
@@ -33,6 +34,7 @@ class Player {
this.syncedTo,
this.activeSource,
this.isExternalSource = false,
this.appId,
});
/// Create a copy of this Player with some fields replaced
@@ -52,6 +54,7 @@ class Player {
String? syncedTo,
String? activeSource,
bool? isExternalSource,
String? appId,
}) {
return Player(
playerId: playerId ?? this.playerId,
@@ -69,6 +72,7 @@ class Player {
syncedTo: syncedTo ?? this.syncedTo,
activeSource: activeSource ?? this.activeSource,
isExternalSource: isExternalSource ?? this.isExternalSource,
appId: appId ?? this.appId,
);
}
@@ -197,9 +201,21 @@ class Player {
final activeSource = json['active_source'] as String?;
// Detect external source (optical, Spotify, AirPlay, etc.)
// External sources have non-MA URIs and often media_type='unknown'
// Primary indicator: app_id - if not 'music_assistant', it's an external source
// Secondary indicators: URI patterns and media_type
bool isExternalSource = false;
if (json.containsKey('current_media')) {
// Check app_id first - this is the most reliable indicator for DLNA players
// When MA is playing: app_id = 'music_assistant'
// When external source: app_id = 'http', 'spotify', etc.
final appId = json['app_id'] as String?;
if (appId != null && appId.isNotEmpty && appId != 'music_assistant') {
// app_id is set but not 'music_assistant' - external source is active
isExternalSource = true;
}
// Also check current_media for additional external source indicators
if (!isExternalSource && json.containsKey('current_media')) {
final currentMedia = json['current_media'] as Map<String, dynamic>?;
if (currentMedia != null) {
final uri = currentMedia['uri'] as String?;
@@ -252,6 +268,7 @@ class Player {
syncedTo: syncedTo,
activeSource: activeSource,
isExternalSource: isExternalSource,
appId: appId,
);
}
@@ -272,6 +289,7 @@ class Player {
if (syncedTo != null) 'synced_to': syncedTo,
if (activeSource != null) 'active_source': activeSource,
'is_external_source': isExternalSource,
if (appId != null) 'app_id': appId,
};
}
}
+75 -15
View File
@@ -2196,7 +2196,9 @@ class MusicAssistantProvider with ChangeNotifier {
Future<void> _loadAndSelectPlayers({bool forceRefresh = false, bool coldStart = false}) async {
try {
// Don't skip on coldStart - we need to apply full auto-selection priority logic
if (!forceRefresh &&
!coldStart &&
_cacheService.isPlayersCacheValid() &&
_availablePlayers.isNotEmpty) {
return;
@@ -2370,8 +2372,9 @@ class MusicAssistantProvider with ChangeNotifier {
.firstWhere((p) => p.playerId == _selectedPlayer!.playerId)
.state;
final currentIsPlaying = currentPlayerState == 'playing';
// Exclude external sources - they're not playing MA content
final playingPlayers = _availablePlayers.where(
(p) => p.state == 'playing' && p.available,
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
).toList();
// Switch to playing player only if current isn't playing and exactly one other is
@@ -2400,8 +2403,9 @@ class MusicAssistantProvider with ChangeNotifier {
if (!preferLocalPlayer) {
// Priority 1 (normal): Single playing player (skip if multiple are playing)
// Exclude external sources - they're not playing MA content
final playingPlayers = _availablePlayers.where(
(p) => p.state == 'playing' && p.available,
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
).toList();
if (playingPlayers.length == 1) {
playerToSelect = playingPlayers.first;
@@ -2415,7 +2419,19 @@ class MusicAssistantProvider with ChangeNotifier {
(p) => p.playerId == builtinPlayerId && p.available,
);
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
} catch (e) {}
} catch (e) {
// Local player not found or not available - check why
final localPlayer = _availablePlayers
.where((p) => p.playerId == builtinPlayerId)
.toList();
if (localPlayer.isEmpty) {
_logger.log('⚠️ Priority 2 skipped: local player ($builtinPlayerId) not in available players list');
} else {
_logger.log('⚠️ Priority 2 skipped: local player found but available=${localPlayer.first.available}');
}
}
} else if (playerToSelect == null && builtinPlayerId == null) {
_logger.log('⚠️ Priority 2 skipped: builtinPlayerId is null');
}
}
@@ -2462,17 +2478,24 @@ class MusicAssistantProvider with ChangeNotifier {
// IMPORTANT: Always set from cache, even if null - this prevents showing
// stale track info when switching to a non-playing player.
// For grouped child players, this returns the leader's track
_currentTrack = getCachedTrackForPlayer(player.playerId);
// IMPORTANT: Clear track for external sources - they're playing non-MA content
if (player.isExternalSource) {
_currentTrack = null;
} else {
_currentTrack = getCachedTrackForPlayer(player.playerId);
}
// Initialize position tracker for this player
// Initialize position tracker for this player (skip for external sources)
_positionTracker.onPlayerSelected(player.playerId);
_positionTracker.updateFromServer(
playerId: player.playerId,
position: player.elapsedTime ?? 0.0,
isPlaying: player.state == 'playing',
duration: _currentTrack?.duration,
serverTimestamp: player.elapsedTimeLastUpdated,
);
if (!player.isExternalSource) {
_positionTracker.updateFromServer(
playerId: player.playerId,
position: player.elapsedTime ?? 0.0,
isPlaying: player.state == 'playing',
duration: _currentTrack?.duration,
serverTimestamp: player.elapsedTimeLastUpdated,
);
}
// Switch audio handler mode based on player type
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
@@ -2756,7 +2779,19 @@ class MusicAssistantProvider with ChangeNotifier {
if (_api == null) return;
try {
_logger.log('🔍 Preload ${player.name}: state=${player.state}, available=${player.available}');
// Log app_id for playing players to help diagnose external source detection
if (player.state == 'playing') {
_logger.log('🔍 Preload ${player.name}: state=${player.state}, app_id=${player.appId}, isExternalSource=${player.isExternalSource}');
} else {
_logger.log('🔍 Preload ${player.name}: state=${player.state}, available=${player.available}');
}
// Skip external sources - they're not playing MA content
if (player.isExternalSource) {
_logger.log('🔍 Preload ${player.name}: SKIPPED - external source (app_id=${player.appId})');
_cacheService.setCachedTrackForPlayer(player.playerId, null);
return;
}
if (!player.available || !player.powered) {
_logger.log('🔍 Preload ${player.name}: SKIPPED - not available or powered');
@@ -2888,6 +2923,28 @@ class MusicAssistantProvider with ChangeNotifier {
_selectedPlayer = updatedPlayer;
stateChanged = true;
// Handle external sources (Spotify Connect, TV optical, etc.)
// These players are "playing" from MA's perspective but not MA content
if (updatedPlayer.isExternalSource) {
if (_currentTrack != null) {
_currentTrack = null;
_cacheService.setCachedTrackForPlayer(updatedPlayer.playerId, null);
stateChanged = true;
_persistPlaybackState();
}
if (_currentAudiobook != null) {
_logger.log('📚 External source active, clearing audiobook context');
_currentAudiobook = null;
stateChanged = true;
}
// Clear notification for external source
audioHandler.clearRemotePlaybackState();
if (stateChanged) {
notifyListeners();
}
return;
}
// Feed position tracker with server data
// Log raw values to debug position issues
final rawElapsedTime = updatedPlayer.elapsedTime;
@@ -3933,10 +3990,13 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
Future<void> toggleShuffle(String queueId) async {
Future<void> toggleShuffle(String queueId, bool shuffleEnabled) async {
_logger.log('🔀 toggleShuffle called: queueId=$queueId, shuffleEnabled=$shuffleEnabled');
try {
await _api?.toggleShuffle(queueId);
await _api?.toggleShuffle(queueId, shuffleEnabled);
_logger.log('🔀 toggleShuffle: API call completed');
} catch (e) {
_logger.log('🔀 toggleShuffle ERROR: $e');
ErrorHandler.logError('Toggle shuffle', e);
rethrow;
}
+4 -3
View File
@@ -575,10 +575,11 @@ class PlayerProvider with ChangeNotifier {
} catch (e) {}
}
// Try playing player (exclude external sources - not playing MA content)
if (playerToSelect == null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.state == 'playing' && p.available,
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
);
} catch (e) {}
}
@@ -906,10 +907,10 @@ class PlayerProvider with ChangeNotifier {
}
}
Future<void> toggleShuffle(String queueId) async {
Future<void> toggleShuffle(String queueId, bool shuffleEnabled) async {
if (_api == null) return;
try {
await _api!.toggleShuffle(queueId);
await _api!.toggleShuffle(queueId, shuffleEnabled);
} catch (e) {
ErrorHandler.logError('Toggle shuffle', e);
rethrow;
+44 -38
View File
@@ -558,7 +558,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
},
),
),
const SizedBox(height: 16),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
@@ -703,10 +703,16 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
},
child: Scaffold(
backgroundColor: colorScheme.background,
body: CustomScrollView(
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 50% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 350, // Increased height for bigger art
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.background,
leading: IconButton(
@@ -727,11 +733,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: Hero(
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
child: Container(
width: 280, // Increased size
height: 280,
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(16), // Slightly more rounded
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
@@ -749,7 +755,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: imageUrl == null
? Icon(
Icons.album_rounded,
size: 120,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
)
: null,
@@ -1080,7 +1086,9 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player
],
),
);
},
),
),
);
}
@@ -1132,7 +1140,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
);
}
/// Shared "Play on..." bottom sheet with fixed height and scrollable list
/// Shared "Play on..." bottom sheet that sizes to content
void _showPlayOnSheet(
BuildContext context,
List players, {
@@ -1141,21 +1149,16 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
final screenHeight = MediaQuery.of(context).size.height;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final sheetHeight = (screenHeight * 0.45) + bottomPadding; // Fixed 45% + safe area
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => Container(
height: sheetHeight,
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(
@@ -1163,30 +1166,33 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Expanded(
child: players.isEmpty
? Center(
child: Text(S.of(context)!.noPlayersAvailable),
)
: ListView.builder(
padding: EdgeInsets.only(bottom: bottomPadding + 16),
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () {
Navigator.pop(context);
onPlayerSelected(player);
},
);
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () {
Navigator.pop(context);
onPlayerSelected(player);
},
),
),
);
},
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
+117 -109
View File
@@ -243,57 +243,25 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
}
Future<void> _startArtistRadio() async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
try {
await maProvider.playArtistRadio(selectedPlayer.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.startingRadio(widget.artist.name)),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error starting artist radio: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
}
void _showPlayOnMenu(BuildContext context) {
void _showRadioMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
final colorScheme = Theme.of(context).colorScheme;
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => Padding(
padding: const EdgeInsets.all(16.0),
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,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
Text(
S.of(context)!.startRadioOn(widget.artist.name),
style: Theme.of(context).textTheme.titleLarge,
@@ -314,7 +282,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
return ListTile(
leading: Icon(
Icons.speaker,
color: colorScheme.onSurface,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
@@ -345,54 +313,103 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
},
),
),
const SizedBox(height: 16),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
);
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
}
Future<void> _addArtistRadioToQueue() async {
void _showAddToQueueMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
final players = maProvider.availablePlayers;
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
final api = maProvider.api;
if (api == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.notConnected)),
);
return;
}
try {
// For adding to queue with radio mode, we use radio_mode but with 'add' option
await api.playArtistRadioToQueue(selectedPlayer.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding artist radio to queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
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(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
final api = maProvider.api;
if (api == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.notConnected)),
);
}
return;
}
try {
await api.playArtistRadioToQueue(player.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding artist radio to queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
},
);
},
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
}
Future<void> _loadArtistImage() async {
@@ -534,10 +551,16 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
},
child: Scaffold(
backgroundColor: colorScheme.background,
body: CustomScrollView(
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 40% of screen width, clamped between 160-280 (smaller for circular artist image)
final coverSize = (constraints.maxWidth * 0.4).clamp(160.0, 280.0);
final expandedHeight = coverSize + 100;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 300,
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.background,
leading: IconButton(
@@ -557,8 +580,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + _heroTagSuffix,
child: ClipOval(
child: Container(
width: 200,
height: 200,
width: coverSize,
height: coverSize,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
@@ -568,13 +591,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Icon(
Icons.person_rounded,
size: 100,
size: coverSize * 0.5,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.person_rounded,
size: 100,
size: coverSize * 0.5,
color: colorScheme.onSurfaceVariant,
),
),
@@ -635,7 +658,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
onPressed: _startArtistRadio,
onPressed: () => _showRadioMenu(context),
icon: const Icon(Icons.radio),
label: Text(S.of(context)!.radio),
style: ElevatedButton.styleFrom(
@@ -648,23 +671,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
),
const SizedBox(width: 12),
// "Play on..." Button (Square)
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: () => _showPlayOnMenu(context),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
),
),
const SizedBox(width: 12),
@@ -673,7 +679,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _addArtistRadioToQueue,
onPressed: () => _showAddToQueueMenu(context),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
@@ -847,6 +853,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
],
],
],
);
},
),
),
);
+48 -38
View File
@@ -211,23 +211,19 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
final screenHeight = MediaQuery.of(context).size.height;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final sheetHeight = (screenHeight * 0.45) + bottomPadding;
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => Container(
height: sheetHeight,
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(
@@ -235,37 +231,43 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Expanded(
child: players.isEmpty
? Center(child: Text(S.of(context)!.noPlayersAvailable))
: ListView.builder(
padding: EdgeInsets.only(bottom: bottomPadding + 16),
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
maProvider.selectPlayer(player);
maProvider.setCurrentAudiobook(_audiobook);
await maProvider.api?.playAudiobook(
player.playerId,
widget.audiobook,
);
},
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
maProvider.selectPlayer(player);
maProvider.setCurrentAudiobook(_audiobook);
await maProvider.api?.playAudiobook(
player.playerId,
widget.audiobook,
);
},
),
),
);
},
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
}
@@ -466,10 +468,16 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
},
child: Scaffold(
backgroundColor: colorScheme.surface,
body: CustomScrollView(
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 50% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 350,
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
@@ -538,8 +546,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 280,
height: 280,
width: coverSize,
height: coverSize,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
@@ -550,14 +558,14 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
size: 120,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.bookOutline,
size: 120,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
@@ -565,7 +573,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
: Center(
child: Icon(
MdiIcons.bookOutline,
size: 120,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
@@ -809,6 +817,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
const SliverToBoxAdapter(child: SizedBox(height: 140)),
],
);
},
),
),
);
+13 -5
View File
@@ -275,11 +275,17 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
return Scaffold(
backgroundColor: colorScheme.surface,
body: CustomScrollView(
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 50% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
slivers: [
// App bar with series cover collage (matches audiobook detail screen)
SliverAppBar(
expandedHeight: 350,
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
@@ -292,12 +298,12 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 60),
// Series cover collage - same size as audiobook detail cover
// Series cover collage - responsive size
Hero(
tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
child: Container(
width: 280,
height: 280,
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
@@ -449,6 +455,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
child: SizedBox(height: BottomSpacing.withMiniPlayer),
),
],
);
},
),
);
}
File diff suppressed because it is too large Load Diff
+94 -23
View File
@@ -1,36 +1,99 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:device_info_plus/device_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import 'debug_logger.dart';
import 'settings_service.dart';
/// Service to generate unique per-installation player IDs
/// Simplified to match KMP client pattern: single storage key, lazy generation
/// This prevents the critical bug where multiple devices of the same model
/// would share the same player ID and trigger playback on all devices
/// Service to generate unique per-device player IDs
/// Uses stable device identifiers (ANDROID_ID) combined with MA username
/// to create persistent player IDs that survive storage wipes and support
/// multiple devices per account
class DeviceIdService {
static const String _keyLocalPlayerId = 'local_player_id';
static final _logger = DebugLogger();
static const _uuid = Uuid();
/// Get or generate a unique player ID for this installation
/// ID is generated once and persisted across app restarts
/// Format: ensemble_<uuid>
///
/// This matches the KMP client pattern: generate on first access, store once
static Future<String> getOrCreateDevicePlayerId() async {
final prefs = await SharedPreferences.getInstance();
// Cache stable device ID to avoid repeated platform calls
static String? _cachedStableDeviceId;
// Single source of truth - no complex migration logic
final existingId = prefs.getString(_keyLocalPlayerId);
if (existingId != null && existingId.startsWith('ensemble_')) {
_logger.log('Using existing player ID: $existingId');
return existingId;
/// Get stable device identifier that persists across storage wipes
/// - Android: Uses ANDROID_ID (persists until factory reset)
/// - iOS: Uses identifierForVendor (persists until app reinstall)
static Future<String> _getStableDeviceId() async {
if (_cachedStableDeviceId != null) {
return _cachedStableDeviceId!;
}
// Generate new ID on first access
final playerId = 'ensemble_${_uuid.v4()}';
await prefs.setString(_keyLocalPlayerId, playerId);
_logger.log('Generated new player ID: $playerId');
return playerId;
final deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await deviceInfo.androidInfo;
// ANDROID_ID is unique per device+user combo, persists across storage wipes
// Only resets on factory reset
_cachedStableDeviceId = android.id;
} else if (Platform.isIOS) {
final ios = await deviceInfo.iosInfo;
// identifierForVendor persists across app updates but resets on reinstall
_cachedStableDeviceId = ios.identifierForVendor ?? 'unknown_ios';
} else {
// Fallback for other platforms (web, desktop)
_cachedStableDeviceId = 'unknown_platform';
}
return _cachedStableDeviceId!;
}
/// Detect if device is a tablet based on screen size
/// Uses the standard 600dp threshold (shortest side >= 600 = tablet)
static bool get isTablet {
try {
final view = ui.PlatformDispatcher.instance.views.first;
final size = view.physicalSize / view.devicePixelRatio;
return size.shortestSide >= 600;
} catch (e) {
_logger.log('Failed to detect device type: $e');
return false; // Default to phone if detection fails
}
}
/// Get or generate a unique player ID for this device
/// Format: ensemble_{username}_{deviceId} or ensemble_{deviceId}
///
/// Key behaviors:
/// - Uses stable device ID that survives storage wipes
/// - Includes MA username to support multiple devices per account
/// - Regenerates if username changes (user logged in with different account)
static Future<String> getOrCreateDevicePlayerId() async {
final prefs = await SharedPreferences.getInstance();
final stableDeviceId = await _getStableDeviceId();
final maUsername = await SettingsService.getUsername();
// Generate expected ID based on current username and device
// Use shorter device ID suffix (first 8 chars) to keep ID reasonable length
final deviceIdSuffix = stableDeviceId.length > 8
? stableDeviceId.substring(0, 8)
: stableDeviceId;
final expectedId = maUsername != null && maUsername.isNotEmpty
? 'ensemble_${maUsername}_$deviceIdSuffix'
: 'ensemble_$deviceIdSuffix';
// Check if we already have the correct ID
final existingId = prefs.getString(_keyLocalPlayerId);
if (existingId == expectedId) {
_logger.log('Using existing player ID: $expectedId');
return expectedId;
}
// ID needs to be updated (first login, username changed, or migration)
if (existingId != null) {
_logger.log('Updating player ID from $existingId to $expectedId');
} else {
_logger.log('Generated new player ID: $expectedId');
}
await prefs.setString(_keyLocalPlayerId, expectedId);
return expectedId;
}
/// Adopt an existing player ID (used when claiming a ghost player)
@@ -45,4 +108,12 @@ class DeviceIdService {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLocalPlayerId) == null;
}
/// Force regeneration of player ID on next call
/// Useful after login/logout to ensure ID matches current user
static Future<void> invalidateCachedId() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyLocalPlayerId);
_logger.log('Invalidated cached player ID');
}
}
+3 -2
View File
@@ -1528,6 +1528,7 @@ class MusicAssistantAPI {
currentIndex = queueResult['current_index'] as int?;
shuffleEnabled = queueResult['shuffle_enabled'] as bool?;
repeatMode = queueResult['repeat_mode'] as String?;
_logger.log('🔀 Queue metadata: shuffle_enabled=$shuffleEnabled, repeat_mode=$repeatMode');
final currentItemData = queueResult['current_item'] as Map<String, dynamic>?;
final currentItemName = currentItemData?['name'] as String?;
@@ -2067,11 +2068,11 @@ class MusicAssistantAPI {
}
/// Toggle shuffle mode for queue
Future<void> toggleShuffle(String queueId) async {
Future<void> toggleShuffle(String queueId, bool shuffleEnabled) async {
try {
await _sendCommand(
'player_queues/shuffle',
args: {'queue_id': queueId},
args: {'queue_id': queueId, 'shuffle_enabled': shuffleEnabled},
);
} catch (e) {
_logger.log('Error toggling shuffle: $e');
+6 -3
View File
@@ -1,5 +1,6 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
import 'device_id_service.dart';
class SettingsService {
static const String _keyServerUrl = 'server_url';
@@ -394,12 +395,14 @@ class SettingsService {
}
// Helper to create player name with possessive apostrophe
// Automatically detects Phone vs Tablet based on screen size
static String _makePlayerName(String ownerName) {
// Handle possessive: "Chris" -> "Chris' Phone", "Mom" -> "Mom's Phone"
final deviceType = DeviceIdService.isTablet ? 'Tablet' : 'Phone';
// Handle possessive: "Chris" -> "Chris' Tablet", "Mom" -> "Mom's Phone"
if (ownerName.toLowerCase().endsWith('s')) {
return "$ownerName' Phone";
return "$ownerName' $deviceType";
} else {
return "$ownerName's Phone";
return "$ownerName's $deviceType";
}
}
+86 -13
View File
@@ -345,14 +345,17 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
}
// 1. Show cached queue immediately (if available)
final cachedQueue = await maProvider.getCachedQueue(player.playerId);
if (cachedQueue != null && cachedQueue.items.isNotEmpty && mounted) {
setState(() {
_queue = cachedQueue;
_isLoadingQueue = false;
});
} else {
setState(() => _isLoadingQueue = true);
// Only load from cache if we don't already have a queue (preserves optimistic updates)
if (_queue == null) {
final cachedQueue = await maProvider.getCachedQueue(player.playerId);
if (cachedQueue != null && cachedQueue.items.isNotEmpty && mounted) {
setState(() {
_queue = cachedQueue;
_isLoadingQueue = false;
});
} else {
setState(() => _isLoadingQueue = true);
}
}
// 2. Fetch fresh queue in background
@@ -360,11 +363,28 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
try {
final freshQueue = await maProvider.getQueue(player.playerId);
if (mounted && freshQueue != null) {
// Only update if queue changed
debugPrint('🔀 Queue loaded: shuffle=${freshQueue.shuffle}, shuffleEnabled=${freshQueue.shuffleEnabled}');
// Check if queue items were reordered by comparing first few item IDs
bool itemsReordered = false;
if (_queue != null && _queue!.items.isNotEmpty && freshQueue.items.isNotEmpty) {
// Compare first 3 items to detect reordering (shuffle changes item order)
final maxCheck = (_queue!.items.length < 3 ? _queue!.items.length : 3).clamp(1, freshQueue.items.length);
for (int i = 0; i < maxCheck; i++) {
if (_queue!.items[i].queueItemId != freshQueue.items[i].queueItemId) {
itemsReordered = true;
debugPrint('🔀 Queue items reordered detected at position $i');
break;
}
}
}
// Update if queue metadata or item order changed
final queueChanged = _queue == null ||
_queue!.items.length != freshQueue.items.length ||
_queue!.currentIndex != freshQueue.currentIndex;
_queue!.currentIndex != freshQueue.currentIndex ||
_queue!.shuffle != freshQueue.shuffle ||
itemsReordered;
if (queueChanged) {
debugPrint('🔀 Queue changed, updating UI (reordered=$itemsReordered)');
setState(() {
_queue = freshQueue;
_isLoadingQueue = false;
@@ -406,16 +426,69 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
}
Future<void> _toggleShuffle() async {
if (_queue == null) return;
debugPrint('🔀 Shuffle button pressed');
if (_queue == null) {
debugPrint('🔀 Shuffle: _queue is null, returning');
return;
}
final newShuffleState = !_queue!.shuffle;
debugPrint('🔀 Shuffle: current=${_queue!.shuffle}, toggling to $newShuffleState');
// Optimistically update local state for immediate visual feedback
setState(() {
_queue = PlayerQueue(
playerId: _queue!.playerId,
items: _queue!.items,
currentIndex: _queue!.currentIndex,
shuffleEnabled: newShuffleState,
repeatMode: _queue!.repeatMode,
);
});
debugPrint('🔀 Shuffle: optimistic update applied, shuffleEnabled=$newShuffleState');
final maProvider = context.read<MusicAssistantProvider>();
await maProvider.toggleShuffle(_queue!.playerId);
// Toggle: if currently shuffled, disable; if not shuffled, enable
await maProvider.toggleShuffle(_queue!.playerId, newShuffleState);
debugPrint('🔀 Shuffle: command sent, waiting for server to reorder queue');
// Small delay to allow server to finish reordering queue items
await Future.delayed(const Duration(milliseconds: 150));
debugPrint('🔀 Shuffle: reloading queue');
await _loadQueue();
}
Future<void> _cycleRepeat() async {
if (_queue == null) return;
// Calculate next repeat mode: off -> all -> one -> off
String nextMode;
switch (_queue!.repeatMode) {
case 'off':
case null:
nextMode = 'all';
break;
case 'all':
nextMode = 'one';
break;
case 'one':
nextMode = 'off';
break;
default:
nextMode = 'off';
}
// Optimistically update local state for immediate visual feedback
setState(() {
_queue = PlayerQueue(
playerId: _queue!.playerId,
items: _queue!.items,
currentIndex: _queue!.currentIndex,
shuffleEnabled: _queue!.shuffleEnabled,
repeatMode: nextMode,
);
});
final maProvider = context.read<MusicAssistantProvider>();
await maProvider.cycleRepeatMode(_queue!.playerId, _queue!.repeatMode);
await maProvider.setRepeatMode(_queue!.playerId, nextMode);
await _loadQueue();
}
+126 -5
View File
@@ -44,6 +44,10 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
double _dragOffset = 0;
bool _isDragging = false;
// Scroll controller for when player list overflows
final ScrollController _scrollController = ScrollController();
bool _isScrollable = false;
// Cache extracted colors per player for per-device accent colors
final Map<String, ColorScheme?> _playerColors = {};
@@ -128,6 +132,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
void dispose() {
_revealController.dispose();
_refreshTimer?.cancel();
_scrollController.dispose();
super.dispose();
}
@@ -157,19 +162,51 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
});
}
/// Check if scroll is at top (or not scrollable)
bool get _isAtTop {
if (!_isScrollable) return true;
if (!_scrollController.hasClients) return true;
return _scrollController.offset <= 0;
}
void _handleVerticalDragUpdate(DragUpdateDetails details) {
final delta = details.primaryDelta ?? 0;
// If scrollable and not at top, let scroll handle it
if (_isScrollable && !_isAtTop) {
// Scroll the list
if (_scrollController.hasClients) {
final newOffset = _scrollController.offset - delta;
_scrollController.jumpTo(newOffset.clamp(0.0, _scrollController.position.maxScrollExtent));
}
return;
}
// At top: check if trying to scroll down (view more) or dismiss
if (_isScrollable && delta < 0 && _scrollController.hasClients) {
// Swiping up (delta negative) = scroll down to see more players
final newOffset = _scrollController.offset - delta;
_scrollController.jumpTo(newOffset.clamp(0.0, _scrollController.position.maxScrollExtent));
return;
}
// Only process dismiss gesture when swiping DOWN (positive delta = finger moving down)
// Ignore swipe up when not scrollable - cards should stay fixed
if (delta <= 0) return;
// Swiping down = dismiss gesture
setState(() {
_isDragging = true;
// Negate delta so swipe down = cards move down (toward dismissal)
_dragOffset -= details.primaryDelta ?? 0;
// Clamp: allow moving down (positive) freely, limit moving up to 20px
_dragOffset = _dragOffset.clamp(-20.0, double.infinity);
_dragOffset += delta;
});
}
void _handleVerticalDragEnd(DragEndDetails details) {
final velocity = details.primaryVelocity ?? 0;
// If not dragging (was scrolling), don't dismiss
if (!_isDragging) return;
// Dismiss if dragged down enough or with enough velocity
if (_dragOffset > 100 || velocity > 500) {
HapticFeedback.lightImpact();
@@ -215,6 +252,21 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
const cardSpacing = 12.0;
final totalStackHeight = players.length * cardHeight + (players.length - 1) * cardSpacing;
// Calculate max available height (80% of screen minus mini player area)
final screenHeight = MediaQuery.of(context).size.height;
final statusBarHeight = MediaQuery.of(context).padding.top;
final bottomPadding = widget.miniPlayerBottom + widget.miniPlayerHeight + 20;
final maxListHeight = (screenHeight - statusBarHeight - bottomPadding) * 0.8;
// Determine if scrolling is needed
final needsScroll = totalStackHeight > maxListHeight;
// Update scrollable state (used by gesture handlers)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isScrollable != needsScroll) {
_isScrollable = needsScroll;
}
});
return AnimatedBuilder(
animation: _revealAnimation,
builder: (context, child) {
@@ -289,7 +341,76 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
);
},
),
// Build player cards - all slide from behind mini player
// Build player cards - scrollable when overflow, otherwise static
// Using ListView.builder for many players: only builds visible items
// This significantly improves animation performance with 10+ players
if (needsScroll)
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxListHeight),
child: ListView.builder(
controller: _scrollController,
physics: const ClampingScrollPhysics(),
itemCount: players.length,
// Fixed height per item for optimal scroll performance
itemExtent: cardHeight + cardSpacing,
itemBuilder: (context, index) {
final player = players[index];
final isPlaying = player.state == 'playing';
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
String? albumArtUrl;
if (playerTrack != null && player.available && player.powered) {
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
}
final playerColorScheme = _playerColors[player.playerId];
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
// Animation: slide from behind mini player
// Only calculate for items that will be built (visible ones)
const baseOffset = 80.0;
final reverseIndex = players.length - 1 - index;
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
final slideOffset = distanceToTravel * (1.0 - t);
return Transform.translate(
offset: Offset(0, slideOffset),
child: Padding(
padding: const EdgeInsets.only(bottom: cardSpacing),
child: PlayerCard(
player: player,
trackInfo: playerTrack,
albumArtUrl: albumArtUrl,
isSelected: false,
isPlaying: isPlaying,
isGrouped: player.isGrouped,
backgroundColor: cardBgColor,
textColor: cardTextColor,
onTap: () {
HapticFeedback.mediumImpact();
maProvider.selectPlayer(player);
dismiss();
},
onLongPress: () {
HapticFeedback.mediumImpact();
maProvider.togglePlayerSync(player.playerId);
},
onPlayPause: () {
if (isPlaying) {
maProvider.pausePlayer(player.playerId);
} else {
maProvider.resumePlayer(player.playerId);
}
},
onSkipNext: () => maProvider.nextTrack(player.playerId),
onPower: () => maProvider.togglePower(player.playerId),
),
),
);
},
),
)
else
// Non-scrollable list for when players fit
...List.generate(players.length, (index) {
final player = players[index];
final isPlaying = player.state == 'playing';
+226
View File
@@ -0,0 +1,226 @@
import 'package:flutter/material.dart';
import '../models/player.dart';
import '../l10n/app_localizations.dart';
/// A themed bottom sheet for selecting a player.
///
/// Supports adaptive theming - pass [adaptiveColorScheme] to use colors
/// extracted from album art, matching the detail screen backgrounds.
class PlayerPickerSheet extends StatelessWidget {
final String title;
final List<Player> players;
final Player? selectedPlayer;
final ColorScheme? adaptiveColorScheme;
final Future<void> Function(Player player) onPlayerSelected;
const PlayerPickerSheet({
super.key,
required this.title,
required this.players,
this.selectedPlayer,
this.adaptiveColorScheme,
required this.onPlayerSelected,
});
@override
Widget build(BuildContext context) {
final themeColorScheme = Theme.of(context).colorScheme;
final colorScheme = adaptiveColorScheme ?? themeColorScheme;
// Use surface container for slightly elevated look, or adaptive surface
final backgroundColor = adaptiveColorScheme?.surface ?? themeColorScheme.surfaceContainerHigh;
final onBackgroundColor = adaptiveColorScheme?.onSurface ?? themeColorScheme.onSurface;
return Container(
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 8),
child: Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: onBackgroundColor.withOpacity(0.3),
borderRadius: BorderRadius.circular(2),
),
),
),
// Title
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: onBackgroundColor,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 8),
// Player list
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(
S.of(context)!.noPlayersAvailable,
style: TextStyle(color: onBackgroundColor.withOpacity(0.6)),
),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
final isSelected = selectedPlayer?.playerId == player.playerId;
return _PlayerTile(
player: player,
isSelected: isSelected,
colorScheme: colorScheme,
onBackgroundColor: onBackgroundColor,
onTap: () => onPlayerSelected(player),
);
},
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 70),
],
),
);
}
}
class _PlayerTile extends StatelessWidget {
final Player player;
final bool isSelected;
final ColorScheme colorScheme;
final Color onBackgroundColor;
final VoidCallback onTap;
const _PlayerTile({
required this.player,
required this.isSelected,
required this.colorScheme,
required this.onBackgroundColor,
required this.onTap,
});
@override
Widget build(BuildContext context) {
// Status color based on player state
Color statusColor;
if (!player.available) {
statusColor = Colors.grey.withOpacity(0.5);
} else if (!player.powered) {
statusColor = Colors.grey;
} else if (player.state == 'playing') {
statusColor = Colors.green;
} else {
statusColor = Colors.orange; // Idle/paused but powered
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Material(
color: isSelected
? colorScheme.primaryContainer.withOpacity(0.3)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Status indicator
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: statusColor,
),
),
const SizedBox(width: 12),
// Speaker icon
Icon(
Icons.speaker_rounded,
color: isSelected
? colorScheme.primary
: onBackgroundColor.withOpacity(0.7),
size: 24,
),
const SizedBox(width: 12),
// Player name
Expanded(
child: Text(
player.name,
style: TextStyle(
color: isSelected
? colorScheme.primary
: onBackgroundColor,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
fontSize: 16,
),
),
),
// Selected checkmark
if (isSelected)
Icon(
Icons.check_circle_rounded,
color: colorScheme.primary,
size: 20,
),
],
),
),
),
),
);
}
}
/// Shows the player picker bottom sheet with adaptive theming.
///
/// Returns the selected player, or null if dismissed.
Future<void> showPlayerPickerSheet({
required BuildContext context,
required String title,
required List<Player> players,
Player? selectedPlayer,
ColorScheme? adaptiveColorScheme,
required Future<void> Function(Player player) onPlayerSelected,
}) {
return showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => PlayerPickerSheet(
title: title,
players: players,
selectedPlayer: selectedPlayer,
adaptiveColorScheme: adaptiveColorScheme,
onPlayerSelected: (player) async {
Navigator.pop(context);
await onPlayerSelected(player);
},
),
);
}
+3 -2
View File
@@ -19,11 +19,12 @@ class PlayerSelector extends StatelessWidget {
final availablePlayers = maProvider.availablePlayers;
final colorScheme = Theme.of(context).colorScheme;
// Count players that are currently playing (excluding selected player)
// Count players that are currently playing MA content (excluding selected player)
// User already sees if selected player is playing, badge shows "other" activity
// Exclude external sources (Spotify Connect, TV optical, etc.) - they're not playing MA content
final selectedPlayerId = selectedPlayer?.playerId;
final playingCount = availablePlayers
.where((p) => p.state == 'playing' && p.playerId != selectedPlayerId)
.where((p) => p.state == 'playing' && p.playerId != selectedPlayerId && !p.isExternalSource)
.length;
return Padding(
+1 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.7.0-beta+30
version: 2.7.3-beta
environment:
sdk: '>=3.0.0 <4.0.0'