Compare commits

..
Author SHA1 Message Date
Chris cf0336e99d Bump version to 3.0.5+57 2026-05-05 06:45:09 +01:00
ChrisandClaude Opus 4.7 73fd1dbdcd Add per-ABI versionCode override for F-Droid split builds
Required by F-Droid for Flutter apps using --split-per-abi: each
per-ABI APK gets a unique versionCode = base * 10 + abiCode
(armeabi-v7a=1, arm64-v8a=2, x86_64=3).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 06:08:46 +01:00
Chris 2eb22cf524 Add new sponsor to README
Added a sponsor section with a new sponsor link.
2026-02-24 15:28:20 +00:00
ChrisandClaude Opus 4.6 3fe7a5800a Reduce log noise and add AA diagnostic logging
Downgrade high-frequency polling logs to debug level (PositionTracker,
Sendspin state, queue metadata, received messages) to prevent the
1000-entry log cap from rotating out useful entries. Add logging when
AA provider is not set or when it becomes available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 06:06:28 +00:00
ChrisandClaude Opus 4.6 972690b894 Add QueueHandler mixin for Android Auto queue management
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 21:28:03 +00:00
ChrisandClaude Opus 4.6 0e5679262c Add Android Auto unknown sources setup steps to README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 07:19:16 +00:00
ChrisandClaude Opus 4.6 67bae29f9d Add Android Auto to features and setup in README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 07:17:36 +00:00
ChrisandClaude Opus 4.6 4f7fbe92d9 Remove duplicate contributors section from README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 07:14:14 +00:00
9 changed files with 54 additions and 25 deletions
+19 -4
View File
@@ -86,6 +86,13 @@ This application was built with AI-assisted development using **Claude Code** an
- **Skip Controls** - Skip forward/backward during playback
- **High-Resolution Artwork** - Fetched via iTunes for best quality
### Android Auto
- **Media Browsing** - Browse your library from your car's display with categories for Home, Music, Audiobooks, Podcasts, and Radio
- **Voice Search** - Find and play tracks using voice commands
- **Full Playback Controls** - Play, pause, and skip from your car's head unit
- **Album Art & Metadata** - Track info and artwork displayed on your car's screen
- **Smart Queueing** - Tap any track in an album or playlist to play from that point with the full queue
### Smart Features
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
- **Auto-Reconnect** - Automatically reconnects when connection is lost
@@ -141,6 +148,17 @@ If you run Music Assistant as a Home Assistant add-on:
- Use the IP address of your Home Assistant server
- Do **not** use your Home Assistant URL or ingress URL
### Android Auto
Since Ensemble is not installed from the Play Store, you need to enable unknown sources in Android Auto:
1. Open **Android Auto** settings on your phone
2. Tap the **Version** number at the bottom 10 times to enable developer mode
3. Tap the three-dot menu and open **Developer settings**
4. Enable **Unknown sources**
5. Connect your phone to your car via USB or wirelessly
6. Ensemble will appear in Android Auto's app list
### Remote Access
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Traefik, Nginx Proxy Manager, Cloudflare Tunnel).
@@ -157,10 +175,7 @@ For access outside your home network, you'll need to expose Music Assistant thro
A huge thank you to my sponsors! 💖
<a href="https://github.com/pcsokonay"><img src="https://github.com/pcsokonay.png" width="60px" alt="pcsokonay" /></a>
## Contributors
<a href="https://github.com/antoinevandenhurk"><img src="https://github.com/antoinevandenhurk.png" width="60px" alt="antoinevandenhurk" /></a>
<a href="https://github.com/githubchb"><img src="https://github.com/githubchb.png" width="60px" alt="githubchb" /></a>
## License
+10
View File
@@ -77,6 +77,16 @@ android {
}
}
def abiCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86_64': 3]
android.applicationVariants.configureEach { variant ->
variant.outputs.each { output ->
def abiVersionCode = abiCodes[output.getFilter('ABI')]
if (abiVersionCode != null) {
output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode
}
}
}
flutter {
source '../..'
}
+4 -4
View File
@@ -3063,7 +3063,7 @@ class MusicAssistantProvider with ChangeNotifier {
_cacheService.setCachedTrackForPlayer(playerId, mergedTrack);
_logger.log('📋 Merged album info into existing track for $playerName: ${albumName}');
} else {
_logger.log('📋 Skipped caching for $playerName - already have better data (artist: $existingHasProperArtist, image: $existingHasImage)');
_logger.debug('📋 Skipped caching for $playerName - already have better data (artist: $existingHasProperArtist, image: $existingHasImage)');
}
// For selected player, _updatePlayerState() is already called above which fetches queue data
@@ -4556,7 +4556,7 @@ class MusicAssistantProvider with ChangeNotifier {
return;
}
// Sendspin PCM player - continue to start timer
_logger.log('🔔 Starting notification timer for Sendspin PCM player');
_logger.debug('🔔 Starting notification timer for Sendspin PCM player');
}
// Only run timer if player is playing
@@ -4800,7 +4800,7 @@ class MusicAssistantProvider with ChangeNotifier {
/// Called when playback stops (player state becomes idle)
void _startIdleServiceTimer() {
_idleServiceTimer?.cancel();
_logger.log('⏱️ Starting idle service timer (${_idleServiceTimeout.inMinutes} min)');
_logger.debug('⏱️ Starting idle service timer (${_idleServiceTimeout.inMinutes} min)');
_idleServiceTimer = Timer(_idleServiceTimeout, () {
_logger.log('⏱️ Idle service timeout reached - stopping foreground service');
audioHandler.stopService();
@@ -5730,7 +5730,7 @@ class MusicAssistantProvider with ChangeNotifier {
final itemJsonList = queue.items.map((item) => jsonEncode(item.toJson())).toList();
await DatabaseService.instance.saveQueue(playerId, itemJsonList);
_logger.log('💾 Persisted ${queue.items.length} queue items to database');
_logger.debug('💾 Persisted ${queue.items.length} queue items to database');
} catch (e) {
_logger.log('⚠️ Error persisting queue to database: $e');
}
+6 -2
View File
@@ -12,7 +12,7 @@ import '../../models/media_item.dart' as ma;
/// Custom AudioHandler for Ensemble that provides full control over
/// notification actions and metadata updates.
class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
final AudioPlayer _player = AudioPlayer();
final AuthManager authManager;
final _logger = DebugLogger();
@@ -335,6 +335,7 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
/// Called from _MusicAssistantAppState.initState().
void setProvider(MusicAssistantProvider provider) {
_autoProvider = provider;
_logger.log('AndroidAuto: provider set');
}
// Media ID constants — root categories
@@ -377,7 +378,10 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
Future<List<MediaItem>> getChildren(String parentMediaId,
[Map<String, dynamic>? options]) async {
final provider = _autoProvider;
if (provider == null) return [];
if (provider == null) {
_logger.log('AndroidAuto: getChildren("$parentMediaId") — provider not set yet');
return [];
}
// Ensure provider is connected — AA may browse before app is fully ready
if (!provider.isConnected) {
+3 -3
View File
@@ -251,7 +251,7 @@ class MusicAssistantAPI {
void _handleMessage(dynamic message) {
try {
final data = jsonDecode(message as String) as Map<String, dynamic>;
_logger.log('Received message: ${data.keys}');
_logger.debug('Received message: ${data.keys}');
// Check for server info message (first message on connect)
if (data.containsKey('server_version')) {
@@ -1981,7 +1981,7 @@ class MusicAssistantAPI {
currentIndex = queueResult['current_index'] as int?;
shuffleEnabled = queueResult['shuffle_enabled'] as bool?;
repeatMode = queueResult['repeat_mode'] as String?;
_logger.log('🔀 Queue metadata: shuffle_enabled=$shuffleEnabled, repeat_mode=$repeatMode');
_logger.debug('🔀 Queue metadata: shuffle_enabled=$shuffleEnabled, repeat_mode=$repeatMode');
final currentItemData = queueResult['current_item'] as Map<String, dynamic>?;
final currentItemName = currentItemData?['name'] as String?;
@@ -2017,7 +2017,7 @@ class MusicAssistantAPI {
}
if (items.isEmpty) {
_logger.log('⚠️ Queue is empty or all items failed to parse');
_logger.debug('⚠️ Queue is empty or all items failed to parse');
return null;
}
+9 -9
View File
@@ -100,7 +100,7 @@ class PositionTracker {
Duration? duration,
double? serverTimestamp,
}) {
_logger.log('⏱️ PositionTracker.updateFromServer: pos=${position.toStringAsFixed(1)}s, isPlaying=$isPlaying, playerId=$playerId');
_logger.debug('⏱️ PositionTracker.updateFromServer: pos=${position.toStringAsFixed(1)}s, isPlaying=$isPlaying, playerId=$playerId');
final bool playerChanged = _playerId != playerId;
final bool playStateChanged = _isPlaying != isPlaying;
final bool durationChanged = duration != null && _duration != duration;
@@ -179,11 +179,11 @@ class PositionTracker {
final newAnchorPos = anchorIsGettingStale && !playerChanged && !playStateChanged && isPlaying
? currentInterpolated // Keep our interpolated position, just refresh the anchor time
: anchorPos; // Use server position for other cases
_logger.log('⏱️ PositionTracker: Anchor updated ${_anchorPosition.toStringAsFixed(1)}s -> ${newAnchorPos.toStringAsFixed(1)}s (playerChanged=$playerChanged, playStateChanged=$playStateChanged, positionDiff=${positionDiff.toStringAsFixed(1)}, suspiciousReset=$isSuspiciousReset, anchorStale=$anchorIsGettingStale)');
_logger.debug('⏱️ PositionTracker: Anchor updated ${_anchorPosition.toStringAsFixed(1)}s -> ${newAnchorPos.toStringAsFixed(1)}s (playerChanged=$playerChanged, playStateChanged=$playStateChanged, positionDiff=${positionDiff.toStringAsFixed(1)}, suspiciousReset=$isSuspiciousReset, anchorStale=$anchorIsGettingStale)');
_anchorPosition = newAnchorPos;
_anchorTime = DateTime.now();
} else if (positionDiff > 2) {
_logger.log('⏱️ PositionTracker: Anchor NOT updated (suspiciousReset=$isSuspiciousReset, staleTimestamp=$hasStaleTimestamp, interpolated=${currentInterpolated.toStringAsFixed(1)}s)');
_logger.debug('⏱️ PositionTracker: Anchor NOT updated (suspiciousReset=$isSuspiciousReset, staleTimestamp=$hasStaleTimestamp, interpolated=${currentInterpolated.toStringAsFixed(1)}s)');
}
_isPlaying = isPlaying;
@@ -227,7 +227,7 @@ class PositionTracker {
/// Clear tracker state (e.g., when disconnecting)
void clear() {
_logger.log('⏱️ PositionTracker.clear() called');
_logger.debug('⏱️ PositionTracker.clear() called');
_stopInterpolationTimer();
_playerId = null;
_isPlaying = false;
@@ -238,17 +238,17 @@ class PositionTracker {
void _startInterpolationTimer() {
if (_interpolationTimer != null) {
_logger.log('⏱️ PositionTracker: Timer already running, skipping start');
_logger.debug('⏱️ PositionTracker: Timer already running, skipping start');
return;
}
_logger.log('⏱️ PositionTracker: Starting interpolation timer (anchor=${_anchorPosition.toStringAsFixed(1)}s)');
_logger.debug('⏱️ PositionTracker: Starting interpolation timer (anchor=${_anchorPosition.toStringAsFixed(1)}s)');
_interpolationTimer = Timer.periodic(const Duration(milliseconds: 250), (timer) {
// Log every 4 ticks (1 second) if no emission happened
if (timer.tick % 4 == 0) {
final pos = currentPosition;
if (pos.inSeconds == _lastEmittedSeconds) {
_logger.log('⏱️ PositionTracker: Timer tick ${timer.tick}, pos=${pos.inSeconds}s unchanged');
_logger.debug('⏱️ PositionTracker: Timer tick ${timer.tick}, pos=${pos.inSeconds}s unchanged');
}
}
_emitPosition();
@@ -259,7 +259,7 @@ class PositionTracker {
}
void _stopInterpolationTimer() {
_logger.log('⏱️ PositionTracker: Stopping interpolation timer (was ${_interpolationTimer != null ? "running" : "null"})');
_logger.debug('⏱️ PositionTracker: Stopping interpolation timer (was ${_interpolationTimer != null ? "running" : "null"})');
_interpolationTimer?.cancel();
_interpolationTimer = null;
}
@@ -271,7 +271,7 @@ class PositionTracker {
// Only emit if second changed (reduces unnecessary updates)
if (seconds != _lastEmittedSeconds) {
_lastEmittedSeconds = seconds;
_logger.log('⏱️ PositionTracker: Emitting position ${seconds}s (anchor=${_anchorPosition.toStringAsFixed(1)}s, playing=$_isPlaying)');
_logger.debug('⏱️ PositionTracker: Emitting position ${seconds}s (anchor=${_anchorPosition.toStringAsFixed(1)}s, playing=$_isPlaying)');
_positionController.add(pos);
}
}
+1 -1
View File
@@ -541,7 +541,7 @@ class SendspinService {
try {
final json = jsonEncode(message);
_logger.log('Sendspin: Sending message: ${message['type']}');
_logger.debug('Sendspin: Sending message: ${message['type']}');
_channel!.sink.add(json);
} catch (e) {
_logger.log('Sendspin: Error sending message: $e');
+1 -1
View File
@@ -657,7 +657,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_logger.log('📊 Subscribing to positionStream for progress bar');
_positionSubscription = maProvider.positionTracker.positionStream.listen((position) {
if (!mounted) return;
_logger.log('📊 Progress received position: ${position.inSeconds}s');
_logger.debug('📊 Progress received position: ${position.inSeconds}s');
_progressNotifier.value = position.inSeconds;
});
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 3.0.4+56
version: 3.0.5+57
environment:
sdk: '>=3.0.0 <4.0.0'