Files
Ensemble/lib/widgets/volume_control.dart
ChrisandClaude Sonnet 4.6 f287fa2819 Fix Sendspin volume sync: use FlutterVolumeController, remove track cache, fix volume UI
- Replace pcmAudioPlayer.setVolumeGain() with FlutterVolumeController.setVolume() for system volume control
- Add notifyListeners() after local volume updates so volume slider stays in sync
- Sync localPlayerVolume from player_updated events to keep UI accurate
- Fix volume_control.dart to read maLocalVolume from provider instead of _systemVolume
- Change supported_roles to 'player@v1' in Sendspin handshake
- Remove track background sync from SyncService (tracks load on-demand)
- Simplify getAlbumTracks/getPlaylistTracks error handling (rethrow instead of swallow)
- Pass album to getAlbumTracks in AlbumDetailsScreen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 19:50:05 +00:00

479 lines
17 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import '../services/settings_service.dart';
/// Volume control widget for Music Assistant players
/// For local device: controls system media volume
/// For MA players: controls player volume via API
class VolumeControl extends StatefulWidget {
final bool compact;
final Color? accentColor;
const VolumeControl({super.key, this.compact = false, this.accentColor});
@override
State<VolumeControl> createState() => _VolumeControlState();
}
class _VolumeControlState extends State<VolumeControl> {
final _logger = DebugLogger();
double? _pendingVolume;
double? _systemVolume;
StreamSubscription? _volumeListener;
String? _localPlayerId;
bool _isLocalPlayer = false;
bool _isDragging = false;
// Live volume update state
int _lastVolumeUpdateTime = 0;
static const int _volumeThrottleMs = 150;
static const int _precisionThrottleMs = 50;
// Precision mode state
bool _inPrecisionMode = false;
Timer? _precisionTimer;
Offset? _lastDragPosition;
double _lastLocalX = 0.0;
bool _precisionModeEnabled = true;
double _precisionZoomCenter = 0.0;
double _precisionStartX = 0.0;
static const int _precisionTriggerMs = 800;
static const double _precisionStillnessThreshold = 5.0;
static const double _precisionSensitivity = 0.1; // 10% range in precision mode
// Button tap indicator state
bool _showButtonIndicator = false;
Timer? _buttonIndicatorTimer;
@override
void initState() {
super.initState();
_initLocalPlayer();
_loadSettings();
}
Future<void> _initLocalPlayer() async {
_localPlayerId = await SettingsService.getBuiltinPlayerId();
final volume = await FlutterVolumeController.getVolume();
if (mounted) {
setState(() {
_systemVolume = volume;
});
}
_volumeListener = FlutterVolumeController.addListener((volume) {
if (mounted && _isLocalPlayer && !_isDragging) {
setState(() {
_systemVolume = volume;
});
context.read<MusicAssistantProvider>().updateLocalPlayerVolume((volume * 100).round());
}
});
}
Future<void> _loadSettings() async {
final precisionEnabled = await SettingsService.getVolumePrecisionMode();
if (mounted) {
setState(() {
_precisionModeEnabled = precisionEnabled;
});
}
}
void _enterPrecisionMode() {
if (_inPrecisionMode) return;
HapticFeedback.mediumImpact();
setState(() {
_inPrecisionMode = true;
_precisionZoomCenter = _pendingVolume ?? 0.5;
_precisionStartX = _lastLocalX;
});
}
void _exitPrecisionMode() {
_precisionTimer?.cancel();
_precisionTimer = null;
if (_inPrecisionMode) {
setState(() {
_inPrecisionMode = false;
});
}
}
@override
void dispose() {
_volumeListener?.cancel();
_precisionTimer?.cancel();
_buttonIndicatorTimer?.cancel();
super.dispose();
}
Future<void> _adjustVolume(MusicAssistantProvider maProvider, String playerId, double currentVolume, int delta) async {
final newVolume = ((currentVolume * 100).round() + delta).clamp(0, 100);
// Show indicator briefly on button tap
_buttonIndicatorTimer?.cancel();
setState(() {
_showButtonIndicator = true;
_pendingVolume = newVolume / 100.0;
});
_buttonIndicatorTimer = Timer(const Duration(milliseconds: 800), () {
if (mounted) {
setState(() {
_showButtonIndicator = false;
_pendingVolume = null;
});
}
});
if (_isLocalPlayer) {
_logger.log('Volume: Setting system volume to $newVolume%');
try {
await FlutterVolumeController.setVolume(newVolume / 100.0);
if (mounted) {
setState(() {
_systemVolume = newVolume / 100.0;
});
}
maProvider.updateLocalPlayerVolume(newVolume);
} catch (e) {
_logger.log('Volume: Error setting system volume - $e');
}
} else {
_logger.log('Volume: Setting to $newVolume%');
try {
await maProvider.setVolume(playerId, newVolume);
} catch (e) {
_logger.log('Volume: Error - $e');
}
}
}
void _sendVolumeUpdate(MusicAssistantProvider maProvider, String playerId, double volume) {
final now = DateTime.now().millisecondsSinceEpoch;
final throttleMs = _inPrecisionMode ? _precisionThrottleMs : _volumeThrottleMs;
if (now - _lastVolumeUpdateTime >= throttleMs) {
_lastVolumeUpdateTime = now;
final volumeLevel = (volume * 100).round();
if (_isLocalPlayer) {
FlutterVolumeController.setVolume(volume);
maProvider.updateLocalPlayerVolume(volumeLevel);
} else {
maProvider.setVolume(playerId, volumeLevel);
}
}
}
@override
Widget build(BuildContext context) {
// Use select to only rebuild when selectedPlayer or localPlayerVolume changes
final player = context.select<MusicAssistantProvider, dynamic>((p) => p.selectedPlayer);
final maLocalVolume = context.select<MusicAssistantProvider, int>((p) => p.localPlayerVolume);
if (player == null) {
return const SizedBox.shrink();
}
_isLocalPlayer = _localPlayerId != null && player.playerId == _localPlayerId;
// Use pending volume during drag or button tap to prevent stale state issues
final currentVolume = (_isDragging || _showButtonIndicator)
? (_pendingVolume ?? (_isLocalPlayer ? maLocalVolume / 100.0 : player.volume.toDouble() / 100.0))
: _isLocalPlayer
? maLocalVolume / 100.0
: player.volume.toDouble() / 100.0;
// Use accent color if provided, otherwise fall back to theme primary
final accentColor = widget.accentColor ?? Theme.of(context).colorScheme.primary;
if (widget.compact) {
return IconButton(
icon: Icon(
currentVolume < 0.01
? Icons.volume_off
: currentVolume < 0.3
? Icons.volume_down
: Icons.volume_up,
color: Colors.white70,
),
onPressed: () async {
// In compact mode, toggle between 0 and 50%
final newVolume = currentVolume < 0.01 ? 50 : 0;
final maProvider = context.read<MusicAssistantProvider>();
await _adjustVolume(maProvider, player.playerId, newVolume / 100.0, 0);
},
);
}
return Row(
children: [
// Volume decrease button
IconButton(
icon: const Icon(
Icons.volume_down,
color: Colors.white70,
),
onPressed: () => _adjustVolume(context.read<MusicAssistantProvider>(), player.playerId, currentVolume, -1),
),
// Slider with floating teardrop indicator
Expanded(
child: SizedBox(
height: 48,
child: LayoutBuilder(
builder: (context, constraints) {
final sliderWidth = constraints.maxWidth;
// Calculate thumb position for the custom slider track
final thumbPosition = sliderWidth * currentVolume.clamp(0.0, 1.0);
return Stack(
clipBehavior: Clip.none,
children: [
// The slider with gesture detection for precision mode
GestureDetector(
onHorizontalDragStart: (details) {
setState(() {
_isDragging = true;
_pendingVolume = currentVolume;
_lastDragPosition = details.globalPosition;
_lastLocalX = details.localPosition.dx;
});
},
onHorizontalDragUpdate: (details) {
if (!_isDragging) return;
final currentPosition = details.globalPosition;
// Check for stillness to trigger precision mode
if (_precisionModeEnabled && _lastDragPosition != null) {
final movement = (currentPosition - _lastDragPosition!).distance;
if (movement < _precisionStillnessThreshold) {
// Finger is still - start precision timer
if (_precisionTimer == null && !_inPrecisionMode) {
_precisionTimer = Timer(
Duration(milliseconds: _precisionTriggerMs),
_enterPrecisionMode,
);
}
} else {
// Finger moved - cancel timer
_precisionTimer?.cancel();
_precisionTimer = null;
}
}
_lastDragPosition = currentPosition;
_lastLocalX = details.localPosition.dx;
double newVolume;
if (_inPrecisionMode) {
// PRECISION MODE: Movement from entry point maps to zoomed range
final offsetX = details.localPosition.dx - _precisionStartX;
final normalizedOffset = offsetX / sliderWidth;
final volumeChange = normalizedOffset * _precisionSensitivity;
newVolume = (_precisionZoomCenter + volumeChange).clamp(0.0, 1.0);
} else {
// NORMAL MODE: Position-based (like standard slider)
newVolume = (details.localPosition.dx / sliderWidth).clamp(0.0, 1.0);
}
if ((newVolume - (_pendingVolume ?? 0)).abs() > 0.001) {
setState(() {
_pendingVolume = newVolume;
});
_sendVolumeUpdate(context.read<MusicAssistantProvider>(), player.playerId, newVolume);
}
},
onHorizontalDragEnd: (details) {
if (!_isDragging) return;
// Send final volume
final finalVolume = _pendingVolume ?? currentVolume;
final volumeLevel = (finalVolume * 100).round();
if (_isLocalPlayer) {
FlutterVolumeController.setVolume(finalVolume);
_systemVolume = finalVolume;
context.read<MusicAssistantProvider>().updateLocalPlayerVolume(volumeLevel);
} else {
context.read<MusicAssistantProvider>().setVolume(player.playerId, volumeLevel);
}
_exitPrecisionMode();
_lastDragPosition = null;
setState(() {
_isDragging = false;
_pendingVolume = null;
});
},
// Custom slider track (no Overlay needed - Flutter 3.38 Slider uses OverlayPortal)
child: SizedBox(
height: 48,
child: Center(
child: SizedBox(
height: 20,
child: Stack(
clipBehavior: Clip.none,
children: [
// Inactive track (full width, thinner)
Positioned(
left: 0,
right: 0,
top: 8.5,
child: Container(
height: 3,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(1.5),
),
),
),
// Active track
Positioned(
left: 0,
top: 7,
child: Container(
width: sliderWidth * currentVolume.clamp(0.0, 1.0),
height: 6,
decoration: BoxDecoration(
color: _inPrecisionMode ? accentColor : Colors.white,
borderRadius: BorderRadius.circular(3),
),
),
),
// Thumb
Positioned(
left: (sliderWidth * currentVolume.clamp(0.0, 1.0)) - 6,
top: 4,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: _inPrecisionMode ? accentColor : Colors.white,
shape: BoxShape.circle,
),
),
),
],
),
),
),
),
),
// Floating teardrop indicator (visible when dragging or button tap)
if (_isDragging || _showButtonIndicator)
Positioned(
left: thumbPosition - 16,
top: -28,
child: CustomPaint(
size: const Size(32, 32),
painter: _TeardropPainter(
color: _inPrecisionMode ? accentColor : Colors.white,
volume: (currentVolume * 100).round(),
),
),
),
],
);
},
),
),
),
// Volume increase button
IconButton(
icon: const Icon(
Icons.volume_up,
color: Colors.white70,
),
onPressed: () => _adjustVolume(context.read<MusicAssistantProvider>(), player.playerId, currentVolume, 1),
),
],
);
}
}
/// Custom painter for upside-down teardrop volume indicator
class _TeardropPainter extends CustomPainter {
final Color color;
final int volume;
_TeardropPainter({required this.color, required this.volume});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 2;
// Draw upside-down teardrop shape
final path = Path();
// Start at bottom point (the tip pointing down toward slider)
path.moveTo(center.dx, size.height - 2);
// Curve up to the left side of the circle
path.quadraticBezierTo(
center.dx - radius * 0.8,
center.dy + radius * 0.3,
center.dx - radius,
center.dy - radius * 0.2,
);
// Arc around the top (the bulb)
path.arcToPoint(
Offset(center.dx + radius, center.dy - radius * 0.2),
radius: Radius.circular(radius),
clockwise: true,
);
// Curve down to the bottom point
path.quadraticBezierTo(
center.dx + radius * 0.8,
center.dy + radius * 0.3,
center.dx,
size.height - 2,
);
path.close();
canvas.drawPath(path, paint);
// Draw the volume number (no % symbol)
final textColor = color.computeLuminance() > 0.5 ? Colors.black87 : Colors.white;
final textPainter = TextPainter(
text: TextSpan(
text: '$volume',
style: TextStyle(
color: textColor,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
// Position text in the center of the bulb (slightly above center)
final textOffset = Offset(
center.dx - textPainter.width / 2,
center.dy - radius * 0.4 - textPainter.height / 2,
);
textPainter.paint(canvas, textOffset);
}
@override
bool shouldRepaint(covariant _TeardropPainter oldDelegate) {
return oldDelegate.color != color || oldDelegate.volume != volume;
}
}