Compare commits
17
Commits
@@ -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}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -225,6 +225,7 @@
|
||||
}
|
||||
},
|
||||
"playingTrack": "Reproduciendo canción",
|
||||
"nowPlaying": "Reproduciendo ahora",
|
||||
"markedAsFinished": "{name} marcado como terminado",
|
||||
"@markedAsFinished": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user