Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f871f0078 | ||
|
|
573a5f6fe7 | ||
|
|
cb87492c9c | ||
|
|
b1b91e8dca | ||
|
|
ba0f21856a | ||
|
|
218bec9607 | ||
|
|
2d491e1ae9 | ||
|
|
1ab51bbf25 | ||
|
|
5e6da3ec2f | ||
|
|
3894574a05 | ||
|
|
7fe4e553ee | ||
|
|
459eb316b2 | ||
|
|
d3d03fd4d1 | ||
|
|
8f5008b902 | ||
|
|
c3acd0029e | ||
|
|
925feb89b6 |
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user