Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0568e917b | ||
|
|
2e3f3c1e25 | ||
|
|
e1b18c7f9e | ||
|
|
2e0cb175cb | ||
|
|
d7194d0f2f | ||
|
|
4a94a0b596 | ||
|
|
d28219df98 | ||
|
|
27b2df7e98 | ||
|
|
9682e877fb | ||
|
|
daf624c8bc | ||
|
|
2c2c5e7939 | ||
|
|
a6c35c6263 | ||
|
|
25b074c1e8 |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 874 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 804 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 903 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 686 KiB After Width: | Height: | Size: 498 KiB |
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||