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? 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 String? activeSource; // The currently active source for this player
|
||||||
final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active
|
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({
|
Player({
|
||||||
required this.playerId,
|
required this.playerId,
|
||||||
@@ -33,6 +34,7 @@ class Player {
|
|||||||
this.syncedTo,
|
this.syncedTo,
|
||||||
this.activeSource,
|
this.activeSource,
|
||||||
this.isExternalSource = false,
|
this.isExternalSource = false,
|
||||||
|
this.appId,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Create a copy of this Player with some fields replaced
|
/// Create a copy of this Player with some fields replaced
|
||||||
@@ -52,6 +54,7 @@ class Player {
|
|||||||
String? syncedTo,
|
String? syncedTo,
|
||||||
String? activeSource,
|
String? activeSource,
|
||||||
bool? isExternalSource,
|
bool? isExternalSource,
|
||||||
|
String? appId,
|
||||||
}) {
|
}) {
|
||||||
return Player(
|
return Player(
|
||||||
playerId: playerId ?? this.playerId,
|
playerId: playerId ?? this.playerId,
|
||||||
@@ -69,6 +72,7 @@ class Player {
|
|||||||
syncedTo: syncedTo ?? this.syncedTo,
|
syncedTo: syncedTo ?? this.syncedTo,
|
||||||
activeSource: activeSource ?? this.activeSource,
|
activeSource: activeSource ?? this.activeSource,
|
||||||
isExternalSource: isExternalSource ?? this.isExternalSource,
|
isExternalSource: isExternalSource ?? this.isExternalSource,
|
||||||
|
appId: appId ?? this.appId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +268,7 @@ class Player {
|
|||||||
syncedTo: syncedTo,
|
syncedTo: syncedTo,
|
||||||
activeSource: activeSource,
|
activeSource: activeSource,
|
||||||
isExternalSource: isExternalSource,
|
isExternalSource: isExternalSource,
|
||||||
|
appId: appId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +289,7 @@ class Player {
|
|||||||
if (syncedTo != null) 'synced_to': syncedTo,
|
if (syncedTo != null) 'synced_to': syncedTo,
|
||||||
if (activeSource != null) 'active_source': activeSource,
|
if (activeSource != null) 'active_source': activeSource,
|
||||||
'is_external_source': isExternalSource,
|
'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 {
|
Future<void> _loadAndSelectPlayers({bool forceRefresh = false, bool coldStart = false}) async {
|
||||||
try {
|
try {
|
||||||
|
// Don't skip on coldStart - we need to apply full auto-selection priority logic
|
||||||
if (!forceRefresh &&
|
if (!forceRefresh &&
|
||||||
|
!coldStart &&
|
||||||
_cacheService.isPlayersCacheValid() &&
|
_cacheService.isPlayersCacheValid() &&
|
||||||
_availablePlayers.isNotEmpty) {
|
_availablePlayers.isNotEmpty) {
|
||||||
return;
|
return;
|
||||||
@@ -2370,8 +2372,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
.firstWhere((p) => p.playerId == _selectedPlayer!.playerId)
|
.firstWhere((p) => p.playerId == _selectedPlayer!.playerId)
|
||||||
.state;
|
.state;
|
||||||
final currentIsPlaying = currentPlayerState == 'playing';
|
final currentIsPlaying = currentPlayerState == 'playing';
|
||||||
|
// Exclude external sources - they're not playing MA content
|
||||||
final playingPlayers = _availablePlayers.where(
|
final playingPlayers = _availablePlayers.where(
|
||||||
(p) => p.state == 'playing' && p.available,
|
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
|
||||||
).toList();
|
).toList();
|
||||||
|
|
||||||
// Switch to playing player only if current isn't playing and exactly one other is
|
// 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) {
|
if (!preferLocalPlayer) {
|
||||||
// Priority 1 (normal): Single playing player (skip if multiple are playing)
|
// Priority 1 (normal): Single playing player (skip if multiple are playing)
|
||||||
|
// Exclude external sources - they're not playing MA content
|
||||||
final playingPlayers = _availablePlayers.where(
|
final playingPlayers = _availablePlayers.where(
|
||||||
(p) => p.state == 'playing' && p.available,
|
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
|
||||||
).toList();
|
).toList();
|
||||||
if (playingPlayers.length == 1) {
|
if (playingPlayers.length == 1) {
|
||||||
playerToSelect = playingPlayers.first;
|
playerToSelect = playingPlayers.first;
|
||||||
@@ -2474,10 +2478,16 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// IMPORTANT: Always set from cache, even if null - this prevents showing
|
// IMPORTANT: Always set from cache, even if null - this prevents showing
|
||||||
// stale track info when switching to a non-playing player.
|
// stale track info when switching to a non-playing player.
|
||||||
// For grouped child players, this returns the leader's track
|
// For grouped child players, this returns the leader's track
|
||||||
|
// IMPORTANT: Clear track for external sources - they're playing non-MA content
|
||||||
|
if (player.isExternalSource) {
|
||||||
|
_currentTrack = null;
|
||||||
|
} else {
|
||||||
_currentTrack = getCachedTrackForPlayer(player.playerId);
|
_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.onPlayerSelected(player.playerId);
|
||||||
|
if (!player.isExternalSource) {
|
||||||
_positionTracker.updateFromServer(
|
_positionTracker.updateFromServer(
|
||||||
playerId: player.playerId,
|
playerId: player.playerId,
|
||||||
position: player.elapsedTime ?? 0.0,
|
position: player.elapsedTime ?? 0.0,
|
||||||
@@ -2485,6 +2495,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
duration: _currentTrack?.duration,
|
duration: _currentTrack?.duration,
|
||||||
serverTimestamp: player.elapsedTimeLastUpdated,
|
serverTimestamp: player.elapsedTimeLastUpdated,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Switch audio handler mode based on player type
|
// Switch audio handler mode based on player type
|
||||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
@@ -2768,7 +2779,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
if (_api == null) return;
|
if (_api == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 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}');
|
_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) {
|
if (!player.available || !player.powered) {
|
||||||
_logger.log('🔍 Preload ${player.name}: SKIPPED - not available or powered');
|
_logger.log('🔍 Preload ${player.name}: SKIPPED - not available or powered');
|
||||||
@@ -2900,6 +2923,28 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_selectedPlayer = updatedPlayer;
|
_selectedPlayer = updatedPlayer;
|
||||||
stateChanged = true;
|
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
|
// Feed position tracker with server data
|
||||||
// Log raw values to debug position issues
|
// Log raw values to debug position issues
|
||||||
final rawElapsedTime = updatedPlayer.elapsedTime;
|
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 {
|
try {
|
||||||
await _api?.toggleShuffle(queueId);
|
await _api?.toggleShuffle(queueId, shuffleEnabled);
|
||||||
|
_logger.log('🔀 toggleShuffle: API call completed');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
_logger.log('🔀 toggleShuffle ERROR: $e');
|
||||||
ErrorHandler.logError('Toggle shuffle', e);
|
ErrorHandler.logError('Toggle shuffle', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -575,10 +575,11 @@ class PlayerProvider with ChangeNotifier {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try playing player (exclude external sources - not playing MA content)
|
||||||
if (playerToSelect == null) {
|
if (playerToSelect == null) {
|
||||||
try {
|
try {
|
||||||
playerToSelect = _availablePlayers.firstWhere(
|
playerToSelect = _availablePlayers.firstWhere(
|
||||||
(p) => p.state == 'playing' && p.available,
|
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
|
||||||
);
|
);
|
||||||
} catch (e) {}
|
} 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;
|
if (_api == null) return;
|
||||||
try {
|
try {
|
||||||
await _api!.toggleShuffle(queueId);
|
await _api!.toggleShuffle(queueId, shuffleEnabled);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ErrorHandler.logError('Toggle shuffle', e);
|
ErrorHandler.logError('Toggle shuffle', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
|
|||||||
@@ -703,10 +703,16 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: colorScheme.background,
|
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: [
|
slivers: [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 350, // Increased height for bigger art
|
expandedHeight: expandedHeight,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.background,
|
backgroundColor: colorScheme.background,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
@@ -727,11 +733,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
child: Hero(
|
child: Hero(
|
||||||
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 280, // Increased size
|
width: coverSize,
|
||||||
height: 280,
|
height: coverSize,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surfaceVariant,
|
color: colorScheme.surfaceVariant,
|
||||||
borderRadius: BorderRadius.circular(16), // Slightly more rounded
|
borderRadius: BorderRadius.circular(16),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.3),
|
color: Colors.black.withOpacity(0.3),
|
||||||
@@ -749,7 +755,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
child: imageUrl == null
|
child: imageUrl == null
|
||||||
? Icon(
|
? Icon(
|
||||||
Icons.album_rounded,
|
Icons.album_rounded,
|
||||||
size: 120,
|
size: coverSize * 0.43,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
@@ -1080,6 +1086,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
),
|
),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player
|
const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -551,10 +551,16 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: colorScheme.background,
|
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: [
|
slivers: [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 300,
|
expandedHeight: expandedHeight,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.background,
|
backgroundColor: colorScheme.background,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
@@ -574,8 +580,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + _heroTagSuffix,
|
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + _heroTagSuffix,
|
||||||
child: ClipOval(
|
child: ClipOval(
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 200,
|
width: coverSize,
|
||||||
height: 200,
|
height: coverSize,
|
||||||
color: colorScheme.surfaceVariant,
|
color: colorScheme.surfaceVariant,
|
||||||
child: imageUrl != null
|
child: imageUrl != null
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
@@ -585,13 +591,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
errorWidget: (_, __, ___) => Icon(
|
errorWidget: (_, __, ___) => Icon(
|
||||||
Icons.person_rounded,
|
Icons.person_rounded,
|
||||||
size: 100,
|
size: coverSize * 0.5,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Icon(
|
: Icon(
|
||||||
Icons.person_rounded,
|
Icons.person_rounded,
|
||||||
size: 100,
|
size: coverSize * 0.5,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -847,6 +853,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -468,10 +468,16 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: colorScheme.surface,
|
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: [
|
slivers: [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 350,
|
expandedHeight: expandedHeight,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
@@ -540,8 +546,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 280,
|
width: coverSize,
|
||||||
height: 280,
|
height: coverSize,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: imageUrl != null
|
child: imageUrl != null
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
@@ -552,14 +558,14 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
placeholder: (_, __) => Center(
|
placeholder: (_, __) => Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
MdiIcons.bookOutline,
|
MdiIcons.bookOutline,
|
||||||
size: 120,
|
size: coverSize * 0.43,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
errorWidget: (_, __, ___) => Center(
|
errorWidget: (_, __, ___) => Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
MdiIcons.bookOutline,
|
MdiIcons.bookOutline,
|
||||||
size: 120,
|
size: coverSize * 0.43,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -567,7 +573,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
: Center(
|
: Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
MdiIcons.bookOutline,
|
MdiIcons.bookOutline,
|
||||||
size: 120,
|
size: coverSize * 0.43,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -811,6 +817,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 140)),
|
const SliverToBoxAdapter(child: SizedBox(height: 140)),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -275,11 +275,17 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colorScheme.surface,
|
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: [
|
slivers: [
|
||||||
// App bar with series cover collage (matches audiobook detail screen)
|
// App bar with series cover collage (matches audiobook detail screen)
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 350,
|
expandedHeight: expandedHeight,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
@@ -292,12 +298,12 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 60),
|
const SizedBox(height: 60),
|
||||||
// Series cover collage - same size as audiobook detail cover
|
// Series cover collage - responsive size
|
||||||
Hero(
|
Hero(
|
||||||
tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
|
tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 280,
|
width: coverSize,
|
||||||
height: 280,
|
height: coverSize,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -449,6 +455,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
|||||||
child: SizedBox(height: BottomSpacing.withMiniPlayer),
|
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:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
|
||||||
import 'debug_logger.dart';
|
import 'debug_logger.dart';
|
||||||
|
import 'settings_service.dart';
|
||||||
|
|
||||||
/// Service to generate unique per-installation player IDs
|
/// Service to generate unique per-device player IDs
|
||||||
/// Simplified to match KMP client pattern: single storage key, lazy generation
|
/// Uses stable device identifiers (ANDROID_ID) combined with MA username
|
||||||
/// This prevents the critical bug where multiple devices of the same model
|
/// to create persistent player IDs that survive storage wipes and support
|
||||||
/// would share the same player ID and trigger playback on all devices
|
/// multiple devices per account
|
||||||
class DeviceIdService {
|
class DeviceIdService {
|
||||||
static const String _keyLocalPlayerId = 'local_player_id';
|
static const String _keyLocalPlayerId = 'local_player_id';
|
||||||
static final _logger = DebugLogger();
|
static final _logger = DebugLogger();
|
||||||
static const _uuid = Uuid();
|
|
||||||
|
|
||||||
/// Get or generate a unique player ID for this installation
|
// Cache stable device ID to avoid repeated platform calls
|
||||||
/// ID is generated once and persisted across app restarts
|
static String? _cachedStableDeviceId;
|
||||||
/// 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();
|
|
||||||
|
|
||||||
// Single source of truth - no complex migration logic
|
/// Get stable device identifier that persists across storage wipes
|
||||||
final existingId = prefs.getString(_keyLocalPlayerId);
|
/// - Android: Uses ANDROID_ID (persists until factory reset)
|
||||||
if (existingId != null && existingId.startsWith('ensemble_')) {
|
/// - iOS: Uses identifierForVendor (persists until app reinstall)
|
||||||
_logger.log('Using existing player ID: $existingId');
|
static Future<String> _getStableDeviceId() async {
|
||||||
return existingId;
|
if (_cachedStableDeviceId != null) {
|
||||||
|
return _cachedStableDeviceId!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new ID on first access
|
final deviceInfo = DeviceInfoPlugin();
|
||||||
final playerId = 'ensemble_${_uuid.v4()}';
|
|
||||||
await prefs.setString(_keyLocalPlayerId, playerId);
|
if (Platform.isAndroid) {
|
||||||
_logger.log('Generated new player ID: $playerId');
|
final android = await deviceInfo.androidInfo;
|
||||||
return playerId;
|
// 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)
|
/// Adopt an existing player ID (used when claiming a ghost player)
|
||||||
@@ -45,4 +108,12 @@ class DeviceIdService {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
return prefs.getString(_keyLocalPlayerId) == null;
|
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?;
|
currentIndex = queueResult['current_index'] as int?;
|
||||||
shuffleEnabled = queueResult['shuffle_enabled'] as bool?;
|
shuffleEnabled = queueResult['shuffle_enabled'] as bool?;
|
||||||
repeatMode = queueResult['repeat_mode'] as String?;
|
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 currentItemData = queueResult['current_item'] as Map<String, dynamic>?;
|
||||||
final currentItemName = currentItemData?['name'] as String?;
|
final currentItemName = currentItemData?['name'] as String?;
|
||||||
@@ -2067,11 +2068,11 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle shuffle mode for queue
|
/// Toggle shuffle mode for queue
|
||||||
Future<void> toggleShuffle(String queueId) async {
|
Future<void> toggleShuffle(String queueId, bool shuffleEnabled) async {
|
||||||
try {
|
try {
|
||||||
await _sendCommand(
|
await _sendCommand(
|
||||||
'player_queues/shuffle',
|
'player_queues/shuffle',
|
||||||
args: {'queue_id': queueId},
|
args: {'queue_id': queueId, 'shuffle_enabled': shuffleEnabled},
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Error toggling shuffle: $e');
|
_logger.log('Error toggling shuffle: $e');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'device_id_service.dart';
|
||||||
|
|
||||||
class SettingsService {
|
class SettingsService {
|
||||||
static const String _keyServerUrl = 'server_url';
|
static const String _keyServerUrl = 'server_url';
|
||||||
@@ -394,12 +395,14 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper to create player name with possessive apostrophe
|
// Helper to create player name with possessive apostrophe
|
||||||
|
// Automatically detects Phone vs Tablet based on screen size
|
||||||
static String _makePlayerName(String ownerName) {
|
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')) {
|
if (ownerName.toLowerCase().endsWith('s')) {
|
||||||
return "$ownerName' Phone";
|
return "$ownerName' $deviceType";
|
||||||
} else {
|
} else {
|
||||||
return "$ownerName's Phone";
|
return "$ownerName's $deviceType";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -345,6 +345,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Show cached queue immediately (if available)
|
// 1. Show cached queue immediately (if available)
|
||||||
|
// 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);
|
final cachedQueue = await maProvider.getCachedQueue(player.playerId);
|
||||||
if (cachedQueue != null && cachedQueue.items.isNotEmpty && mounted) {
|
if (cachedQueue != null && cachedQueue.items.isNotEmpty && mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -354,17 +356,35 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
} else {
|
} else {
|
||||||
setState(() => _isLoadingQueue = true);
|
setState(() => _isLoadingQueue = true);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Fetch fresh queue in background
|
// 2. Fetch fresh queue in background
|
||||||
if (maProvider.api != null) {
|
if (maProvider.api != null) {
|
||||||
try {
|
try {
|
||||||
final freshQueue = await maProvider.getQueue(player.playerId);
|
final freshQueue = await maProvider.getQueue(player.playerId);
|
||||||
if (mounted && freshQueue != null) {
|
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 ||
|
final queueChanged = _queue == null ||
|
||||||
_queue!.items.length != freshQueue.items.length ||
|
_queue!.items.length != freshQueue.items.length ||
|
||||||
_queue!.currentIndex != freshQueue.currentIndex;
|
_queue!.currentIndex != freshQueue.currentIndex ||
|
||||||
|
_queue!.shuffle != freshQueue.shuffle ||
|
||||||
|
itemsReordered;
|
||||||
if (queueChanged) {
|
if (queueChanged) {
|
||||||
|
debugPrint('🔀 Queue changed, updating UI (reordered=$itemsReordered)');
|
||||||
setState(() {
|
setState(() {
|
||||||
_queue = freshQueue;
|
_queue = freshQueue;
|
||||||
_isLoadingQueue = false;
|
_isLoadingQueue = false;
|
||||||
@@ -406,16 +426,69 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleShuffle() async {
|
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>();
|
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();
|
await _loadQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _cycleRepeat() async {
|
Future<void> _cycleRepeat() async {
|
||||||
if (_queue == null) return;
|
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>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
await maProvider.cycleRepeatMode(_queue!.playerId, _queue!.repeatMode);
|
await maProvider.setRepeatMode(_queue!.playerId, nextMode);
|
||||||
await _loadQueue();
|
await _loadQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,12 @@ class PlayerSelector extends StatelessWidget {
|
|||||||
final availablePlayers = maProvider.availablePlayers;
|
final availablePlayers = maProvider.availablePlayers;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
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
|
// 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 selectedPlayerId = selectedPlayer?.playerId;
|
||||||
final playingCount = availablePlayers
|
final playingCount = availablePlayers
|
||||||
.where((p) => p.state == 'playing' && p.playerId != selectedPlayerId)
|
.where((p) => p.state == 'playing' && p.playerId != selectedPlayerId && !p.isExternalSource)
|
||||||
.length;
|
.length;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: ensemble
|
name: ensemble
|
||||||
description: A minimalistic Music Assistant client built with Flutter
|
description: A minimalistic Music Assistant client built with Flutter
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.7.2-beta+31
|
version: 2.7.3-beta
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|||||||
Reference in New Issue
Block a user