Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf0336e99d | ||
|
|
73fd1dbdcd | ||
|
|
2eb22cf524 | ||
|
|
3fe7a5800a | ||
|
|
972690b894 | ||
|
|
0e5679262c | ||
|
|
67bae29f9d | ||
|
|
4f7fbe92d9 |
@@ -86,6 +86,13 @@ This application was built with AI-assisted development using **Claude Code** an
|
|||||||
- **Skip Controls** - Skip forward/backward during playback
|
- **Skip Controls** - Skip forward/backward during playback
|
||||||
- **High-Resolution Artwork** - Fetched via iTunes for best quality
|
- **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
|
### Smart Features
|
||||||
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
|
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
|
||||||
- **Auto-Reconnect** - Automatically reconnects when connection is lost
|
- **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
|
- Use the IP address of your Home Assistant server
|
||||||
- Do **not** use your Home Assistant URL or ingress URL
|
- 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
|
### 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).
|
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 huge thank you to my sponsors! 💖
|
||||||
|
|
||||||
<a href="https://github.com/pcsokonay"><img src="https://github.com/pcsokonay.png" width="60px" alt="pcsokonay" /></a>
|
<a href="https://github.com/pcsokonay"><img src="https://github.com/pcsokonay.png" width="60px" alt="pcsokonay" /></a>
|
||||||
|
<a href="https://github.com/githubchb"><img src="https://github.com/githubchb.png" width="60px" alt="githubchb" /></a>
|
||||||
## Contributors
|
|
||||||
|
|
||||||
<a href="https://github.com/antoinevandenhurk"><img src="https://github.com/antoinevandenhurk.png" width="60px" alt="antoinevandenhurk" /></a>
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
flutter {
|
||||||
source '../..'
|
source '../..'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3063,7 +3063,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_cacheService.setCachedTrackForPlayer(playerId, mergedTrack);
|
_cacheService.setCachedTrackForPlayer(playerId, mergedTrack);
|
||||||
_logger.log('📋 Merged album info into existing track for $playerName: ${albumName}');
|
_logger.log('📋 Merged album info into existing track for $playerName: ${albumName}');
|
||||||
} else {
|
} 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
|
// For selected player, _updatePlayerState() is already called above which fetches queue data
|
||||||
@@ -4556,7 +4556,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Sendspin PCM player - continue to start timer
|
// 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
|
// Only run timer if player is playing
|
||||||
@@ -4800,7 +4800,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
/// Called when playback stops (player state becomes idle)
|
/// Called when playback stops (player state becomes idle)
|
||||||
void _startIdleServiceTimer() {
|
void _startIdleServiceTimer() {
|
||||||
_idleServiceTimer?.cancel();
|
_idleServiceTimer?.cancel();
|
||||||
_logger.log('⏱️ Starting idle service timer (${_idleServiceTimeout.inMinutes} min)');
|
_logger.debug('⏱️ Starting idle service timer (${_idleServiceTimeout.inMinutes} min)');
|
||||||
_idleServiceTimer = Timer(_idleServiceTimeout, () {
|
_idleServiceTimer = Timer(_idleServiceTimeout, () {
|
||||||
_logger.log('⏱️ Idle service timeout reached - stopping foreground service');
|
_logger.log('⏱️ Idle service timeout reached - stopping foreground service');
|
||||||
audioHandler.stopService();
|
audioHandler.stopService();
|
||||||
@@ -5730,7 +5730,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
final itemJsonList = queue.items.map((item) => jsonEncode(item.toJson())).toList();
|
final itemJsonList = queue.items.map((item) => jsonEncode(item.toJson())).toList();
|
||||||
await DatabaseService.instance.saveQueue(playerId, itemJsonList);
|
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) {
|
} catch (e) {
|
||||||
_logger.log('⚠️ Error persisting queue to database: $e');
|
_logger.log('⚠️ Error persisting queue to database: $e');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import '../../models/media_item.dart' as ma;
|
|||||||
|
|
||||||
/// Custom AudioHandler for Ensemble that provides full control over
|
/// Custom AudioHandler for Ensemble that provides full control over
|
||||||
/// notification actions and metadata updates.
|
/// notification actions and metadata updates.
|
||||||
class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
|
class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||||
final AudioPlayer _player = AudioPlayer();
|
final AudioPlayer _player = AudioPlayer();
|
||||||
final AuthManager authManager;
|
final AuthManager authManager;
|
||||||
final _logger = DebugLogger();
|
final _logger = DebugLogger();
|
||||||
@@ -335,6 +335,7 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
|
|||||||
/// Called from _MusicAssistantAppState.initState().
|
/// Called from _MusicAssistantAppState.initState().
|
||||||
void setProvider(MusicAssistantProvider provider) {
|
void setProvider(MusicAssistantProvider provider) {
|
||||||
_autoProvider = provider;
|
_autoProvider = provider;
|
||||||
|
_logger.log('AndroidAuto: provider set');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Media ID constants — root categories
|
// Media ID constants — root categories
|
||||||
@@ -377,7 +378,10 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
|
|||||||
Future<List<MediaItem>> getChildren(String parentMediaId,
|
Future<List<MediaItem>> getChildren(String parentMediaId,
|
||||||
[Map<String, dynamic>? options]) async {
|
[Map<String, dynamic>? options]) async {
|
||||||
final provider = _autoProvider;
|
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
|
// Ensure provider is connected — AA may browse before app is fully ready
|
||||||
if (!provider.isConnected) {
|
if (!provider.isConnected) {
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ class MusicAssistantAPI {
|
|||||||
void _handleMessage(dynamic message) {
|
void _handleMessage(dynamic message) {
|
||||||
try {
|
try {
|
||||||
final data = jsonDecode(message as String) as Map<String, dynamic>;
|
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)
|
// Check for server info message (first message on connect)
|
||||||
if (data.containsKey('server_version')) {
|
if (data.containsKey('server_version')) {
|
||||||
@@ -1981,7 +1981,7 @@ class MusicAssistantAPI {
|
|||||||
currentIndex = queueResult['current_index'] as int?;
|
currentIndex = queueResult['current_index'] as int?;
|
||||||
shuffleEnabled = queueResult['shuffle_enabled'] as bool?;
|
shuffleEnabled = queueResult['shuffle_enabled'] as bool?;
|
||||||
repeatMode = queueResult['repeat_mode'] as String?;
|
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 currentItemData = queueResult['current_item'] as Map<String, dynamic>?;
|
||||||
final currentItemName = currentItemData?['name'] as String?;
|
final currentItemName = currentItemData?['name'] as String?;
|
||||||
@@ -2017,7 +2017,7 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (items.isEmpty) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class PositionTracker {
|
|||||||
Duration? duration,
|
Duration? duration,
|
||||||
double? serverTimestamp,
|
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 playerChanged = _playerId != playerId;
|
||||||
final bool playStateChanged = _isPlaying != isPlaying;
|
final bool playStateChanged = _isPlaying != isPlaying;
|
||||||
final bool durationChanged = duration != null && _duration != duration;
|
final bool durationChanged = duration != null && _duration != duration;
|
||||||
@@ -179,11 +179,11 @@ class PositionTracker {
|
|||||||
final newAnchorPos = anchorIsGettingStale && !playerChanged && !playStateChanged && isPlaying
|
final newAnchorPos = anchorIsGettingStale && !playerChanged && !playStateChanged && isPlaying
|
||||||
? currentInterpolated // Keep our interpolated position, just refresh the anchor time
|
? currentInterpolated // Keep our interpolated position, just refresh the anchor time
|
||||||
: anchorPos; // Use server position for other cases
|
: 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;
|
_anchorPosition = newAnchorPos;
|
||||||
_anchorTime = DateTime.now();
|
_anchorTime = DateTime.now();
|
||||||
} else if (positionDiff > 2) {
|
} 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;
|
_isPlaying = isPlaying;
|
||||||
@@ -227,7 +227,7 @@ class PositionTracker {
|
|||||||
|
|
||||||
/// Clear tracker state (e.g., when disconnecting)
|
/// Clear tracker state (e.g., when disconnecting)
|
||||||
void clear() {
|
void clear() {
|
||||||
_logger.log('⏱️ PositionTracker.clear() called');
|
_logger.debug('⏱️ PositionTracker.clear() called');
|
||||||
_stopInterpolationTimer();
|
_stopInterpolationTimer();
|
||||||
_playerId = null;
|
_playerId = null;
|
||||||
_isPlaying = false;
|
_isPlaying = false;
|
||||||
@@ -238,17 +238,17 @@ class PositionTracker {
|
|||||||
|
|
||||||
void _startInterpolationTimer() {
|
void _startInterpolationTimer() {
|
||||||
if (_interpolationTimer != null) {
|
if (_interpolationTimer != null) {
|
||||||
_logger.log('⏱️ PositionTracker: Timer already running, skipping start');
|
_logger.debug('⏱️ PositionTracker: Timer already running, skipping start');
|
||||||
return;
|
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) {
|
_interpolationTimer = Timer.periodic(const Duration(milliseconds: 250), (timer) {
|
||||||
// Log every 4 ticks (1 second) if no emission happened
|
// Log every 4 ticks (1 second) if no emission happened
|
||||||
if (timer.tick % 4 == 0) {
|
if (timer.tick % 4 == 0) {
|
||||||
final pos = currentPosition;
|
final pos = currentPosition;
|
||||||
if (pos.inSeconds == _lastEmittedSeconds) {
|
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();
|
_emitPosition();
|
||||||
@@ -259,7 +259,7 @@ class PositionTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _stopInterpolationTimer() {
|
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?.cancel();
|
||||||
_interpolationTimer = null;
|
_interpolationTimer = null;
|
||||||
}
|
}
|
||||||
@@ -271,7 +271,7 @@ class PositionTracker {
|
|||||||
// Only emit if second changed (reduces unnecessary updates)
|
// Only emit if second changed (reduces unnecessary updates)
|
||||||
if (seconds != _lastEmittedSeconds) {
|
if (seconds != _lastEmittedSeconds) {
|
||||||
_lastEmittedSeconds = seconds;
|
_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);
|
_positionController.add(pos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -541,7 +541,7 @@ class SendspinService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final json = jsonEncode(message);
|
final json = jsonEncode(message);
|
||||||
_logger.log('Sendspin: Sending message: ${message['type']}');
|
_logger.debug('Sendspin: Sending message: ${message['type']}');
|
||||||
_channel!.sink.add(json);
|
_channel!.sink.add(json);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Sendspin: Error sending message: $e');
|
_logger.log('Sendspin: Error sending message: $e');
|
||||||
|
|||||||
@@ -657,7 +657,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
_logger.log('📊 Subscribing to positionStream for progress bar');
|
_logger.log('📊 Subscribing to positionStream for progress bar');
|
||||||
_positionSubscription = maProvider.positionTracker.positionStream.listen((position) {
|
_positionSubscription = maProvider.positionTracker.positionStream.listen((position) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_logger.log('📊 Progress received position: ${position.inSeconds}s');
|
_logger.debug('📊 Progress received position: ${position.inSeconds}s');
|
||||||
_progressNotifier.value = position.inSeconds;
|
_progressNotifier.value = position.inSeconds;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: ensemble
|
name: ensemble
|
||||||
description: A minimalistic Music Assistant client built with Flutter
|
description: A minimalistic Music Assistant client built with Flutter
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 3.0.4+56
|
version: 3.0.5+57
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|||||||
Reference in New Issue
Block a user