Compare commits

..
Author SHA1 Message Date
Your Name f0568e917b Merge feature/hardware-volume-control: Hardware volume button support 2025-12-11 23:31:00 +00:00
Your NameandClaude 2e3f3c1e25 Fix hardware volume for both MA players and builtin player
- Add dynamic volume interception toggling based on selected player
- MA player selected: intercept hardware buttons, control via API
- Builtin player selected: disable interception, let native volume work
- Use FlutterVolumeController for builtin player UI slider and MA commands

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 23:18:13 +00:00
Your NameandClaude Opus 4.5 e1b18c7f9e fix: Handle 'set_volume' event type for builtin player
MA server sends events with type 'set_volume' and 'volume' key,
but we were only checking for 'volume_set' and 'volume_level'.
Now handles both variations so hardware volume buttons properly
adjust the local audio volume for the builtin player.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 22:34:28 +00:00
Your NameandClaude Opus 4.5 2e0cb175cb fix: Track builtin player volume to prevent periodic state reset
The periodic state reporting was using just_audio player volume
(always 100%) instead of the MA-tracked volume. Now using a separate
_localPlayerVolume variable that updates when:
- volume_set event is received from MA server
- setVolume is called for the builtin player

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 22:20:10 +00:00
Your NameandClaude Opus 4.5 d7194d0f2f fix: Use MainActivity extending AudioServiceActivity for volume buttons
The AndroidManifest was using AudioServiceActivity directly, which meant
our custom MainActivity with volume button interception wasn't being used.

Changes:
- MainActivity now extends AudioServiceActivity (for audio_service support)
- AndroidManifest updated to reference .MainActivity instead of AudioServiceActivity
- This allows both background audio AND volume button interception to work

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 21:58:25 +00:00
Your NameandClaude Opus 4.5 4a94a0b596 fix: Use dispatchKeyEvent instead of onKeyDown for volume buttons
Flutter's engine uses dispatchKeyEvent which may consume key events
before they reach onKeyDown. Switching to dispatchKeyEvent ensures
we intercept volume button presses before Flutter processes them.

Also consume ACTION_UP events for volume keys to fully prevent
system volume from changing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 21:47:26 +00:00
Your NameandClaude Opus 4.5 d28219df98 debug: Add step-by-step logging to trace volume init
- Add numbered steps [1/4] through [4/4] in main.dart init
- Add comprehensive logging in HardwareVolumeService.init()
- Wrap initialization in try-catch to capture any errors
- Log each stage of MethodChannel setup

This helps diagnose why volume initialization logs aren't appearing
in bug reports.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 21:33:08 +00:00
Your Name 27b2df7e98 debug: Add comprehensive logging for volume button tracing 2025-12-11 21:17:02 +00:00
Your Name 9682e877fb fix: Use correct Player property name (name not displayName) 2025-12-11 21:04:27 +00:00
Your NameandClaude Opus 4.5 daf624c8bc debug: Add logging to hardware volume control
Add detailed logging to diagnose why volume buttons aren't working
for remote players. This will help identify if:
- Volume button events are being captured
- Selected player is available
- API calls are succeeding

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 20:55:29 +00:00
Your NameandClaude Opus 4.5 2c2c5e7939 feat: Add hardware volume button control for selected player
Intercepts hardware volume up/down buttons and routes them to the
currently selected Music Assistant player instead of changing device
volume. Uses platform channel to capture volume key events at the
Android native level.

- Add HardwareVolumeService for Flutter/Android MethodChannel communication
- Update MainActivity.kt to intercept KEYCODE_VOLUME_UP/DOWN
- Integrate volume control in main.dart with 5% step size

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 20:43:41 +00:00
Your Name a6c35c6263 docs: reframe AI disclaimer in README 2025-12-11 17:26:34 +00:00
Your NameandClaude Opus 4.5 25b074c1e8 docs: update README features and screenshots
- Add Home Screen section (customizable rows, favorites, adaptive layout)
- Update Library Browsing (playlists, favorites filter, artist details)
- Update screenshots to reflect current UI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 17:20:58 +00:00
12 changed files with 323 additions and 10 deletions
+11 -2
View File
@@ -11,7 +11,7 @@
## Disclaimer
I am not a developer. This application was created using **Claude Code** and **Gemini CLI**.
This application was built with AI-assisted development using **Claude Code** and **Gemini CLI**.
---
@@ -28,9 +28,18 @@ I am not a developer. This application was created using **Claude Code** and **G
- **Full Playback Controls** - Play, pause, skip, seek, and adjust volume
- **Queue Management** - View and manage the playback queue
### Home Screen
- **Customizable Rows** - Toggle Recently Played, Discover Artists, and Discover Albums
- **Favorites Rows** - Optional rows for Favorite Albums, Artists, and Tracks
- **Adaptive Layout** - Rows scale properly for different screen sizes and aspect ratios
- **Pull to Refresh** - Refresh content with a simple pull gesture
### Library Browsing
- **Browse Your Collection** - Artists, albums, and tracks from all your music sources
- **Browse Your Collection** - Artists, albums, playlists, and tracks from all your music sources
- **Favorites Filter** - Toggle to show only your favorite items
- **Album Details** - View track listings with artwork
- **Artist Details** - View artist albums and top tracks
- **Playlist Support** - Browse and play your playlists
- **Search** - Find music across your entire library
### Smart Features
+1 -1
View File
@@ -14,7 +14,7 @@
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name="com.ryanheise.audioservice.AudioServiceActivity"
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
@@ -1,6 +1,87 @@
package com.musicassistant.music_assistant
import io.flutter.embedding.android.FlutterActivity
import android.util.Log
import android.view.KeyEvent
import com.ryanheise.audioservice.AudioServiceActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity: FlutterActivity() {
// Extend AudioServiceActivity instead of FlutterActivity to support audio_service package
// while also intercepting volume button events
class MainActivity: AudioServiceActivity() {
private val TAG = "EnsembleVolume"
private val CHANNEL = "com.musicassistant.music_assistant/volume_buttons"
private var methodChannel: MethodChannel? = null
private var isListening = false
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Log.d(TAG, "Configuring Flutter engine, setting up MethodChannel")
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
methodChannel?.setMethodCallHandler { call, result ->
Log.d(TAG, "Received method call: ${call.method}")
when (call.method) {
"startListening" -> {
isListening = true
Log.d(TAG, "Volume listening ENABLED")
result.success(null)
}
"stopListening" -> {
isListening = false
Log.d(TAG, "Volume listening DISABLED")
result.success(null)
}
else -> {
Log.d(TAG, "Unknown method: ${call.method}")
result.notImplemented()
}
}
}
}
// Use dispatchKeyEvent instead of onKeyDown - Flutter's engine uses dispatchKeyEvent
// and may consume events before they reach onKeyDown
override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
if (event == null) {
return super.dispatchKeyEvent(event)
}
val keyCode = event.keyCode
val action = event.action
Log.d(TAG, "dispatchKeyEvent: keyCode=$keyCode, action=$action, isListening=$isListening")
// Only handle KEY_DOWN events to avoid double-triggering (down + up)
if (action != KeyEvent.ACTION_DOWN) {
// For volume keys when listening, also consume ACTION_UP to fully block system volume
if (isListening && (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN)) {
Log.d(TAG, "Consuming ACTION_UP for volume key")
return true
}
return super.dispatchKeyEvent(event)
}
if (!isListening) {
Log.d(TAG, "Not listening, passing to super")
return super.dispatchKeyEvent(event)
}
return when (keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> {
Log.d(TAG, "VOLUME UP pressed - sending to Flutter")
methodChannel?.invokeMethod("volumeUp", null)
true // Consume the event to prevent system volume change
}
KeyEvent.KEYCODE_VOLUME_DOWN -> {
Log.d(TAG, "VOLUME DOWN pressed - sending to Flutter")
methodChannel?.invokeMethod("volumeDown", null)
true // Consume the event to prevent system volume change
}
else -> {
Log.d(TAG, "Other key, passing to super")
super.dispatchKeyEvent(event)
}
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 804 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 686 KiB

After

Width:  |  Height:  |  Size: 498 KiB

+92
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
@@ -10,6 +11,7 @@ import 'services/settings_service.dart';
import 'services/audio/massiv_audio_handler.dart';
import 'services/auth/auth_manager.dart';
import 'services/debug_logger.dart';
import 'services/hardware_volume_service.dart';
import 'services/music_assistant_api.dart' show MAConnectionState;
import 'theme/theme_provider.dart';
import 'theme/app_theme.dart';
@@ -71,6 +73,14 @@ class MusicAssistantApp extends StatefulWidget {
class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindingObserver {
late MusicAssistantProvider _musicProvider;
late ThemeProvider _themeProvider;
final _hardwareVolumeService = HardwareVolumeService();
StreamSubscription? _volumeUpSub;
StreamSubscription? _volumeDownSub;
String? _lastSelectedPlayerId;
String? _builtinPlayerId;
// Volume step size (percentage points per button press)
static const int _volumeStep = 5;
@override
void initState() {
@@ -78,10 +88,92 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
_musicProvider = MusicAssistantProvider();
_themeProvider = ThemeProvider();
WidgetsBinding.instance.addObserver(this);
_initHardwareVolumeControl();
// Listen to player selection changes to toggle volume interception
_musicProvider.addListener(_onProviderChanged);
}
Future<void> _initHardwareVolumeControl() async {
try {
_logger.log('🔊 [1/5] Starting hardware volume control initialization...');
// Get builtin player ID for detecting when to disable interception
_builtinPlayerId = await SettingsService.getBuiltinPlayerId();
_logger.log('🔊 [2/5] Builtin player ID: $_builtinPlayerId');
_logger.log('🔊 [3/5] Calling HardwareVolumeService.init()...');
await _hardwareVolumeService.init();
_logger.log('🔊 [4/5] HardwareVolumeService.init() completed, isListening=${_hardwareVolumeService.isListening}');
_volumeUpSub = _hardwareVolumeService.onVolumeUp.listen((_) {
_logger.log('🔊 Volume UP event received in main.dart');
_adjustVolume(_volumeStep);
});
_volumeDownSub = _hardwareVolumeService.onVolumeDown.listen((_) {
_logger.log('🔊 Volume DOWN event received in main.dart');
_adjustVolume(-_volumeStep);
});
_logger.log('🔊 [5/5] Hardware volume control fully initialized');
} catch (e, stack) {
_logger.error('Hardware volume control initialization FAILED', context: 'VolumeInit', error: e, stackTrace: stack);
}
}
/// Called when provider state changes - check if selected player changed
void _onProviderChanged() {
final currentPlayerId = _musicProvider.selectedPlayer?.playerId;
if (currentPlayerId != _lastSelectedPlayerId) {
_lastSelectedPlayerId = currentPlayerId;
_updateVolumeInterception();
}
}
/// Enable/disable volume button interception based on selected player.
/// For builtin player: disable interception so native volume works
/// For MA players: enable interception to control player via API
Future<void> _updateVolumeInterception() async {
final isBuiltinPlayer = _builtinPlayerId != null &&
_musicProvider.selectedPlayer?.playerId == _builtinPlayerId;
if (isBuiltinPlayer) {
_logger.log('🔊 Builtin player selected - disabling volume interception');
await _hardwareVolumeService.setIntercepting(false);
} else {
_logger.log('🔊 MA player selected - enabling volume interception');
await _hardwareVolumeService.setIntercepting(true);
}
}
Future<void> _adjustVolume(int delta) async {
final player = _musicProvider.selectedPlayer;
if (player == null) {
_logger.log('🔊 Hardware volume button pressed but no player selected');
return;
}
_logger.log('🔊 Hardware volume: player=${player.name}, current=${player.volume}, delta=$delta');
final newVolume = (player.volume + delta).clamp(0, 100);
if (newVolume != player.volume) {
try {
await _musicProvider.setVolume(player.playerId, newVolume);
_logger.log('🔊 Hardware volume adjusted: ${player.volume} -> $newVolume for ${player.name}');
} catch (e) {
_logger.error('Hardware volume adjustment failed', context: 'VolumeControl', error: e);
}
} else {
_logger.log('🔊 Hardware volume: already at limit ($newVolume)');
}
}
@override
void dispose() {
_musicProvider.removeListener(_onProviderChanged);
_volumeUpSub?.cancel();
_volumeDownSub?.cancel();
_hardwareVolumeService.dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
+26 -5
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
import '../models/media_item.dart';
import '../models/player.dart';
import '../services/music_assistant_api.dart';
@@ -50,6 +51,7 @@ class MusicAssistantProvider with ChangeNotifier {
// Local player state
bool _isLocalPlayerPowered = true;
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
bool _builtinPlayerAvailable = true; // False on MA 2.7.0b20+ (uses Sendspin instead)
StreamSubscription? _localPlayerEventSubscription;
StreamSubscription? _playerUpdatedEventSubscription;
@@ -491,7 +493,9 @@ class MusicAssistantProvider with ChangeNotifier {
final isPlaying = _localPlayer.isPlaying;
final position = _localPlayer.position.inSeconds;
final volume = (_localPlayer.volume * 100).round();
// Use tracked MA volume instead of just_audio player volume
// just_audio volume is for local playback, MA volume is for server sync
final volume = _localPlayerVolume;
final isPaused = !isPlaying && position > 0;
await _api!.updateBuiltinPlayerState(
@@ -501,7 +505,7 @@ class MusicAssistantProvider with ChangeNotifier {
paused: isPaused,
position: position,
volume: volume,
muted: _localPlayer.volume == 0.0,
muted: _localPlayerVolume == 0,
);
}
@@ -590,9 +594,14 @@ class MusicAssistantProvider with ChangeNotifier {
break;
case 'volume_set':
final volume = event['volume_level'] as int?;
case 'set_volume':
// MA sends 'set_volume' in event type but 'volume' in data
final volume = event['volume_level'] as int? ?? event['volume'] as int?;
if (volume != null) {
await _localPlayer.setVolume(volume / 100.0);
_localPlayerVolume = volume; // Track MA volume
// Use device media volume (FlutterVolumeController), NOT just_audio software volume
await FlutterVolumeController.setVolume(volume / 100.0);
_logger.log('🔊 Builtin player DEVICE volume set to ${(volume / 100.0).toStringAsFixed(2)} from server event');
}
break;
@@ -1700,7 +1709,19 @@ class MusicAssistantProvider with ChangeNotifier {
Future<void> setVolume(String playerId, int volumeLevel) async {
try {
await _api?.setVolume(playerId, volumeLevel);
// Check if this is the builtin player
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && playerId == builtinPlayerId) {
// For builtin player, control device media volume directly
_localPlayerVolume = volumeLevel;
await FlutterVolumeController.setVolume(volumeLevel / 100.0);
_logger.log('🔊 Builtin player DEVICE volume set to $volumeLevel%');
// Also notify MA server about the volume change
await _api?.setVolume(playerId, volumeLevel);
} else {
// For MA players, just send to MA server
await _api?.setVolume(playerId, volumeLevel);
}
} catch (e) {
ErrorHandler.logError('Set volume', e);
rethrow;
+110
View File
@@ -0,0 +1,110 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'debug_logger.dart';
/// Service to intercept hardware volume button presses
/// and route them to the selected Music Assistant player.
///
/// For MA players: intercept buttons and send volume commands via API
/// For builtin player: let native volume work (don't intercept)
class HardwareVolumeService {
static final HardwareVolumeService _instance = HardwareVolumeService._internal();
factory HardwareVolumeService() => _instance;
HardwareVolumeService._internal();
static const _channel = MethodChannel('com.musicassistant.music_assistant/volume_buttons');
final _logger = DebugLogger();
final _volumeUpController = StreamController<void>.broadcast();
final _volumeDownController = StreamController<void>.broadcast();
Stream<void> get onVolumeUp => _volumeUpController.stream;
Stream<void> get onVolumeDown => _volumeDownController.stream;
bool _isListening = false;
bool get isListening => _isListening;
bool _isIntercepting = false;
bool get isIntercepting => _isIntercepting;
/// Initialize the service and start listening for volume button events
Future<void> init() async {
_logger.log('🔊 HardwareVolumeService.init() called, _isListening=$_isListening');
if (_isListening) {
_logger.log('🔊 HardwareVolumeService.init() - already listening, returning early');
return;
}
_logger.log('🔊 Setting up MethodChannel handler for volume events...');
_channel.setMethodCallHandler((call) async {
_logger.log('🔊 MethodChannel received call: ${call.method}');
switch (call.method) {
case 'volumeUp':
_logger.log('🔊 Hardware VOLUME UP pressed - broadcasting event');
_volumeUpController.add(null);
break;
case 'volumeDown':
_logger.log('🔊 Hardware VOLUME DOWN pressed - broadcasting event');
_volumeDownController.add(null);
break;
default:
_logger.log('🔊 Unknown method call: ${call.method}');
}
});
_logger.log('🔊 MethodChannel handler set up');
try {
_logger.log('🔊 Invoking startListening on native channel...');
await _channel.invokeMethod('startListening');
_isListening = true;
_isIntercepting = true;
_logger.log('🔊 Native startListening succeeded, _isListening=$_isListening');
} catch (e) {
_logger.error('Failed to start volume button listening', context: 'VolumeService', error: e);
}
}
/// Enable or disable volume button interception.
/// When disabled, hardware volume buttons control device volume normally.
/// When enabled, volume button events are sent to Flutter for MA player control.
Future<void> setIntercepting(bool intercept) async {
if (!_isListening) {
_logger.log('🔊 setIntercepting($intercept) called but not listening yet');
return;
}
if (_isIntercepting == intercept) {
_logger.log('🔊 setIntercepting($intercept) - already in that state');
return;
}
try {
if (intercept) {
await _channel.invokeMethod('startListening');
_isIntercepting = true;
_logger.log('🔊 Volume interception ENABLED - will capture buttons for MA players');
} else {
await _channel.invokeMethod('stopListening');
_isIntercepting = false;
_logger.log('🔊 Volume interception DISABLED - native volume buttons work normally');
}
} catch (e) {
_logger.error('Failed to set volume interception to $intercept', context: 'VolumeService', error: e);
}
}
/// Stop listening for volume button events
Future<void> dispose() async {
if (!_isListening) return;
try {
await _channel.invokeMethod('stopListening');
_isListening = false;
_isIntercepting = false;
_logger.info('Hardware volume button listening stopped', context: 'VolumeService');
} catch (e) {
_logger.error('Failed to stop volume button listening', context: 'VolumeService', error: e);
}
}
}