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
13 changed files with 322 additions and 86 deletions
+6
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,
);
}
@@ -264,6 +268,7 @@ class Player {
syncedTo: syncedTo,
activeSource: activeSource,
isExternalSource: isExternalSource,
appId: appId,
);
}
@@ -284,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,
};
}
}
+62 -14
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;
@@ -2474,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();
@@ -2768,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');
@@ -2900,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;
@@ -3945,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;
+15 -7
View File
@@ -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
],
),
);
},
),
),
);
}
+14 -6
View File
@@ -551,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(
@@ -574,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(
@@ -585,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,
),
),
@@ -847,6 +853,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
],
],
],
);
},
),
),
);
+15 -7
View File
@@ -468,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(
@@ -540,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(
@@ -552,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,
),
),
@@ -567,7 +573,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
: Center(
child: Icon(
MdiIcons.bookOutline,
size: 120,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
@@ -811,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),
),
],
);
},
),
);
}
+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();
}
+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.2-beta+31
version: 2.7.3-beta
environment:
sdk: '>=3.0.0 <4.0.0'