Compare commits

..
Author SHA1 Message Date
Your Name 65e8e87e01 Bump version to 2.5.0-beta+27 2025-12-26 09:11:22 +00:00
Your NameandClaude Opus 4.5 2658be1a3f Add nowPlaying localization for device selector UI
Adds translations:
- English: "Now playing"
- German: "Wird gespielt"
- Spanish: "Reproduciendo ahora"

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 08:24:57 +00:00
Your NameandClaude Opus 4.5 2a9a4b2494 Show player name in mini player when device selector is open
When the device reveal overlay is visible:
- Mini player shows player name instead of track name
- Secondary line shows "Now Playing" instead of artist
- Adds blur backdrop behind device selector for focus

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 08:15:47 +00:00
Your NameandClaude Opus 4.5 6ff1b8b95f Fix grouping: translate Cast player ID to Sendspin ID when syncing
When showing the regular Cast player (ungrouped state), sync operations
need to use the corresponding Sendspin player ID since only Sendspin
players can group with other Sendspin players.

Added _castToSendspinIdMap to track the mapping between regular Cast
and Sendspin player IDs, populated during player filtering.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 06:51:12 +00:00
Your NameandClaude Opus 4.5 06700f54c8 Smart Sendspin/Cast player switching based on group status
- When grouped: show Sendspin version (for sync), hide original Cast
- When ungrouped: show original Cast (for power control + queue)

This preserves power control and proper queue behavior when not syncing,
while still providing seamless Sendspin grouping when needed.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 06:02:27 +00:00
Your NameandClaude Opus 4.5 489e2c3f3a Prefer Sendspin version of Cast players in player list
When a Cast device has Sendspin mode enabled, both the regular Cast
player and the "(Sendspin)" version appear in Music Assistant. This
change:
- Automatically prefers the Sendspin version when both exist
- Removes the "(Sendspin)" suffix for a cleaner display
- Provides seamless transitions when Sendspin becomes available

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 23:06:54 +00:00
Your NameandClaude Opus 4.5 60fb374d0f Fix Sendspin: always report synchronized state, rename timestamp field
- Change playerState from conditional 'synchronized'/'idle' to always 'synchronized'
  (MA only accepts 'synchronized', 'error', 'external_source')
- Rename 'timestamp' to 'client_transmitted' in time messages per protocol spec

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 22:40:58 +00:00
Your NameandClaude Opus 4.5 8946373a28 Reduce grouped border to 1.5px everywhere
Thinner border for a more subtle grouped indicator on:
- Mini player
- Player cards in reveal overlay
- Player selector sheet

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 10:00:55 +00:00
Your NameandClaude Opus 4.5 00560ed1ec Show leader's queue and track for grouped child players
When a player is synced/grouped as a child:
- getQueue() now returns the leader's queue
- getCachedTrackForPlayer() returns the leader's track
- selectPlayer() gets the leader's track from cache

This ensures grouped players show the same queue and track info
as their leader, reflecting the actual audio being played.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 09:40:56 +00:00
Your NameandClaude Opus 4.5 6b13e6e8f7 Add yellow border to mini player when grouped
Shows the same pastel yellow border (#FFF59D) on the mini player
that is shown on grouped player cards in the reveal overlay.
Only visible when collapsed (t < 0.5) to indicate group membership.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 09:38:28 +00:00
Your NameandClaude Opus 4.5 957cc170a8 Fix isGrouped to include child players with syncedTo
Child players (like Dining Room) have empty group_members but have
syncedTo set to the leader. Updated isGrouped to check for both:
- Leader: group_members.length > 1
- Child: syncedTo != null

Both should show the yellow border now.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 09:10:50 +00:00
Your NameandClaude Opus 4.5 91a00c62b0 Add long-press sync to PlayerCard in reveal overlay
The device list uses PlayerCard widget (not _PlayerSelectorSheet).
Added:
- onLongPress callback to PlayerCard
- isGrouped property for pastel yellow border
- Long-press handler in player_reveal_overlay.dart
- SnackBar feedback when long-press detected

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 08:41:39 +00:00
Your NameandClaude Opus 4.5 cb1122277a Add snackbar feedback for long-press sync detection
- Show visual snackbar "Syncing {player}..." when long-press detected
- Remove !isSelected condition to allow sync on any available player
- Helps debug if long-press gesture is being recognized

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 08:39:46 +00:00
Your NameandClaude Opus 4.5 190fa4f61c Add detailed logging for player sync debugging
Logs added at:
- UI: Long-press trigger with player details
- Provider: togglePlayerSync, syncPlayerToSelected
- API: syncPlayerToLeader with request/response

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 08:29:35 +00:00
Your NameandClaude Opus 4.5 2c02473f32 Fix sync API command: use players/cmd/group
The correct MA API command is:
- players/cmd/group (not group_many)
- player_id: the player joining the group
- target_player: the leader player to sync to

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 08:05:52 +00:00
Your NameandClaude Opus 4.5 79962517ae Add temporary player grouping via long-press
Feature: Long-press on a player in the device list to sync it with the
currently selected/playing player, creating a temporary group.

Changes:
- API: Add syncPlayers() and unsyncPlayer() methods using MA commands
  players/cmd/group_many and players/cmd/ungroup
- Model: Add groupMembers and syncedTo fields to Player, with derived
  properties isGrouped, isGroupLeader, isGroupChild
- Provider: Add syncPlayerToSelected(), unsyncPlayer(), togglePlayerSync()
- UI: Long-press gesture on player cards (with haptic feedback)
- UI: Pastel yellow border (#FFF59D) around grouped players
- UI: "Synced" badge with link icon for grouped players

UX Flow:
1. Play music on a speaker (e.g., Kitchen HiFi)
2. Open device selector
3. Long-press another speaker (e.g., Bedroom)
4. Bedroom syncs to Kitchen and both show yellow border
5. Long-press again to unsync

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 07:25:39 +00:00
Your NameandClaude Opus 4.5 8641c89b9b Fix badge count and auto-selection priority logic
- Badge now excludes selected player from count (shows "other" activity)
- Auto-selection now switches to playing player when preferLocalPlayer is OFF
  and current selection is not playing

Addresses feedback from issue #21

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 07:21:27 +00:00
13 changed files with 442 additions and 26 deletions
+1
View File
@@ -126,6 +126,7 @@
"playing": "Spiele {name}",
"playingTrack": "Spiele Song",
"nowPlaying": "Wird gespielt",
"markedAsFinished": "{name} als beendet markiert",
"failedToMarkFinished": "Konnte nicht als beendet markieren: {error}",
+5
View File
@@ -635,6 +635,11 @@
"description": "Status when playing track"
},
"nowPlaying": "Now playing",
"@nowPlaying": {
"description": "Label shown when device selector is open to indicate current player"
},
"markedAsFinished": "{name} marked as finished",
"@markedAsFinished": {
"description": "Audiobook marked finished",
+1
View File
@@ -225,6 +225,7 @@
}
},
"playingTrack": "Reproduciendo canción",
"nowPlaying": "Reproduciendo ahora",
"markedAsFinished": "{name} marcado como terminado",
"@markedAsFinished": {
"placeholders": {
+25
View File
@@ -12,6 +12,8 @@ class Player {
final bool? volumeMuted;
final double? elapsedTime; // Seconds elapsed in current track
final double? elapsedTimeLastUpdated; // Unix timestamp when elapsed_time was last updated
final List<String>? groupMembers; // List of player IDs in sync group (includes self)
final String? syncedTo; // Player ID this player is synced to (null if leader or not synced)
Player({
required this.playerId,
@@ -25,6 +27,8 @@ class Player {
this.volumeMuted,
this.elapsedTime,
this.elapsedTimeLastUpdated,
this.groupMembers,
this.syncedTo,
});
/// Create a copy of this Player with some fields replaced
@@ -40,6 +44,8 @@ class Player {
bool? volumeMuted,
double? elapsedTime,
double? elapsedTimeLastUpdated,
List<String>? groupMembers,
String? syncedTo,
}) {
return Player(
playerId: playerId ?? this.playerId,
@@ -53,6 +59,8 @@ class Player {
volumeMuted: volumeMuted ?? this.volumeMuted,
elapsedTime: elapsedTime ?? this.elapsedTime,
elapsedTimeLastUpdated: elapsedTimeLastUpdated ?? this.elapsedTimeLastUpdated,
groupMembers: groupMembers ?? this.groupMembers,
syncedTo: syncedTo ?? this.syncedTo,
);
}
@@ -61,6 +69,12 @@ class Player {
bool get isMuted => volumeMuted ?? false;
int get volume => volumeLevel ?? 0;
// Group properties
// A player is grouped if it's a leader with members OR a child synced to another
bool get isGrouped => (groupMembers != null && groupMembers!.length > 1) || syncedTo != null;
bool get isGroupLeader => groupMembers != null && groupMembers!.length > 1 && syncedTo == null;
bool get isGroupChild => syncedTo != null;
// Track when this Player object was created (for local interpolation fallback)
static final Map<String, double> _playerCreationTimes = {};
@@ -164,6 +178,13 @@ class Player {
elapsedTime ??= topLevelElapsedTime;
elapsedTimeLastUpdated ??= topLevelLastUpdated;
// Parse group members - MA returns this as a list of player IDs
final groupMembersList = json['group_members'] as List<dynamic>?;
final groupMembers = groupMembersList?.map((e) => e.toString()).toList();
// Parse synced_to - the player ID this player is synced to
final syncedTo = json['synced_to'] as String?;
return Player(
playerId: json['player_id'] as String,
name: json['name'] as String,
@@ -176,6 +197,8 @@ class Player {
volumeMuted: json['volume_muted'] as bool?,
elapsedTime: elapsedTime,
elapsedTimeLastUpdated: elapsedTimeLastUpdated,
groupMembers: groupMembers,
syncedTo: syncedTo,
);
}
@@ -192,6 +215,8 @@ class Player {
if (volumeMuted != null) 'volume_muted': volumeMuted,
if (elapsedTime != null) 'elapsed_time': elapsedTime,
if (elapsedTimeLastUpdated != null) 'elapsed_time_last_updated': elapsedTimeLastUpdated,
if (groupMembers != null) 'group_members': groupMembers,
if (syncedTo != null) 'synced_to': syncedTo,
};
}
}
+230 -9
View File
@@ -56,6 +56,7 @@ class MusicAssistantProvider with ChangeNotifier {
// Player state
Player? _selectedPlayer;
List<Player> _availablePlayers = [];
Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping
Track? _currentTrack;
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
Timer? _playerStateTimer;
@@ -171,11 +172,30 @@ class MusicAssistantProvider with ChangeNotifier {
}
/// Get cached track for a player (used for smooth swipe transitions)
Track? getCachedTrackForPlayer(String playerId) => _cacheService.getCachedTrackForPlayer(playerId);
/// For grouped child players, returns the leader's track
Track? getCachedTrackForPlayer(String playerId) {
// If player is a group child, get the leader's track instead
final player = _availablePlayers.firstWhere(
(p) => p.playerId == playerId,
orElse: () => Player(
playerId: playerId,
name: '',
available: false,
powered: false,
state: 'idle',
),
);
final effectivePlayerId = (player.isGroupChild && player.syncedTo != null)
? player.syncedTo!
: playerId;
return _cacheService.getCachedTrackForPlayer(effectivePlayerId);
}
/// Get artwork URL for a player from cache
String? getCachedArtworkUrl(String playerId, {int size = 512}) {
final track = _cacheService.getCachedTrackForPlayer(playerId);
final track = getCachedTrackForPlayer(playerId);
if (track == null) return null;
return getImageUrl(track, size: size);
}
@@ -2028,6 +2048,80 @@ class MusicAssistantProvider with ChangeNotifier {
return true;
}).toList();
// Smart Sendspin/Cast player switching:
// - When grouped: show Sendspin version (renamed), hide original Cast
// - When ungrouped: show original Cast, hide Sendspin version
// This gives power control and proper queue behavior when not syncing
final sendspinSuffix = ' (Sendspin)';
final sendspinPlayers = _availablePlayers
.where((p) => p.name.endsWith(sendspinSuffix))
.toList();
// Clear the Cast-to-Sendspin ID mapping
_castToSendspinIdMap = {};
if (sendspinPlayers.isNotEmpty) {
// Build maps for Sendspin players and their grouped status
final sendspinByBaseName = <String, Player>{};
final groupedSendspinBaseNames = <String>{};
for (final player in sendspinPlayers) {
final baseName = player.name.substring(0, player.name.length - sendspinSuffix.length);
sendspinByBaseName[baseName] = player;
// Find the corresponding regular Cast player and store the ID mapping
final regularCastPlayer = _availablePlayers.where(
(p) => p.name == baseName && !p.name.endsWith(sendspinSuffix)
).firstOrNull;
if (regularCastPlayer != null) {
_castToSendspinIdMap[regularCastPlayer.playerId] = player.playerId;
_logger.log('🔗 Mapped Cast ID ${regularCastPlayer.playerId} -> Sendspin ID ${player.playerId}');
}
if (player.isGrouped) {
groupedSendspinBaseNames.add(baseName);
_logger.log('🔊 Sendspin player "$baseName" is grouped - will prefer Sendspin version');
} else {
_logger.log('🔊 Sendspin player "$baseName" is ungrouped - will prefer original Cast');
}
}
// Filter players based on grouped status
_availablePlayers = _availablePlayers.where((player) {
final isSendspin = player.name.endsWith(sendspinSuffix);
if (isSendspin) {
final baseName = player.name.substring(0, player.name.length - sendspinSuffix.length);
// Keep Sendspin only if grouped
if (player.isGrouped) {
return true;
} else {
_logger.log('🚫 Hiding ungrouped Sendspin player: ${player.name}');
filteredCount++;
return false;
}
} else {
// For regular players, hide if Sendspin version exists AND is grouped
if (groupedSendspinBaseNames.contains(player.name)) {
_logger.log('🚫 Preferring grouped Sendspin version over: ${player.name}');
filteredCount++;
return false;
}
return true;
}
}).toList();
// Rename remaining Sendspin players to remove the suffix
_availablePlayers = _availablePlayers.map((player) {
if (player.name.endsWith(sendspinSuffix)) {
final cleanName = player.name.substring(0, player.name.length - sendspinSuffix.length);
_logger.log('✨ Renaming "${player.name}" to "$cleanName"');
return player.copyWith(name: cleanName);
}
return player;
}).toList();
}
// Sort players - check if smart sort is enabled
final smartSort = await SettingsService.getSmartSortPlayers();
if (smartSort) {
@@ -2084,14 +2178,35 @@ class MusicAssistantProvider with ChangeNotifier {
}
// Keep currently selected player if still available (and not overridden by prefer local)
// But allow switching to a playing player when preferLocalPlayer is OFF
if (playerToSelect == null && _selectedPlayer != null) {
final stillAvailable = _availablePlayers.any(
(p) => p.playerId == _selectedPlayer!.playerId && p.available,
);
if (stillAvailable) {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == _selectedPlayer!.playerId,
);
// If preferLocalPlayer is OFF, check if we should switch to a playing player
if (!preferLocalPlayer) {
final currentPlayerState = _availablePlayers
.firstWhere((p) => p.playerId == _selectedPlayer!.playerId)
.state;
final currentIsPlaying = currentPlayerState == 'playing';
final playingPlayers = _availablePlayers.where(
(p) => p.state == 'playing' && p.available,
).toList();
// Switch to playing player only if current isn't playing and exactly one other is
if (!currentIsPlaying && playingPlayers.length == 1) {
playerToSelect = playingPlayers.first;
_logger.log('🎵 Switched to playing player: ${playerToSelect?.name}');
}
}
// Keep current selection if no switch happened
if (playerToSelect == null) {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == _selectedPlayer!.playerId,
);
}
}
}
@@ -2162,7 +2277,8 @@ class MusicAssistantProvider with ChangeNotifier {
// 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);
// For grouped child players, this returns the leader's track
_currentTrack = getCachedTrackForPlayer(player.playerId);
// Initialize position tracker for this player
_positionTracker.onPlayerSelected(player.playerId);
@@ -2537,8 +2653,9 @@ class MusicAssistantProvider with ChangeNotifier {
// After preloading, update _currentTrack from cache if it has better data
// This fixes the issue where mini player shows wrong info but device list is correct
// For grouped child players, this gets the leader's track
if (_selectedPlayer != null && _currentTrack != null) {
final cachedTrack = _cacheService.getCachedTrackForPlayer(_selectedPlayer!.playerId);
final cachedTrack = getCachedTrackForPlayer(_selectedPlayer!.playerId);
if (cachedTrack != null && cachedTrack.uri == _currentTrack!.uri) {
final cachedHasImage = cachedTrack.metadata?['images'] != null;
final currentHasImage = _currentTrack!.metadata?['images'] != null;
@@ -2636,7 +2753,8 @@ class MusicAssistantProvider with ChangeNotifier {
if (trackChanged) {
// Check if cached track has better metadata (images, proper artist info)
// This prevents losing album art and artist data when resuming the app
final cachedTrack = _cacheService.getCachedTrackForPlayer(_selectedPlayer!.playerId);
// For grouped child players, this gets the leader's cached track
final cachedTrack = getCachedTrackForPlayer(_selectedPlayer!.playerId);
final cachedHasImage = cachedTrack?.metadata?['images'] != null;
final queueHasImage = queueTrack.metadata?['images'] != null;
// Also check for malformed artist names (e.g., "Artist - Title" format in name)
@@ -3024,7 +3142,26 @@ class MusicAssistantProvider with ChangeNotifier {
}
Future<PlayerQueue?> getQueue(String playerId) async {
final queue = await _api?.getQueue(playerId);
// If this player is a group child, fetch the leader's queue instead
// This ensures grouped players show the same queue as their leader
String effectivePlayerId = playerId;
final player = _availablePlayers.firstWhere(
(p) => p.playerId == playerId,
orElse: () => Player(
playerId: playerId,
name: '',
available: false,
powered: false,
state: 'idle',
),
);
if (player.isGroupChild && player.syncedTo != null) {
_logger.log('🔗 Player $playerId is grouped, fetching leader queue: ${player.syncedTo}');
effectivePlayerId = player.syncedTo!;
}
final queue = await _api?.getQueue(effectivePlayerId);
// Persist queue to database for instant display on app resume
if (queue != null) {
@@ -3280,6 +3417,90 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Sync a player to the currently selected player (temporary group)
/// The target player will play the same audio as the selected player
Future<void> syncPlayerToSelected(String targetPlayerId) async {
try {
final leaderPlayer = _selectedPlayer;
_logger.log('🔗 syncPlayerToSelected: target=$targetPlayerId, leader=${leaderPlayer?.playerId}');
if (leaderPlayer == null) {
_logger.log('❌ Cannot sync: no player selected');
return;
}
if (targetPlayerId == leaderPlayer.playerId) {
_logger.log('❌ Cannot sync player to itself');
return;
}
if (_api == null) {
_logger.log('❌ Cannot sync: API is null');
return;
}
// Translate Cast player ID to Sendspin ID if available
// This is needed because regular Cast players can't sync with Sendspin players
final actualTargetId = _castToSendspinIdMap[targetPlayerId] ?? targetPlayerId;
if (actualTargetId != targetPlayerId) {
_logger.log('🔗 Translated Cast ID to Sendspin ID: $targetPlayerId -> $actualTargetId');
}
_logger.log('🔗 Calling API syncPlayerToLeader($actualTargetId, ${leaderPlayer.playerId})');
await _api!.syncPlayerToLeader(actualTargetId, leaderPlayer.playerId);
_logger.log('✅ API sync call completed');
// Refresh players to get updated group state
_logger.log('🔄 Refreshing players after sync...');
await refreshPlayers();
_logger.log('✅ Players refreshed');
} catch (e) {
_logger.log('❌ syncPlayerToSelected error: $e');
ErrorHandler.logError('Sync player to selected', e);
rethrow;
}
}
/// Remove a player from its sync group
Future<void> unsyncPlayer(String playerId) async {
try {
_logger.log('🔓 Unsyncing player: $playerId');
await _api?.unsyncPlayer(playerId);
// Refresh players to get updated group state
await refreshPlayers();
} catch (e) {
ErrorHandler.logError('Unsync player', e);
rethrow;
}
}
/// Toggle sync state: if player is synced, unsync it; otherwise sync to selected
Future<void> togglePlayerSync(String playerId) async {
_logger.log('🔗 togglePlayerSync called for: $playerId');
try {
final player = _availablePlayers.firstWhere(
(p) => p.playerId == playerId,
orElse: () => throw Exception('Player not found'),
);
_logger.log('🔗 Player found: ${player.name}, isGrouped: ${player.isGrouped}');
_logger.log('🔗 groupMembers: ${player.groupMembers}, syncedTo: ${player.syncedTo}');
if (player.isGrouped) {
_logger.log('🔓 Player is grouped, unsyncing...');
await unsyncPlayer(playerId);
} else {
_logger.log('🔗 Player not grouped, syncing to selected...');
await syncPlayerToSelected(playerId);
}
} catch (e) {
_logger.log('❌ togglePlayerSync error: $e');
rethrow;
}
}
Future<void> togglePower(String playerId) async {
try {
_logger.log('🔋 togglePower called for playerId: $playerId');
+39
View File
@@ -1996,6 +1996,45 @@ class MusicAssistantAPI {
}
}
/// Sync a player to a leader/group (temporary group)
/// The player will join the leader's sync group and play the same audio
Future<void> syncPlayerToLeader(String playerId, String leaderPlayerId) async {
try {
_logger.log('🔗 API syncPlayerToLeader called');
_logger.log('🔗 player_id: $playerId');
_logger.log('🔗 target_player: $leaderPlayerId');
final response = await _sendCommand(
'players/cmd/group',
args: {
'player_id': playerId,
'target_player': leaderPlayerId,
},
);
_logger.log('✅ Player synced successfully, response: $response');
} catch (e) {
_logger.log('❌ Error syncing player: $e');
rethrow;
}
}
/// Remove a player from its sync group
Future<void> unsyncPlayer(String playerId) async {
try {
_logger.log('🔓 Unsyncing player: $playerId');
await _sendCommand(
'players/cmd/ungroup',
args: {
'player_id': playerId,
},
);
_logger.log('✅ Player unsynced successfully');
} catch (e) {
_logger.log('Error unsyncing player: $e');
rethrow;
}
}
/// Toggle shuffle mode for queue
Future<void> toggleShuffle(String queueId) async {
try {
+4 -3
View File
@@ -518,8 +518,9 @@ class SendspinService {
if (muted != null) _isMuted = muted;
// Determine player state for Sendspin protocol
// 'synchronized' = ready/playing, 'idle' = not playing
final playerState = _isPlaying || _isPaused ? 'synchronized' : 'idle';
// Valid values: 'synchronized', 'error', 'external_source'
// When connected, we're always synchronized (ready to receive audio)
const playerState = 'synchronized';
_sendMessage({
'type': 'client/state',
@@ -580,7 +581,7 @@ class SendspinService {
_sendMessage({
'type': 'client/time',
'payload': {
'timestamp': timestampMicroseconds,
'client_transmitted': timestampMicroseconds,
},
}, allowDuringHandshake: allowDuringHandshake);
}
+35 -11
View File
@@ -32,11 +32,16 @@ class ExpandablePlayer extends StatefulWidget {
/// Callback when swipe-down gesture triggers player reveal
final VoidCallback? onRevealPlayers;
/// Whether the device reveal overlay is currently visible
/// When true, shows player name instead of track name in collapsed state
final bool isDeviceRevealVisible;
const ExpandablePlayer({
super.key,
this.slideOffset = 0.0,
this.bounceOffset = 0.0,
this.onRevealPlayers,
this.isDeviceRevealVisible = false,
});
@override
@@ -74,6 +79,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
static const double _bottomNavHeight = 56.0;
static const double _edgeDeadZone = 40.0; // Dead zone for Android back gesture
// Pastel yellow for grouped players (matches PlayerCard.groupBorderColor)
static const Color _groupBorderColor = Color(0xFFFFF59D);
// Track horizontal drag start position
double? _horizontalDragStartX;
@@ -1347,13 +1355,20 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_handleHorizontalDragEnd(details, maProvider);
}
},
child: Material(
color: backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
elevation: _lerpDouble(4, 0, t),
shadowColor: Colors.black.withOpacity(0.3),
clipBehavior: Clip.antiAlias,
child: SizedBox(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(borderRadius),
border: selectedPlayer.isGrouped && t < 0.5
? Border.all(color: _groupBorderColor, width: 1.5)
: null,
),
child: Material(
color: backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
elevation: _lerpDouble(4, 0, t),
shadowColor: Colors.black.withOpacity(0.3),
clipBehavior: Clip.antiAlias,
child: SizedBox(
width: width,
height: height,
child: Stack(
@@ -1468,6 +1483,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
),
// Track title - with slide animation when collapsed
// When device reveal is visible, show player name instead of track name
// Hidden during transition to prevent flash
// FALLBACK: Show if transition active but no peek content available
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
@@ -1478,7 +1494,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
child: SizedBox(
width: titleWidth,
child: Text(
currentTrack.name,
// Show player name when device reveal visible and collapsed
(widget.isDeviceRevealVisible && t < 0.5)
? selectedPlayer.name
: currentTrack.name,
style: TextStyle(
color: textColor,
fontSize: titleFontSize,
@@ -1495,6 +1514,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
),
// Artist/Author name - with slide animation when collapsed
// When device reveal is visible, show "Now Playing" hint
// For audiobooks: show author from audiobook context
// Hidden during transition to prevent flash
// FALLBACK: Show if transition active but no peek content available
@@ -1506,9 +1526,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
child: SizedBox(
width: titleWidth,
child: Text(
maProvider.isPlayingAudiobook
? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor)
: currentTrack.artistsString,
// Show "Now Playing" when device reveal visible and collapsed
(widget.isDeviceRevealVisible && t < 0.5)
? S.of(context)!.nowPlaying
: maProvider.isPlayingAudiobook
? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor)
: currentTrack.artistsString,
style: TextStyle(
color: textColor.withOpacity(t > 0.5 ? 0.7 : MiniPlayerLayout.secondaryTextOpacity),
fontSize: artistFontSize,
@@ -1886,6 +1909,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
),
),
),
),
),
);
}
+15
View File
@@ -1,3 +1,4 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
@@ -362,6 +363,19 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
},
),
),
// Blur backdrop when device selector is open
if (_isRevealVisible)
Positioned.fill(
child: IgnorePointer(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
child: Container(
color: Colors.black.withOpacity(0.1),
),
),
),
),
// Player reveal overlay - renders BELOW mini player so cards slide behind it
if (_isRevealVisible)
PlayerRevealOverlay(
@@ -396,6 +410,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
slideOffset: _slideAnimation.value,
bounceOffset: bounceOffset,
onRevealPlayers: _showPlayerReveal,
isDeviceRevealVisible: _isRevealVisible,
);
},
);
+11
View File
@@ -12,13 +12,18 @@ class PlayerCard extends StatelessWidget {
final String? albumArtUrl;
final bool isSelected;
final bool isPlaying;
final bool isGrouped;
final Color backgroundColor;
final Color textColor;
final VoidCallback onTap;
final VoidCallback? onLongPress;
final VoidCallback? onPlayPause;
final VoidCallback? onSkipNext;
final VoidCallback? onPower;
// Pastel yellow for grouped players
static const Color groupBorderColor = Color(0xFFFFF59D);
const PlayerCard({
super.key,
required this.player,
@@ -26,9 +31,11 @@ class PlayerCard extends StatelessWidget {
this.albumArtUrl,
required this.isSelected,
required this.isPlaying,
this.isGrouped = false,
required this.backgroundColor,
required this.textColor,
required this.onTap,
this.onLongPress,
this.onPlayPause,
this.onSkipNext,
this.onPower,
@@ -43,11 +50,15 @@ class PlayerCard extends StatelessWidget {
return GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: Container(
height: cardHeight,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
border: isGrouped
? Border.all(color: groupBorderColor, width: 1.5)
: null,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
@@ -269,6 +269,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
albumArtUrl: albumArtUrl,
isSelected: false,
isPlaying: isPlaying,
isGrouped: player.isGrouped,
backgroundColor: cardBgColor,
textColor: cardTextColor,
onTap: () {
@@ -276,6 +277,17 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
maProvider.selectPlayer(player);
dismiss();
},
onLongPress: player.available ? () {
HapticFeedback.mediumImpact();
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Syncing ${player.name}...'),
duration: const Duration(seconds: 2),
),
);
maProvider.togglePlayerSync(player.playerId);
} : null,
onPlayPause: () {
if (isPlaying) {
maProvider.pausePlayer(player.playerId);
+63 -2
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -18,8 +19,12 @@ class PlayerSelector extends StatelessWidget {
final availablePlayers = maProvider.availablePlayers;
final colorScheme = Theme.of(context).colorScheme;
// Count players that are currently playing
final playingCount = availablePlayers.where((p) => p.state == 'playing').length;
// Count players that are currently playing (excluding selected player)
// User already sees if selected player is playing, badge shows "other" activity
final selectedPlayerId = selectedPlayer?.playerId;
final playingCount = availablePlayers
.where((p) => p.state == 'playing' && p.playerId != selectedPlayerId)
.length;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
@@ -214,6 +219,11 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
);
}
// Check if this player is part of a sync group
final isGrouped = player.isGrouped;
// Pastel yellow for grouped players
const groupBorderColor = Color(0xFFFFF59D);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InkWell(
@@ -221,6 +231,25 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
maProvider.selectPlayer(player);
Navigator.pop(context);
},
onLongPress: player.available
? () {
// Haptic feedback for sync action
HapticFeedback.mediumImpact();
// Show snackbar for visual feedback
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Syncing ${player.name}...'),
duration: const Duration(seconds: 2),
),
);
// Long-press to sync/unsync player
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
debugPrint('🔗 Player isGrouped: ${player.isGrouped}, groupMembers: ${player.groupMembers}');
maProvider.togglePlayerSync(player.playerId);
}
: null,
borderRadius: BorderRadius.circular(16),
child: Container(
height: 72,
@@ -229,6 +258,9 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
? colorScheme.primary.withOpacity(0.15)
: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(16),
border: isGrouped
? Border.all(color: groupBorderColor, width: 1.5)
: null,
),
child: Row(
children: [
@@ -318,6 +350,35 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
overflow: TextOverflow.ellipsis,
),
),
if (isGrouped) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: groupBorderColor.withOpacity(0.3),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.link_rounded,
size: 10,
color: Colors.amber.shade800,
),
const SizedBox(width: 3),
Text(
'Synced',
style: TextStyle(
color: Colors.amber.shade800,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
if (isSelected) ...[
const SizedBox(width: 8),
Container(
+1 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.4.1-beta+26
version: 2.5.0-beta+27
environment:
sdk: '>=3.0.0 <4.0.0'