This PR represents a testing and debugging cycle following v3.0.2. Initial work on sync group volume management uncovered multiple UI bugs that required fixes. The final result addresses:
1. **Fix: Group Player Volume Management** - Two distinct volume control issues for Music Assistant sync groups
2. **Feature: Hardware Volume Button Integration** - Phone volume buttons now control Music Assistant player volume from lockscreen / shade widget / app minimised
3. **UI Bug Fixes** - Five UI issues discovered during sync group testing, which had to be fixed to get the sync groups to work without crashes
4. **Fix: Cold Start Homepage Race Condition** - Intermittent empty homepage on app cold start - surfaced once sync group volume calculations were introduced.
5. **Code Quality: Cleanup and Optimization** - Removes dead code and eliminates duplicate logic - mine, not yours, I tried to not to touch your code as much as possible
All changes have been tested and verified with `dart analyze` showing no new issues.
---
**Issue:** Volume control for Music Assistant sync groups had two distinct problems depending on how the group was created:
1. **Server-side groups**: Volume slider would snap back to 0 immediately after release
2. **In-app created groups**: Adjusting the leader's volume only changed that one player, not all members proportionally
**Root Cause:** The Music Assistant API returns `volume_level: null` for certain group players (pre-configured speaker groups with `provider == 'player_group'` and some server-side sync groups). The app had no logic to handle this case, so the null volume was interpreted as 0.
**Solution:** Implemented `GroupVolumeManager` service with dual-tier volume protection:
- **Tier 1 (GroupVolumeManager):** Computes effective volume from group members (detects groups, resolves nested, averages volumes, caches pending for 30s, confirms with ±3 tolerance).
- **Tier 2 (Provider-level):** Optimistic update protection (caches for 8s/30s, prevents stale overwrites).
**Root Cause:** For in-app groups, leader has non-null volume, so `setVolume()` only affects leader, not members.
**Expected behavior:** Leader adjustment changes all members proportionally; individual adjustments affect only that member.
**Solution:** In `setVolume()`, for group leaders with non-null volume:
1. Calculate proportional: `memberNewVol = (memberCurrentVol * newVol / leaderCurrentVol).round()` (handle zero case with absolute).
2. Cache pending, update optimistically, fire parallel API calls.
**Files Changed:**
- `lib/services/group_volume_manager.dart` (NEW - 162 lines)
- `lib/providers/music_assistant_provider.dart` (added manager, helper, updated events/state/volume methods)
---
**Feature:** Phone hardware volume buttons (including lockscreen) control MA player volume, with system HUD mirroring.
**Implementation:**
- **Android Native:** `MainActivity.kt` (~120 lines) - VolumeContentObserver, MethodChannel commands, lifecycle.
- **Flutter Service:** `hardware_volume_service.dart` (~65 lines) - streams, observer methods, sync.
- **Integration:** `main.dart` (~35 lines) - adjust/set/update methods, subscription.
**Why This Matters:** Seamless remote control from lockscreen/tray/buttons.
---
During sync group testing, fixed these UI bugs:
**File:** `lib/main.dart`
**Issue:** Console warnings during init.
**Root Cause:** Init code outside `runZonedGuarded()`.
**Fix:** Move init inside `runZonedGuarded()`.
---
**Files:** `lib/screens/home_screen.dart`, `lib/widgets/global_player_overlay.dart`
**Issue:** Crash on nav interaction.
**Root Cause:** Nav in Stack without Overlay.
**Fix:** Move nav to Scaffold.bottomNavigationBar, add extendBody: true.
---
**Files:** `lib/widgets/artist_card.dart`, `lib/widgets/album_card.dart`
**Issue:** Warnings causing corruption.
**Root Cause:** Service calls in initState trigger setState during build.
**Fix:** Defer with addPostFrameCallback, add mounted guard.
---
**File:** `lib/screens/new_library_screen.dart`
**Issue:** Crash on library tabs.
**Root Cause:** context.select in sliver context.
**Fix:** Use Consumer wrappers for tabs.
---
**File:** `lib/widgets/player/queue_panel.dart`
**Issue:** Crash on long-press buttons.
**Root Cause:** Tooltips require Overlay, but in Stack.
**Fix:** Remove tooltip properties.
---
**File:** `lib/screens/settings_screen.dart`
**Issue:** Back button crashes to black screen.
**Root Cause:** Navigator.pop pops home screen.
**Fix:** Use showPlayer + setSelectedIndex(0).
---
**Issue:** Intermittent empty homepage on cold start.
**Root Cause:** isConnected true before auth complete, leading to failed fetches.
**Solution:** Delay broadcasting 'connected' state until auth if required (`music_assistant_provider.dart:1058-1101`).
---
The `GroupVolumeManager` is a stateful service that handles **server-side groups with null volume**:
1. **Detects group players:**
```dart
bool isGroupPlayer(Player player) {
// Pre-configured MA speaker groups
if (player.provider == 'player_group') return true;
// Dynamic sync groups: have multiple members but MA returns null volume
if (player.groupMembers != null &&
player.groupMembers!.length > 1 &&
player.volumeLevel == null) {
return true;
}
return false;
}
```
2. **Computes effective volume:**
- Recursively resolves nested groups (max depth 3)
- Averages volumes across all leaf members
- Skips self-references in `groupMembers`
- Returns `null` if no members have valid volumes
3. **Caches pending volumes:**
- When user sets volume: stored with timestamp
- Returns cached value if within 30s timeout
- Clears cache when member average converges (±3 tolerance)
- Falls back to computed average after timeout
4. **Integrates with provider:**
- Called from `_applyVolumeProtection()` in provider
- Receives fresh `allPlayers` list for accurate member volumes
- Injects effective volume into `_selectedPlayer` via `copyWith()`
For **in-app created groups with non-null leader volume**, proportional propagation works as follows:
1. **Detection:** `setVolume()` checks if `player.isGroupLeader && !isGroupPlayer(player)`
- Leader has `groupMembers` with 2+ entries
- Leader has its own non-null `volumeLevel`
- NOT detected as a group player (GroupVolumeManager handles those)
2. **Calculation:** For each member in `groupMembers` (excluding leader itself):
```dart
if (leaderCurrentVol > 0) {
memberNewVol = (memberVol * newVol / leaderCurrentVol).round().clamp(0, 100)
} else {
memberNewVol = newVol // Can't ratio from zero
}
```
3. **Execution:**
- Cache `_pendingVolumes[memberId]` and `_pendingVolumeTimestamps[memberId]`
- Update `_availablePlayers[idx]` optimistically
- Fire `_api?.setVolume(memberId, memberNewVol)` without awaiting
- Continue to send leader's own `volume_set` command
4. **Result:** All member volumes scale proportionally with the leader, preserving relative balance.
```
WebSocket connects → MAConnectionState.connected
↓
Auth required?
↙ ↘
YES NO
↓ ↓
Don't notify Set state + notify
Try auth ↓
↓ Initialize
Success?
↙ ↘
YES NO
↓ ↓
auth error state
event + notify
↓
Set state
+ notify
↓
Initialize
```
**Key:** Listeners only see `isConnected = true` after auth completes (or immediately if no auth required).
android/.../MainActivity.kt | 120 + (NEW - ContentObserver)
lib/main.dart | 207 ±
lib/providers/music_assistant_provider.dart | 165 ±
lib/screens/home_screen.dart | 172 ± (BottomNav moved here)
lib/screens/new_library_screen.dart | 70 ± (Consumer wrappers)
lib/screens/settings_screen.dart | 9 ± (back button fix)
lib/services/group_volume_manager.dart | 162 + (NEW)
lib/services/hardware_volume_service.dart | 65 + (NEW)
lib/widgets/album_card.dart | 23 ± (postFrameCallback)
lib/widgets/artist_card.dart | 23 ± (postFrameCallback)
lib/widgets/global_player_overlay.dart | 171 - (BottomNav moved out)
lib/widgets/player/queue_panel.dart | 2 - (tooltip removed)
**Total:** ~650 added, ~300 removed/refactored.
428 lines
14 KiB
Dart
428 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import '../models/media_item.dart';
|
|
import '../providers/music_assistant_provider.dart';
|
|
import '../screens/artist_details_screen.dart';
|
|
import '../constants/hero_tags.dart';
|
|
import '../constants/timings.dart';
|
|
import '../utils/page_transitions.dart';
|
|
import '../services/metadata_service.dart';
|
|
import '../services/debug_logger.dart';
|
|
import '../services/library_status_service.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
import 'media_context_menu.dart';
|
|
import 'library_status_builder.dart';
|
|
|
|
class ArtistCard extends StatefulWidget {
|
|
final Artist artist;
|
|
final VoidCallback? onTap;
|
|
final String? heroTagSuffix;
|
|
/// Image decode size in pixels. Defaults to 256.
|
|
/// Use smaller values (e.g., 128) for list views, larger for grids.
|
|
final int? imageCacheSize;
|
|
|
|
const ArtistCard({
|
|
super.key,
|
|
required this.artist,
|
|
this.onTap,
|
|
this.heroTagSuffix,
|
|
this.imageCacheSize,
|
|
});
|
|
|
|
@override
|
|
State<ArtistCard> createState() => _ArtistCardState();
|
|
}
|
|
|
|
class _ArtistCardState extends State<ArtistCard> with LibraryStatusMixin {
|
|
static final _logger = DebugLogger();
|
|
String? _fallbackImageUrl;
|
|
bool _triedFallback = false;
|
|
bool _maImageFailed = false;
|
|
String? _cachedMaImageUrl;
|
|
Timer? _fallbackTimer;
|
|
bool _isNavigating = false;
|
|
|
|
/// Delay before fetching fallback images to avoid requests during fast scroll
|
|
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
|
|
static const _fallbackDelay = Duration(milliseconds: 400);
|
|
|
|
@override
|
|
String get libraryItemKey => LibraryStatusService.makeKey(
|
|
'artist',
|
|
widget.artist.provider,
|
|
widget.artist.itemId,
|
|
);
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Defer status initialization to after the build frame completes.
|
|
// setLibraryStatus() calls notifyListeners() synchronously, which triggers
|
|
// setState() in listening widgets — illegal during the build phase.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
final service = LibraryStatusService.instance;
|
|
final key = libraryItemKey;
|
|
if (!service.isInLibrary(key) && widget.artist.inLibrary) {
|
|
service.setLibraryStatus(key, true);
|
|
}
|
|
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
|
|
service.setFavoriteStatus(key, true);
|
|
}
|
|
});
|
|
// Fetch fallback image once in initState, not during build
|
|
_initFallbackImage();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_fallbackTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _initFallbackImage() {
|
|
// We'll check if MA has an image after first build, then fetch fallback if needed
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final maImageUrl = maProvider.api?.getImageUrl(widget.artist, size: 256);
|
|
_cachedMaImageUrl = maImageUrl;
|
|
|
|
if (maImageUrl == null && !_triedFallback) {
|
|
_triedFallback = true;
|
|
_logger.debug('No MA image for "${widget.artist.name}", scheduling fallback', context: 'ArtistCard');
|
|
_scheduleFallbackFetch();
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Schedule fallback fetch with delay to avoid requests during fast scroll
|
|
void _scheduleFallbackFetch() {
|
|
_fallbackTimer?.cancel();
|
|
_fallbackTimer = Timer(_fallbackDelay, () {
|
|
if (mounted) {
|
|
_fetchFallbackImage();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _onImageError() {
|
|
// When MA image fails to load, try Deezer fallback
|
|
if (!_triedFallback && !_maImageFailed) {
|
|
_maImageFailed = true;
|
|
_triedFallback = true;
|
|
_logger.debug('MA image failed for "${widget.artist.name}", scheduling fallback', context: 'ArtistCard');
|
|
_scheduleFallbackFetch();
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleFavorite() async {
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final currentFavorite = isFavorite;
|
|
final newState = !currentFavorite;
|
|
|
|
// Optimistic update via centralized service
|
|
setFavoriteStatus(newState);
|
|
|
|
try {
|
|
bool success;
|
|
if (newState) {
|
|
String actualProvider = widget.artist.provider;
|
|
String actualItemId = widget.artist.itemId;
|
|
|
|
if (widget.artist.providerMappings != null && widget.artist.providerMappings!.isNotEmpty) {
|
|
final mapping = widget.artist.providerMappings!.firstWhere(
|
|
(m) => m.available && m.providerInstance != 'library',
|
|
orElse: () => widget.artist.providerMappings!.firstWhere(
|
|
(m) => m.available,
|
|
orElse: () => widget.artist.providerMappings!.first,
|
|
),
|
|
);
|
|
actualProvider = mapping.providerDomain;
|
|
actualItemId = mapping.itemId;
|
|
}
|
|
|
|
success = await maProvider.addToFavorites(
|
|
mediaType: 'artist',
|
|
itemId: actualItemId,
|
|
provider: actualProvider,
|
|
);
|
|
} else {
|
|
int? libraryItemId;
|
|
if (widget.artist.provider == 'library') {
|
|
libraryItemId = int.tryParse(widget.artist.itemId);
|
|
} else if (widget.artist.providerMappings != null) {
|
|
final libraryMapping = widget.artist.providerMappings!.firstWhere(
|
|
(m) => m.providerInstance == 'library',
|
|
orElse: () => widget.artist.providerMappings!.first,
|
|
);
|
|
if (libraryMapping.providerInstance == 'library') {
|
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
|
}
|
|
}
|
|
|
|
if (libraryItemId == null) {
|
|
rollbackFavoriteOperation();
|
|
return;
|
|
}
|
|
|
|
success = await maProvider.removeFromFavorites(
|
|
mediaType: 'artist',
|
|
libraryItemId: libraryItemId,
|
|
);
|
|
}
|
|
|
|
if (success && mounted) {
|
|
completeFavoriteOperation();
|
|
maProvider.invalidateHomeCache();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
|
duration: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
} else {
|
|
rollbackFavoriteOperation();
|
|
}
|
|
} catch (e) {
|
|
rollbackFavoriteOperation();
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleLibrary() async {
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final currentInLibrary = isInLibrary;
|
|
final newState = !currentInLibrary;
|
|
|
|
try {
|
|
if (newState) {
|
|
String? actualProvider;
|
|
String? actualItemId;
|
|
|
|
if (widget.artist.providerMappings != null && widget.artist.providerMappings!.isNotEmpty) {
|
|
final nonLibraryMapping = widget.artist.providerMappings!.where(
|
|
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
|
|
).firstOrNull;
|
|
|
|
if (nonLibraryMapping != null) {
|
|
actualProvider = nonLibraryMapping.providerDomain;
|
|
actualItemId = nonLibraryMapping.itemId;
|
|
}
|
|
}
|
|
|
|
if (actualProvider == null || actualItemId == null) {
|
|
if (widget.artist.provider != 'library') {
|
|
actualProvider = widget.artist.provider;
|
|
actualItemId = widget.artist.itemId;
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Optimistic update via centralized service
|
|
setLibraryStatus(newState);
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(S.of(context)!.addedToLibrary),
|
|
duration: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
}
|
|
|
|
final success = await maProvider.addToLibrary(
|
|
mediaType: 'artist',
|
|
provider: actualProvider,
|
|
itemId: actualItemId,
|
|
);
|
|
|
|
if (success) {
|
|
completeLibraryOperation();
|
|
} else {
|
|
rollbackLibraryOperation();
|
|
}
|
|
} else {
|
|
int? libraryItemId;
|
|
if (widget.artist.provider == 'library') {
|
|
libraryItemId = int.tryParse(widget.artist.itemId);
|
|
} else if (widget.artist.providerMappings != null) {
|
|
final libraryMapping = widget.artist.providerMappings!.firstWhere(
|
|
(m) => m.providerInstance == 'library',
|
|
orElse: () => widget.artist.providerMappings!.first,
|
|
);
|
|
if (libraryMapping.providerInstance == 'library') {
|
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
|
}
|
|
}
|
|
|
|
if (libraryItemId == null) return;
|
|
|
|
// Optimistic update via centralized service
|
|
setLibraryStatus(newState);
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(S.of(context)!.removedFromLibrary),
|
|
duration: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
}
|
|
|
|
final success = await maProvider.removeFromLibrary(
|
|
mediaType: 'artist',
|
|
libraryItemId: libraryItemId,
|
|
);
|
|
|
|
if (success) {
|
|
completeLibraryOperation();
|
|
} else {
|
|
rollbackLibraryOperation();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
rollbackLibraryOperation();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
// Use cached URL if available, otherwise get fresh (but don't trigger fetches during build)
|
|
final maImageUrl = _cachedMaImageUrl ?? maProvider.api?.getImageUrl(widget.artist, size: 256);
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final textTheme = Theme.of(context).textTheme;
|
|
|
|
final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
|
|
|
// Use fallback if MA image failed or wasn't available
|
|
final imageUrl = (_maImageFailed || maImageUrl == null) ? _fallbackImageUrl : maImageUrl;
|
|
|
|
// PERF: Use appropriate cache size based on display size
|
|
final cacheSize = widget.imageCacheSize ?? 256;
|
|
|
|
return RepaintBoundary(
|
|
child: GestureDetector(
|
|
onTap: widget.onTap ?? () {
|
|
// Prevent double-tap navigation
|
|
if (_isNavigating) return;
|
|
_isNavigating = true;
|
|
|
|
// PERF: Color extraction deferred to detail screen's initState
|
|
// to avoid competing with Hero animation for GPU resources
|
|
Navigator.push(
|
|
context,
|
|
FadeSlidePageRoute(
|
|
child: ArtistDetailsScreen(
|
|
artist: widget.artist,
|
|
heroTagSuffix: widget.heroTagSuffix,
|
|
initialImageUrl: imageUrl,
|
|
),
|
|
),
|
|
).then((_) {
|
|
// Reset after navigation debounce delay
|
|
Future.delayed(Timings.navigationDebounce, () {
|
|
if (mounted) _isNavigating = false;
|
|
});
|
|
});
|
|
},
|
|
onLongPressStart: (details) {
|
|
MediaContextMenu.show(
|
|
context: context,
|
|
position: details.globalPosition,
|
|
mediaType: ContextMenuMediaType.artist,
|
|
item: widget.artist,
|
|
isFavorite: isFavorite,
|
|
isInLibrary: isInLibrary,
|
|
onToggleFavorite: _toggleFavorite,
|
|
onToggleLibrary: _toggleLibrary,
|
|
);
|
|
},
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
// Artist image - circular
|
|
// PERF: Use AspectRatio instead of LayoutBuilder to provide fixed geometry
|
|
// before Hero animation starts. This prevents grey icon flash caused by
|
|
// dynamic sizing inside the Hero widget.
|
|
AspectRatio(
|
|
aspectRatio: 1.0,
|
|
child: Hero(
|
|
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
|
|
child: ClipOval(
|
|
child: Container(
|
|
color: colorScheme.surfaceVariant,
|
|
child: imageUrl != null
|
|
? CachedNetworkImage(
|
|
imageUrl: imageUrl,
|
|
fit: BoxFit.cover,
|
|
memCacheWidth: cacheSize,
|
|
memCacheHeight: cacheSize,
|
|
fadeInDuration: Duration.zero,
|
|
fadeOutDuration: Duration.zero,
|
|
placeholder: (context, url) => const SizedBox(),
|
|
errorWidget: (context, url, error) {
|
|
// Try fallback on error (only for MA URLs, not fallback URLs)
|
|
if (!_maImageFailed && url == maImageUrl) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_onImageError();
|
|
});
|
|
}
|
|
return Center(
|
|
child: Icon(
|
|
Icons.person_rounded,
|
|
size: 64,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
);
|
|
},
|
|
)
|
|
: Center(
|
|
child: Icon(
|
|
Icons.person_rounded,
|
|
size: 64,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
// Artist name - fixed height container so image size is consistent
|
|
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
|
SizedBox(
|
|
height: 36, // Fixed height for 2 lines of text
|
|
child: Text(
|
|
widget.artist.name,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.center,
|
|
style: textTheme.titleSmall?.copyWith(
|
|
color: colorScheme.onSurface,
|
|
fontWeight: FontWeight.w500,
|
|
height: 1.15,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _fetchFallbackImage() async {
|
|
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
|
|
if (fallbackUrl != null && mounted) {
|
|
_logger.debug('Found fallback image for "${widget.artist.name}"', context: 'ArtistCard');
|
|
setState(() {
|
|
_fallbackImageUrl = fallbackUrl;
|
|
});
|
|
}
|
|
}
|
|
}
|