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
26 changed files with 354 additions and 1503 deletions
+1
View File
@@ -175,6 +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>
<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 '../..'
}
-6
View File
@@ -54,12 +54,6 @@
</intent-filter>
</receiver>
<provider
android:name=".ArtworkContentProvider"
android:authorities="com.collotsspot.ensemble.artwork"
android:exported="true"
android:grantUriPermissions="true" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
@@ -1,84 +0,0 @@
package com.collotsspot.ensemble
import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.util.Base64
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
/**
* ContentProvider that serves artwork images to Android Auto.
*
* Android Auto only supports content:// and android.resource:// URIs for artwork.
* This provider accepts a base64url-encoded HTTP URL in the path, downloads the
* image (with disk caching), and returns a ParcelFileDescriptor.
*
* URI format: content://com.collotsspot.ensemble.artwork/{base64url_encoded_http_url}
*/
class ArtworkContentProvider : ContentProvider() {
companion object {
private const val CONNECT_TIMEOUT_MS = 5000
private const val READ_TIMEOUT_MS = 10000
}
override fun onCreate(): Boolean = true
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
val encoded = uri.lastPathSegment ?: return null
val httpUrl: String
try {
httpUrl = String(Base64.decode(encoded, Base64.URL_SAFE or Base64.NO_WRAP))
} catch (e: Exception) {
return null
}
val cacheDir = File(context?.cacheDir, "artwork")
if (!cacheDir.exists()) cacheDir.mkdirs()
val hash = md5(httpUrl)
val cacheFile = File(cacheDir, hash)
if (!cacheFile.exists()) {
try {
val connection = URL(httpUrl).openConnection() as HttpURLConnection
connection.connectTimeout = CONNECT_TIMEOUT_MS
connection.readTimeout = READ_TIMEOUT_MS
connection.instanceFollowRedirects = true
try {
connection.inputStream.use { input ->
cacheFile.outputStream().use { output ->
input.copyTo(output)
}
}
} finally {
connection.disconnect()
}
} catch (e: Exception) {
cacheFile.delete()
return null
}
}
return ParcelFileDescriptor.open(cacheFile, ParcelFileDescriptor.MODE_READ_ONLY)
}
override fun query(uri: Uri, projection: Array<String>?, selection: String?,
selectionArgs: Array<String>?, sortOrder: String?): Cursor? = null
override fun getType(uri: Uri): String = "image/*"
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = 0
override fun update(uri: Uri, values: ContentValues?, selection: String?,
selectionArgs: Array<String>?): Int = 0
private fun md5(input: String): String {
val digest = MessageDigest.getInstance("MD5")
val bytes = digest.digest(input.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
}
@@ -1,19 +1,12 @@
package com.collotsspot.ensemble
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.database.ContentObserver
import android.hardware.display.DisplayManager
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.app.UiModeManager
import android.util.Log
import android.view.Display
import android.view.KeyEvent
import com.ryanheise.audioservice.AudioServiceActivity
import io.flutter.embedding.engine.FlutterEngine
@@ -24,10 +17,7 @@ import io.flutter.plugin.common.MethodChannel
class MainActivity: AudioServiceActivity() {
private val TAG = "EnsembleVolume"
private val CHANNEL = "com.collotsspot.ensemble/volume_buttons"
private val AA_CHANNEL = "com.collotsspot.ensemble/android_auto"
private var methodChannel: MethodChannel? = null
private var aaChannel: MethodChannel? = null
private var carModeReceiver: BroadcastReceiver? = null
private var isListening = false
// Volume observer for lockscreen volume changes
@@ -40,10 +30,6 @@ class MainActivity: AudioServiceActivity() {
// Guard flag to ignore volume changes triggered by our own setStreamVolume calls
private var ignoringVolumeChange = false
// Android Auto projection tracking via display listener
private var isAATracked = false
private var displayListener: DisplayManager.DisplayListener? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Log.d(TAG, "Configuring Flutter engine, setting up MethodChannel")
@@ -88,65 +74,6 @@ class MainActivity: AudioServiceActivity() {
}
}
}
// Android Auto / car mode detection
aaChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, AA_CHANNEL)
// Receive AA connection signal from Dart side
aaChannel?.setMethodCallHandler { call, result ->
when (call.method) {
"notifyAAConnected" -> {
Log.d(TAG, "AA tracked via Dart notifyAAConnected")
isAATracked = true
result.success(null)
}
else -> result.notImplemented()
}
}
carModeReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
UiModeManager.ACTION_ENTER_CAR_MODE -> {
Log.d(TAG, "Car mode ENTERED")
isAATracked = true
aaChannel?.invokeMethod("onAndroidAutoConnected", null)
}
UiModeManager.ACTION_EXIT_CAR_MODE -> {
Log.d(TAG, "Car mode EXITED")
aaChannel?.invokeMethod("onAndroidAutoDisconnected", null)
}
}
}
}
val carModeFilter = IntentFilter().apply {
addAction(UiModeManager.ACTION_ENTER_CAR_MODE)
addAction(UiModeManager.ACTION_EXIT_CAR_MODE)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
applicationContext.registerReceiver(carModeReceiver, carModeFilter, Context.RECEIVER_EXPORTED)
} else {
applicationContext.registerReceiver(carModeReceiver, carModeFilter)
}
// Detect AA disconnect via display removal (projection mode)
val dm = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) {}
override fun onDisplayChanged(displayId: Int) {}
override fun onDisplayRemoved(displayId: Int) {
if (!isAATracked) return
val hasExtraDisplay = dm.displays.any {
it.displayId != Display.DEFAULT_DISPLAY
}
if (!hasExtraDisplay) {
Log.d(TAG, "AA projection ended (all extra displays removed)")
isAATracked = false
aaChannel?.invokeMethod("onAndroidAutoDisconnected", null)
}
}
}
dm.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
}
/// Start observing system STREAM_MUSIC volume changes.
@@ -297,13 +224,6 @@ class MainActivity: AudioServiceActivity() {
override fun onDestroy() {
stopVolumeObserver()
carModeReceiver?.let {
try { applicationContext.unregisterReceiver(it) } catch (_: Exception) {}
}
displayListener?.let {
val dm = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
dm.unregisterDisplayListener(it)
}
super.onDestroy()
}
}
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M16.5,3c-1.74,0 -3.41,0.81 -4.5,2.09C10.91,3.81 9.24,3 7.5,3 4.42,3 2,5.42 2,8.5c0,3.78 3.4,6.86 8.55,11.54L12,21.35l1.45,-1.32C18.6,15.36 22,12.28 22,8.5 22,5.42 19.58,3 16.5,3zM12.1,18.55l-0.1,0.1 -0.1,-0.1C7.14,14.24 4,11.39 4,8.5 4,6.5 5.5,5 7.5,5c1.54,0 3.04,0.99 3.57,2.36h1.87C13.46,5.99 14.96,5 16.5,5c2,0 3.5,1.5 3.5,3.5 0,2.89 -3.14,5.74 -7.9,10.05z"/>
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
</vector>
@@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
<path android:fillColor="#FFFFFF"
android:pathData="M4,22h16v2H4z"/>
</vector>
@@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
<path android:fillColor="#FFFFFF"
android:pathData="M13,15V9h-1l-2,1v1h1.5v4z"/>
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5V4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20H20v-5.5l-2.04,2.04 -3.13,-3.13z"/>
</vector>
@@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5V4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20H20v-5.5l-2.04,2.04 -3.13,-3.13z"/>
<path android:fillColor="#FFFFFF"
android:pathData="M4,22h16v2H4z"/>
</vector>
@@ -1,13 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<!-- Shuffle arrows -->
<path android:fillColor="#FFFFFF"
android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5V4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20H20v-5.5l-2.04,2.04 -3.13,-3.13z"/>
<!-- Sparkle -->
<path android:fillColor="#FFFFFF"
android:pathData="M19,1l-1.26,2.74L15,5l2.74,1.26L19,9l1.26,-2.74L23,5l-2.74,-1.26z"/>
</vector>
-3
View File
@@ -70,9 +70,6 @@ void main() {
androidNotificationIcon: 'drawable/ic_notification',
androidShowNotificationBadge: false,
androidStopForegroundOnPause: false, // Keep service alive when paused for background playback
androidBrowsableRootExtras: {
'android.media.browse.SEARCH_SUPPORTED': true,
},
),
);
_logger.log('🎵 AudioService initialized - background playback and media notifications ENABLED');
+150 -405
View File
@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:audio_service/audio_service.dart' as audio_service;
@@ -100,14 +99,6 @@ class MusicAssistantProvider with ChangeNotifier {
Timer? _idleServiceTimer;
static const _idleServiceTimeout = Duration(minutes: 30);
// Artwork content:// URI helper for Android Auto compatibility
static const _artworkAuthority = 'com.collotsspot.ensemble.artwork';
static Uri? _contentArtUri(String? httpUrl) {
if (httpUrl == null) return null;
final encoded = base64Url.encode(utf8.encode(httpUrl));
return Uri.parse('content://$_artworkAuthority/$encoded');
}
// Local player state
bool _isLocalPlayerPowered = true;
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
@@ -126,7 +117,6 @@ class MusicAssistantProvider with ChangeNotifier {
TrackMetadata? _pendingTrackMetadata;
TrackMetadata? _currentNotificationMetadata;
Completer<void>? _registrationInProgress;
Completer<void>? _reconnectInProgress;
// Local player service
late final LocalPlayerService _localPlayer;
@@ -691,40 +681,6 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Get the display artist string for a track, using podcast/radio/audiobook context when applicable
String _trackDisplayArtist(Track track) {
if (isPlayingAudiobook) {
return _currentAudiobook!.authorsString;
}
if (isPlayingPodcast) {
return currentPodcastName ?? 'Podcasts';
}
if (isPlayingRadio) {
return currentRadioStationName ?? track.artistsString;
}
// Fallback: check if track URI looks like an audiobook chapter
final uri = track.uri ?? '';
if (uri.contains('audiobook') || uri.contains('/chapter/')) {
// Try to find audiobook from cached data
final book = _findAudiobookForTrack(track);
if (book != null) {
_currentAudiobook = book;
_logger.log('📚 Auto-restored audiobook context: ${book.name}');
return book.authorsString;
}
}
return track.artistsString;
}
/// Try to find the audiobook that owns this track/chapter from cached data
Audiobook? _findAudiobookForTrack(Track track) {
final uri = track.uri ?? '';
for (final book in SyncService.instance.cachedAudiobooks) {
if (uri.contains(book.itemId)) return book;
}
return null;
}
String get lastSearchQuery => _lastSearchQuery;
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
@@ -929,30 +885,14 @@ class MusicAssistantProvider with ChangeNotifier {
// Restore selected player from settings
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
if (lastSelectedPlayerId != null) {
_selectedPlayer = players.cast<Player?>().firstWhere(
(p) => p!.playerId == lastSelectedPlayerId,
orElse: () => null,
);
if (_selectedPlayer != null) {
try {
_selectedPlayer = players.firstWhere((p) => p.playerId == lastSelectedPlayerId);
_cacheService.setCachedSelectedPlayer(_selectedPlayer);
_logger.log('📦 Restored selected player from database: ${_selectedPlayer?.name}');
} catch (_) {
// Player not in cached list
}
}
// Fallback to local/builtin player
if (_selectedPlayer == null) {
final builtinId = await SettingsService.getBuiltinPlayerId();
if (builtinId != null) {
_selectedPlayer = players.cast<Player?>().firstWhere(
(p) => p!.playerId == builtinId,
orElse: () => null,
);
if (_selectedPlayer != null) {
_logger.log('📦 Defaulted to local player: ${_selectedPlayer?.name}');
}
}
}
if (_selectedPlayer != null) {
_cacheService.setCachedSelectedPlayer(_selectedPlayer);
}
_logger.log('📦 Loaded ${players.length} players from database (instant)');
notifyListeners();
@@ -1013,9 +953,6 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('📦 Pre-loaded library for favorites: ${_albums.length} albums, ${_artists.length} artists');
_syncLibraryStatusToService();
notifyListeners();
audioHandler.invalidateAutoChildren(const [
'cat|artists', 'cat|albums', 'cat|playlists', 'cat|home',
]);
}
} catch (e) {
_logger.log('⚠️ Error loading library from cache: $e');
@@ -2009,12 +1946,6 @@ class MusicAssistantProvider with ChangeNotifier {
}
Future<void> checkAndReconnect() async {
// Deduplicate: if a reconnect is already in progress, join it
if (_reconnectInProgress != null) {
_logger.log('🔄 checkAndReconnect called — already in progress, joining existing attempt');
return _reconnectInProgress!.future;
}
_logger.log('🔄 checkAndReconnect called - state: $_connectionState');
if (_serverUrl == null) {
@@ -2022,82 +1953,60 @@ class MusicAssistantProvider with ChangeNotifier {
return;
}
_reconnectInProgress = Completer<void>();
// IMMEDIATELY load cached players for instant UI display
// This makes mini player and device button appear instantly on app resume
if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) {
_availablePlayers = _cacheService.getCachedPlayers()!;
try {
// IMMEDIATELY load cached players for instant UI display
// This makes mini player and device button appear instantly on app resume
if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) {
_availablePlayers = _cacheService.getCachedPlayers()!;
// Sort cached players immediately so list appears in correct order
final smartSort = await SettingsService.getSmartSortPlayers();
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
_sortPlayersSync(_availablePlayers, smartSort, builtinPlayerId);
// Sort cached players immediately so list appears in correct order
final smartSort = await SettingsService.getSmartSortPlayers();
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
_sortPlayersSync(_availablePlayers, smartSort, builtinPlayerId);
_selectedPlayer = _cacheService.getCachedSelectedPlayer();
// Also try to restore from settings if cache doesn't have selected player
if (_selectedPlayer == null && _availablePlayers.isNotEmpty) {
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
if (lastSelectedPlayerId != null) {
_selectedPlayer = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == lastSelectedPlayerId,
orElse: () => null,
_selectedPlayer = _cacheService.getCachedSelectedPlayer();
// Also try to restore from settings if cache doesn't have selected player
if (_selectedPlayer == null && _availablePlayers.isNotEmpty) {
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
if (lastSelectedPlayerId != null) {
try {
_selectedPlayer = _availablePlayers.firstWhere(
(p) => p.playerId == lastSelectedPlayerId,
);
} catch (e) {
_selectedPlayer = _availablePlayers.first;
}
// Fallback to local/builtin player
if (_selectedPlayer == null) {
final builtinId = await SettingsService.getBuiltinPlayerId();
if (builtinId != null) {
_selectedPlayer = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == builtinId,
orElse: () => null,
);
}
}
// Last resort: first available (only if no builtin player configured)
if (_selectedPlayer == null) {
final builtinConfigured = await SettingsService.getBuiltinPlayerId();
if (builtinConfigured == null) {
_selectedPlayer = _availablePlayers.first;
}
}
} else {
_selectedPlayer = _availablePlayers.first;
}
_logger.log('⚡ Loaded ${_availablePlayers.length} cached players instantly (sorted)');
notifyListeners(); // Update UI immediately with cached data
}
_logger.log('⚡ Loaded ${_availablePlayers.length} cached players instantly (sorted)');
notifyListeners(); // Update UI immediately with cached data
}
if (_connectionState != MAConnectionState.connected &&
_connectionState != MAConnectionState.authenticated) {
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
if (_connectionState != MAConnectionState.connected &&
_connectionState != MAConnectionState.authenticated) {
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
try {
await connectToServer(_serverUrl!);
_logger.log('🔄 Reconnection successful');
} catch (e) {
_logger.log('🔄 Reconnection failed: $e');
}
} else {
_logger.log('🔄 Already connected, verifying connection...');
try {
await refreshPlayers();
await _updatePlayerState();
// Note: _preloadAdjacentPlayers is already called in refreshPlayers() -> _loadAndSelectPlayers()
_logger.log('🔄 Connection verified, players and state refreshed');
} catch (e) {
_logger.log('🔄 Connection verification failed, reconnecting: $e');
try {
await connectToServer(_serverUrl!);
_logger.log('🔄 Reconnection successful');
} catch (e) {
_logger.log('🔄 Reconnection failed: $e');
}
} else {
_logger.log('🔄 Already connected, verifying connection...');
try {
await refreshPlayers();
await _updatePlayerState();
// Note: _preloadAdjacentPlayers is already called in refreshPlayers() -> _loadAndSelectPlayers()
_logger.log('🔄 Connection verified, players and state refreshed');
} catch (e) {
_logger.log('🔄 Connection verification failed, reconnecting: $e');
try {
await connectToServer(_serverUrl!);
} catch (reconnectError) {
_logger.log('🔄 Reconnection failed: $reconnectError');
}
} catch (reconnectError) {
_logger.log('🔄 Reconnection failed: $reconnectError');
}
}
_reconnectInProgress!.complete();
} catch (e) {
_reconnectInProgress!.completeError(e);
} finally {
_reconnectInProgress = null;
}
}
@@ -2120,43 +2029,16 @@ class MusicAssistantProvider with ChangeNotifier {
};
audioHandler.onPlay = () {
_logger.log('🎵 Notification: Play pressed');
if (_selectedPlayer != null) {
resumePlayer(_selectedPlayer!.playerId);
unawaited(refreshPlayers());
}
playPauseSelectedPlayer();
};
audioHandler.onPause = () {
_logger.log('🎵 Notification: Pause pressed');
if (_selectedPlayer != null) {
pausePlayer(_selectedPlayer!.playerId);
}
playPauseSelectedPlayer();
};
audioHandler.onSwitchPlayer = () {
_logger.log('🎵 Notification: Switch player pressed');
selectNextPlayer();
};
audioHandler.onBrowseActivity = () {
_cancelIdleServiceTimer();
};
audioHandler.onAAConnected = () async {
final playerId = await SettingsService.getBuiltinPlayerId();
if (playerId != null && _selectedPlayer?.playerId != playerId) {
final builtinPlayer = _availablePlayers
.where((p) => p.playerId == playerId)
.firstOrNull;
if (builtinPlayer != null) {
_logger.log('🎵 AA connected: auto-selecting builtin player "${builtinPlayer.name}"');
selectPlayer(builtinPlayer);
}
}
};
audioHandler.onAADisconnected = () async {
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null) {
_logger.log('🎵 AA disconnected: pausing builtin player');
pausePlayer(builtinPlayerId);
}
};
// Player registration is now handled in _initializeAfterConnection()
// which runs after authentication completes (when auth is required)
@@ -2584,11 +2466,6 @@ class MusicAssistantProvider with ChangeNotifier {
/// 2. Start the foreground service to prevent background throttling
/// 3. Reset position for new track and start position timer
void _handleSendspinStreamStart(Map<String, dynamic>? trackInfo) async {
final aaDisc = audioHandler.aaDisconnectedAt;
if (aaDisc != null && DateTime.now().difference(aaDisc).inSeconds < 2) {
_logger.log('🎵 Sendspin: Ignoring stream/start (AA disconnected ${DateTime.now().difference(aaDisc).inMilliseconds}ms ago)');
return;
}
_logger.log('🎵 Sendspin: Stream starting');
// Ensure PCM player is initialized and ready
@@ -2631,46 +2508,25 @@ class MusicAssistantProvider with ChangeNotifier {
// Keep the foreground service active to prevent Android from throttling
// the PCM audio playback when the app goes to background.
// Use updateLocalModeNotification (NOT setRemotePlaybackState) to avoid
// conflicting mediaItem.add() calls — _updatePlayerState() also calls
// updateLocalModeNotification with the real track data.
// We use setRemotePlaybackState to maintain the notification without
// actually playing audio through just_audio.
final mediaItem = audio_service.MediaItem(
id: 'sendspin_pcm_stream',
title: title ?? 'Playing via Sendspin',
artist: artist ?? 'Music Assistant',
album: album,
artUri: artworkUrl != null ? Uri.parse(artworkUrl) : null,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
);
// Initialize notification with position 0
_cancelIdleServiceTimer();
if (_currentTrack != null) {
final track = _currentTrack!;
final trackArtworkUrl = _api?.getImageUrl(track, size: 512);
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '${displayArtist} • ${_selectedPlayer?.name ?? ''}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(trackArtworkUrl),
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: true,
duration: track.duration,
position: _positionTracker.currentPosition,
);
} else {
// No track data yet — use stream metadata as fallback
final mediaItem = audio_service.MediaItem(
id: 'sendspin_pcm_stream',
title: title ?? 'Playing via Sendspin',
artist: artist ?? 'Music Assistant',
album: album,
artUri: _contentArtUri(artworkUrl),
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: true,
duration: mediaItem.duration,
position: _positionTracker.currentPosition,
);
}
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: true,
position: Duration.zero,
duration: mediaItem.duration,
);
// Start notification position timer for Sendspin PCM
_manageNotificationPositionTimer();
@@ -2681,8 +2537,8 @@ class MusicAssistantProvider with ChangeNotifier {
/// Handle Sendspin stream end - server stopped sending PCM audio data
/// This is called when audio streaming ends (pause, stop, track end, etc.)
void _handleSendspinStreamEnd() async {
// Use position tracker (server-synced) instead of PCM elapsed time which resets to 0 each stream
final lastPosition = _positionTracker.currentPosition;
// Capture current position before stopping
final lastPosition = _pcmAudioPlayer?.elapsedTime ?? Duration.zero;
_logger.log('🎵 Sendspin: Stream ended at position ${lastPosition.inSeconds}s');
// Stop notification position timer
@@ -2691,46 +2547,27 @@ class MusicAssistantProvider with ChangeNotifier {
// Pause PCM playback (preserves position) instead of stop (resets position)
await _pcmAudioPlayer?.pause();
// Update foreground service to show paused state with last position.
// Use updateLocalModeNotification (NOT setRemotePlaybackState) to avoid
// conflicting mediaItem.add() calls with _updatePlayerState().
// Update foreground service to show paused/stopped state with last position
// Don't completely clear it - keep showing the notification
// in case user wants to resume
final metadata = _currentNotificationMetadata;
final mediaItem = audio_service.MediaItem(
id: 'sendspin_pcm_stream',
title: metadata?.title ?? 'Music Assistant',
artist: metadata?.artist ?? 'Paused',
album: metadata?.album,
artUri: metadata?.artworkUrl != null ? Uri.parse(metadata!.artworkUrl!) : null,
duration: metadata?.duration,
);
// Show paused state with preserved position
_cancelIdleServiceTimer();
if (_currentTrack != null) {
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: 512);
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '${displayArtist} • ${_selectedPlayer?.name ?? ''}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(artworkUrl),
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: false,
duration: track.duration,
position: lastPosition,
);
} else {
final metadata = _currentNotificationMetadata;
final mediaItem = audio_service.MediaItem(
id: 'sendspin_pcm_stream',
title: metadata?.title ?? 'Music Assistant',
artist: metadata?.artist ?? 'Paused',
album: metadata?.album,
artUri: _contentArtUri(metadata?.artworkUrl),
duration: metadata?.duration,
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: false,
duration: mediaItem.duration,
position: lastPosition,
);
}
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: false,
position: lastPosition,
duration: mediaItem.duration,
);
}
void _startReportingLocalPlayerState() {
@@ -2934,7 +2771,6 @@ class MusicAssistantProvider with ChangeNotifier {
final mediaType = event['media_type'] as String?;
_cacheService.invalidateSearchCache();
_scheduleLibraryRefresh(mediaType ?? 'artist');
audioHandler.invalidateAutoChildren(const ['cat|favorites', 'cat|home', 'cat|fav_tracks', 'cat|fav_albums', 'cat|fav_artists']);
} catch (e) {
_logger.log('Error handling media item added event: $e');
}
@@ -2947,7 +2783,6 @@ class MusicAssistantProvider with ChangeNotifier {
final mediaType = event['media_type'] as String?;
_cacheService.invalidateSearchCache();
_scheduleLibraryRefresh(mediaType ?? 'artist');
audioHandler.invalidateAutoChildren(const ['cat|favorites', 'cat|home', 'cat|fav_tracks', 'cat|fav_albums', 'cat|fav_artists']);
} catch (e) {
_logger.log('Error handling media item deleted event: $e');
}
@@ -3986,17 +3821,6 @@ class MusicAssistantProvider with ChangeNotifier {
if (_api == null) return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
try {
// Ensure library is loaded for artist matching
if (_albums.isEmpty) {
final syncService = SyncService.instance;
if (!syncService.hasCache) {
await syncService.loadFromCache();
}
if (syncService.cachedAlbums.isNotEmpty) {
_albums = syncService.cachedAlbums;
}
}
_logger.log('🔄 Fetching albums for artist "$artistName"...');
// Use already-loaded library albums instead of re-fetching from API
@@ -4025,11 +3849,7 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
// Only cache if the library is loaded — empty results during cold start
// are likely due to SyncService not having finished yet
if (allAlbums.isNotEmpty) {
_cacheService.setCachedArtistAlbums(cacheKey, allAlbums);
}
_cacheService.setCachedArtistAlbums(cacheKey, allAlbums);
_logger.log('✅ Cached ${allAlbums.length} albums for artist "$artistName"');
return allAlbums;
} catch (e) {
@@ -4479,31 +4299,19 @@ class MusicAssistantProvider with ChangeNotifier {
}
// Priority 2: Local player (default fallback)
// Try available first, then accept unavailable (Sendspin may still be registering)
if (playerToSelect == null && builtinPlayerId != null) {
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == builtinPlayerId && p.available,
orElse: () => null,
);
if (playerToSelect != null) {
_logger.log('📱 Selected local player: ${playerToSelect.name}');
} else {
// Player exists but not yet available (Sendspin registration in progress)
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == builtinPlayerId,
orElse: () => null,
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == builtinPlayerId && p.available,
);
if (playerToSelect != null) {
_logger.log('📱 Selected local player (pending availability): ${playerToSelect.name}');
} else {
_logger.log('⚠️ Local player not in player list yet — waiting for Sendspin registration');
}
_logger.log('📱 Selected local player: ${playerToSelect.name}');
} catch (e) {
_logger.log('⚠️ Local player not available');
}
}
// Priority 3: First available player (only if no local player configured)
// Don't fall back to another player if builtin player is configured but not yet registered
if (playerToSelect == null && builtinPlayerId == null) {
// Priority 3: First available player
if (playerToSelect == null) {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.available,
orElse: () => _availablePlayers.first,
@@ -4511,13 +4319,7 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
}
if (playerToSelect != null) {
selectPlayer(playerToSelect);
} else {
// Builtin player not yet registered — keep current selection if any,
// otherwise leave unselected until Sendspin registration completes
_logger.log('⏳ No player selected — awaiting builtin player registration');
}
selectPlayer(playerToSelect);
}
// Preload track data and images in background for swipe gestures
@@ -4575,15 +4377,16 @@ class MusicAssistantProvider with ChangeNotifier {
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: 512);
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '${displayArtist} • ${player.name}';
final artistWithPlayer = track.artistsString.isNotEmpty
? '${track.artistsString} • ${player.name}'
: player.name;
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(artworkUrl),
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
);
// Position comes from actual player in updateLocalModeNotification
audioHandler.updateLocalModeNotification(
@@ -4602,29 +4405,8 @@ class MusicAssistantProvider with ChangeNotifier {
item: mediaItem,
playing: player.state == 'playing',
);
} else if (_currentTrack != null) {
// Builtin player is idle but we have track data — show paused notification
// instead of clearing, to avoid artwork flash when AA toggles play/pause
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: 512);
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '${displayArtist} • ${player.name}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(artworkUrl),
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: false,
duration: track.duration,
);
_startIdleServiceTimer();
} else {
// Builtin player is idle with no track - clear notification
// Builtin player is idle - clear notification (fixes issue #42)
audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
}
@@ -4635,15 +4417,16 @@ class MusicAssistantProvider with ChangeNotifier {
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: 512);
// Include player name in artist line: "Artist • Player Name"
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '${displayArtist} • ${player.name}';
final artistWithPlayer = track.artistsString.isNotEmpty
? '${track.artistsString} • ${player.name}'
: player.name;
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(artworkUrl),
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
);
// Use position tracker for consistent position
final position = _positionTracker.currentPosition;
@@ -4773,6 +4556,7 @@ class MusicAssistantProvider with ChangeNotifier {
return;
}
// Sendspin PCM player - continue to start timer
_logger.debug('🔔 Starting notification timer for Sendspin PCM player');
}
// Only run timer if player is playing
@@ -4815,37 +4599,25 @@ class MusicAssistantProvider with ChangeNotifier {
}
final artworkUrl = _api?.getImageUrl(track, size: 512);
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '${displayArtist} • ${_selectedPlayer!.name}';
final artistWithPlayer = track.artistsString.isNotEmpty
? '${track.artistsString} • ${_selectedPlayer!.name}'
: _selectedPlayer!.name;
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(artworkUrl),
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
);
_cancelIdleServiceTimer();
// Use updateLocalModeNotification for builtin players to avoid
// conflicting mediaItem.add() calls with _updatePlayerState()
final isBuiltinPlayer = _sendspinConnected && _pcmAudioPlayer != null;
if (isBuiltinPlayer) {
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: isPlaying,
duration: track.duration,
position: position,
);
} else {
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: isPlaying,
position: position,
duration: track.duration,
);
}
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: isPlaying,
position: position,
duration: track.duration,
);
}
Future<void> _preloadAdjacentPlayers({bool preloadAll = false}) async {
@@ -5216,7 +4988,7 @@ class MusicAssistantProvider with ChangeNotifier {
final isPlayingOrPaused = _selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused';
final isIdleWithContent = _selectedPlayer!.state == 'idle' && _selectedPlayer!.powered;
final shouldShowTrack = _selectedPlayer!.available && (isPlayingOrPaused || isIdleWithContent || isPcmPlaying);
final shouldShowTrack = _selectedPlayer!.available && (isPlayingOrPaused || isIdleWithContent);
if (!shouldShowTrack) {
if (_currentTrack != null) {
@@ -5260,10 +5032,6 @@ class MusicAssistantProvider with ChangeNotifier {
}
if (queue != null && queue.currentItem != null) {
if (queue.currentIndex != null) {
audioHandler.updateQueueIndex(queue.currentIndex!);
}
final queueTrack = queue.currentItem!.track;
final trackChanged = _currentTrack == null ||
_currentTrack!.uri != queueTrack.uri ||
@@ -5359,15 +5127,16 @@ class MusicAssistantProvider with ChangeNotifier {
if (isBuiltinPlayer) {
// Local playback - use local mode notification (keeps pause working)
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '$displayArtist • ${_selectedPlayer!.name}';
final artistWithPlayer = track.artistsString.isNotEmpty
? '${track.artistsString} • ${_selectedPlayer!.name}'
: _selectedPlayer!.name;
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(artworkUrl),
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
);
// Position comes from actual player in updateLocalModeNotification
audioHandler.updateLocalModeNotification(
@@ -5378,15 +5147,16 @@ class MusicAssistantProvider with ChangeNotifier {
} else {
// Remote MA player - show notification via remote mode
// Include player name in artist line: "Artist • Player Name"
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '$displayArtist • ${_selectedPlayer!.name}';
final artistWithPlayer = track.artistsString.isNotEmpty
? '${track.artistsString} • ${_selectedPlayer!.name}'
: _selectedPlayer!.name;
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(artworkUrl),
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
);
// Use position tracker for consistent position (single source of truth)
final position = _positionTracker.currentPosition;
@@ -5452,9 +5222,9 @@ class MusicAssistantProvider with ChangeNotifier {
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: _trackDisplayArtist(track),
artist: track.artistsString,
album: track.album?.name,
artUri: _contentArtUri(_api?.getImageUrl(track, size: 512)),
artUri: _api != null ? Uri.tryParse(_api!.getImageUrl(track, size: 512) ?? '') : null,
duration: track.duration,
);
audioHandler.updateLocalModeNotification(
@@ -5489,23 +5259,15 @@ class MusicAssistantProvider with ChangeNotifier {
// For pause: Don't refresh - we already did optimistic UI update
// The player_updated event from MA will handle final state sync
} else {
// Always try resume first — even if player reports "idle", the server
// queue may still be intact (e.g., Sendspin sends "stopped" on pause).
// Only fall back to re-queuing if resume fails.
final playerId = _selectedPlayer!.playerId;
await resumePlayer(playerId);
// Sendspin PCM streaming has ~5s buffering delay on resume.
// For audiobooks, seek back to compensate so the user doesn't miss narration.
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected && isPlayingAudiobook) {
final pos = _positionTracker.currentPosition.inSeconds;
if (pos > 10) {
_logger.log('📚 Seeking back 10s to compensate for Sendspin buffering');
unawaited(seek(playerId, pos - 10));
}
// If player is idle with no active queue but has a last-known track, re-queue it
final isIdle = _selectedPlayer!.state == 'idle';
final lastKnown = _cacheService.getLastKnownTrack(_selectedPlayer!.playerId);
if (isIdle && lastKnown != null) {
_logger.log('🔄 Player idle with last-known track - re-queuing "${lastKnown.name}"');
await playTrack(_selectedPlayer!.playerId, lastKnown);
} else {
await resumePlayer(_selectedPlayer!.playerId);
}
// For resume: Refresh in background to get updated track info
unawaited(refreshPlayers());
}
@@ -5613,9 +5375,6 @@ class MusicAssistantProvider with ChangeNotifier {
_syncLibraryStatusToService();
notifyListeners();
_prefetchAlbumImages();
audioHandler.invalidateAutoChildren(const [
'cat|artists', 'cat|albums', 'cat|playlists', 'cat|home',
]);
}
syncService.removeListener(onSyncComplete);
}
@@ -5971,6 +5730,7 @@ class MusicAssistantProvider with ChangeNotifier {
final itemJsonList = queue.items.map((item) => jsonEncode(item.toJson())).toList();
await DatabaseService.instance.saveQueue(playerId, itemJsonList);
_logger.debug('💾 Persisted ${queue.items.length} queue items to database');
} catch (e) {
_logger.log('⚠️ Error persisting queue to database: $e');
}
@@ -6035,16 +5795,11 @@ class MusicAssistantProvider with ChangeNotifier {
try {
await _api?.playRadio(playerId, track);
} catch (e) {
_logger.log('Radio failed, falling back to single track: $e');
try {
await playTrack(playerId, track);
} catch (fallbackError) {
final errorInfo = ErrorHandler.handleError(fallbackError, context: 'Play radio fallback');
_error = errorInfo.userMessage;
ErrorHandler.logError('Play radio fallback', fallbackError);
notifyListeners();
rethrow;
}
final errorInfo = ErrorHandler.handleError(e, context: 'Play radio');
_error = errorInfo.userMessage;
ErrorHandler.logError('Play radio', e);
notifyListeners();
rethrow;
}
}
@@ -6052,21 +5807,11 @@ class MusicAssistantProvider with ChangeNotifier {
try {
await _api?.playArtistRadio(playerId, artist);
} catch (e) {
_logger.log('Artist radio failed, falling back to shuffled tracks: $e');
try {
final results = await searchWithCache(artist.name);
final tracks = (results['tracks'] ?? []).whereType<Track>().toList();
if (tracks.isEmpty) rethrow;
tracks.shuffle(Random());
await _api?.playTracks(playerId, tracks, startIndex: 0);
await _api?.toggleShuffle(playerId, true);
} catch (fallbackError) {
final errorInfo = ErrorHandler.handleError(fallbackError, context: 'Play artist radio fallback');
_error = errorInfo.userMessage;
ErrorHandler.logError('Play artist radio fallback', fallbackError);
notifyListeners();
rethrow;
}
final errorInfo = ErrorHandler.handleError(e, context: 'Play artist radio');
_error = errorInfo.userMessage;
ErrorHandler.logError('Play artist radio', e);
notifyListeners();
rethrow;
}
}
+22 -20
View File
@@ -430,28 +430,29 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
),
),
),
// Bottom fade gradient to mask content behind mini player (4+ rows only)
if (_countEnabledRows() >= 4)
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.miniPlayerHeight,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.background.withOpacity(0.0),
colorScheme.background.withOpacity(0.85),
],
),
// Bottom fade gradient to mask content behind mini player
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.miniPlayerHeight + 22.0,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.background.withOpacity(0.0),
colorScheme.background.withOpacity(0.25),
colorScheme.background.withOpacity(0.85),
],
stops: const [0.0, 0.5, 1.0],
),
),
),
),
),
],
);
},
@@ -502,8 +503,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
builder: (context, constraints) {
// Each row is always 1/3 of screen height
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll
// Reserve space for the mini player overlay at the bottom.
final miniPlayerSpace = BottomSpacing.miniPlayerHeight + 22.0;
// With extendBody: true, constraints include the area behind the nav bar.
// Subtract mini player overlay space (mini player + its margins sit above the nav bar).
final miniPlayerSpace = BottomSpacing.miniPlayerHeight + 22.0; // 84 + 22 = 106
final availableHeight = constraints.maxHeight - miniPlayerSpace;
// Account for margins between rows (2px each, 2 margins for 3 rows).
+35 -33
View File
@@ -159,8 +159,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// PERF: Separate ValueNotifier for efficient UI updates (RestorableInt doesn't implement ValueListenable)
final ValueNotifier<int> _tabIndexNotifier = ValueNotifier<int>(0);
// Scroll-to-hide filter bars (0.0 = hidden, 1.0 = visible)
final ValueNotifier<double> _filterBarVisibility = ValueNotifier<double>(1.0);
// Scroll-to-hide filter bars
bool _isFilterBarVisible = true;
double _lastScrollOffset = 0;
static const double _scrollThreshold = 10.0;
bool _isLetterScrollbarDragging = false; // Disable scroll-to-hide while dragging
// Options menu overlay - stored to close on navigation
@@ -857,11 +859,17 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
if (notification is ScrollUpdateNotification) {
final delta = notification.scrollDelta ?? 0.0;
if (notification.metrics.pixels <= 0) {
_filterBarVisibility.value = 1.0;
} else {
_filterBarVisibility.value = (_filterBarVisibility.value - delta / _filterRowHeight).clamp(0.0, 1.0);
final currentOffset = notification.metrics.pixels;
final delta = currentOffset - _lastScrollOffset;
if (delta.abs() > _scrollThreshold) {
final shouldShow = delta < 0 || currentOffset <= 0;
if (shouldShow != _isFilterBarVisible) {
setState(() {
_isFilterBarVisible = shouldShow;
});
}
_lastScrollOffset = currentOffset;
}
}
return false;
@@ -903,11 +911,13 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
void _onLetterScrollbarDragChanged(bool isDragging) {
_isLetterScrollbarDragging = isDragging;
// Show the filter bar when starting to drag
if (isDragging) {
_filterBarVisibility.value = 1.0;
}
setState(() {
_isLetterScrollbarDragging = isDragging;
// Show the filter bar when starting to drag
if (isDragging) {
_isFilterBarVisible = true;
}
});
}
void _animateToCategory(int index) {
@@ -932,7 +942,6 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
_pageController.dispose();
_selectedTabIndex.dispose();
_tabIndexNotifier.dispose();
_filterBarVisibility.dispose();
_artistsScrollController.dispose();
_albumsScrollController.dispose();
_playlistsScrollController.dispose();
@@ -1440,12 +1449,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: Column(
children: [
// Two-row filter: Row 1 = Media types (hides on scroll), Row 2 = Sub-categories (always visible)
ValueListenableBuilder<double>(
valueListenable: _filterBarVisibility,
builder: (context, visibility, _) {
return _buildFilterRows(colorScheme, l10n, barVisibility: visibility);
},
),
_buildFilterRows(colorScheme, l10n, showLibraryTypeRow: _isFilterBarVisible),
// Connecting banner when showing cached data
// Hide when we have cached players - UI is functional during background reconnect
if (!isConnected && syncService.hasCache && !context.read<MusicAssistantProvider>().hasCachedPlayers)
@@ -1542,25 +1546,23 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// Consistent height for filter rows
static const double _filterRowHeight = 36.0;
Widget _buildFilterRows(ColorScheme colorScheme, S l10n, {required double barVisibility}) {
Widget _buildFilterRows(ColorScheme colorScheme, S l10n, {required bool showLibraryTypeRow}) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Row 1: Media type chips (hides when scrolling)
ClipRect(
child: Align(
alignment: Alignment.bottomCenter,
heightFactor: barVisibility,
child: SizedBox(
height: _filterRowHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: _buildMediaTypeChips(colorScheme, l10n),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
height: showLibraryTypeRow ? _filterRowHeight : 0,
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: _buildMediaTypeChips(colorScheme, l10n),
),
),
SizedBox(height: 12 * barVisibility), // Space between rows
if (showLibraryTypeRow) const SizedBox(height: 12), // Space between rows
// Row 2: Sub-category chips (left) + action buttons (right) - always visible
SizedBox(
height: _filterRowHeight,
@@ -4912,7 +4914,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
try {
// Start radio from this track
await maProvider.playRadio(player.playerId, track);
await maProvider.api?.playRadio(player.playerId, track);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
+29 -28
View File
@@ -130,8 +130,10 @@ class SearchScreenState extends State<SearchScreen> {
// Advanced search scorer with fuzzy matching, stopword removal, etc.
final SearchScorer _searchScorer = SearchScorer();
// Scroll-to-hide search bar (0.0 = hidden, 1.0 = visible)
final ValueNotifier<double> _searchBarVisibility = ValueNotifier<double>(1.0);
// Scroll-to-hide search bar (vertical scroll only)
bool _isSearchBarVisible = true;
double _lastVerticalScrollOffset = 0;
static const double _scrollThreshold = 10.0;
// Filter chip position tracking for animated sliding highlight
final Map<String, GlobalKey> _filterKeys = {};
@@ -312,7 +314,10 @@ class SearchScreenState extends State<SearchScreen> {
void requestFocus() {
if (mounted) {
_searchBarVisibility.value = 1.0;
setState(() {
_isSearchBarVisible = true;
_lastVerticalScrollOffset = 0;
});
_focusNode.requestFocus();
}
}
@@ -331,7 +336,6 @@ class SearchScreenState extends State<SearchScreen> {
_pageController.dispose();
_filterScrollController.dispose();
_activeFilterNotifier.dispose();
_searchBarVisibility.dispose();
super.dispose();
}
@@ -343,12 +347,17 @@ class SearchScreenState extends State<SearchScreen> {
}
if (notification is ScrollUpdateNotification) {
final delta = notification.scrollDelta ?? 0.0;
const maxHeight = kToolbarHeight + 16;
if (notification.metrics.pixels <= 0) {
_searchBarVisibility.value = 1.0;
} else {
_searchBarVisibility.value = (_searchBarVisibility.value - delta / maxHeight).clamp(0.0, 1.0);
final currentOffset = notification.metrics.pixels;
final delta = currentOffset - _lastVerticalScrollOffset;
if (delta.abs() > _scrollThreshold) {
final shouldShow = delta < 0 || currentOffset <= 0;
if (shouldShow != _isSearchBarVisible) {
setState(() {
_isSearchBarVisible = shouldShow;
});
}
_lastVerticalScrollOffset = currentOffset;
}
}
return false;
@@ -525,23 +534,16 @@ class SearchScreenState extends State<SearchScreen> {
body: SafeArea(
child: Column(
children: [
// Search bar - slides away with scroll
ValueListenableBuilder<double>(
valueListenable: _searchBarVisibility,
builder: (context, visibility, child) {
return ClipRect(
child: Align(
alignment: Alignment.bottomCenter,
heightFactor: visibility,
child: child!,
),
);
},
child: SizedBox(
height: kToolbarHeight + 16,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
// Animated search bar - hides on scroll down
AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
height: _isSearchBarVisible ? kToolbarHeight + 16 : 0,
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
// Search field container
Expanded(
@@ -645,7 +647,6 @@ class SearchScreenState extends State<SearchScreen> {
],
),
),
),
),
// Main content
Expanded(
File diff suppressed because it is too large Load Diff
+6 -12
View File
@@ -68,14 +68,6 @@ class MusicAssistantAPI {
MusicAssistantAPI(this.serverUrl, this.authManager);
/// Check if a URL string has an explicit port (e.g., ":80", ":8095").
/// Dart's Uri.hasPort returns false for default ports (80/443) even when
/// explicitly specified, so we check the raw string instead.
bool _hasExplicitPort(String url) {
final withoutScheme = url.replaceFirst(RegExp(r'^https?://'), '');
return RegExp(r'^[^/:]+:\d+').hasMatch(withoutScheme);
}
// Guard to prevent multiple simultaneous connection attempts
Completer<void>? _connectionInProgress;
bool _isDisposed = false;
@@ -153,7 +145,7 @@ class MusicAssistantAPI {
queryParameters: {'client_id': clientId},
);
_logger.log('Using custom WebSocket port from settings: $_cachedCustomPort');
} else if (_hasExplicitPort(serverUrl)) {
} else if (uri.hasPort) {
// Port is already specified in URL, keep it
finalUri = Uri(
scheme: uri.scheme,
@@ -259,6 +251,7 @@ class MusicAssistantAPI {
void _handleMessage(dynamic message) {
try {
final data = jsonDecode(message as String) as Map<String, dynamic>;
_logger.debug('Received message: ${data.keys}');
// Check for server info message (first message on connect)
if (data.containsKey('server_version')) {
@@ -1988,6 +1981,7 @@ class MusicAssistantAPI {
currentIndex = queueResult['current_index'] as int?;
shuffleEnabled = queueResult['shuffle_enabled'] as bool?;
repeatMode = queueResult['repeat_mode'] as String?;
_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?;
@@ -2490,7 +2484,7 @@ class MusicAssistantAPI {
port: _cachedCustomPort,
path: '/flow/$playerId/$streamId.$extension',
);
} else if (_hasExplicitPort(serverUrl)) {
} else if (uri.hasPort) {
// Use port from URL
finalUri = Uri(
scheme: uri.scheme,
@@ -3202,7 +3196,7 @@ class MusicAssistantAPI {
if (_cachedCustomPort != null) {
// Use cached custom port from settings
baseUrl = '${uriObj.scheme}://${uriObj.host}:$_cachedCustomPort';
} else if (!_hasExplicitPort(serverUrl)) {
} else if (!uriObj.hasPort) {
if (useSecure) {
// For HTTPS, don't add port 443 (it's the default)
baseUrl = '${uriObj.scheme}://${uriObj.host}';
@@ -3309,7 +3303,7 @@ class MusicAssistantAPI {
if (_cachedCustomPort != null) {
// Use cached custom port from settings
baseUrl = '${uri.scheme}://${uri.host}:$_cachedCustomPort';
} else if (!_hasExplicitPort(serverUrl)) {
} else if (!uri.hasPort) {
if (useSecure) {
// For HTTPS, don't add port 443 (it's the default)
baseUrl = '${uri.scheme}://${uri.host}';
+1 -6
View File
@@ -69,7 +69,6 @@ class PcmAudioPlayer {
bool _isFeeding = false;
bool _isStarted = false;
bool _isAutoRecovering = false; // Tracks auto-recovery from setup errors
bool _userPaused = false; // True when pause is user-initiated (not transitional)
Completer<void>? _feedCompleter; // Tracks when current feed operation completes
// Error callback for operation failures
@@ -236,9 +235,7 @@ class PcmAudioPlayer {
if (_shouldBlockFeeding) {
// If we're paused and audio is arriving, this is likely a new stream
// starting before the stream/start message. Queue for auto-recovery.
// BUT: don't auto-recover if the user explicitly paused — the arriving
// audio is just the tail end of the previous stream.
if (_state == PcmPlayerState.paused && !_isAutoRecovering && !_userPaused) {
if (_state == PcmPlayerState.paused && !_isAutoRecovering) {
_logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery');
_isAutoRecovering = true;
_addToBuffer(audioData);
@@ -495,7 +492,6 @@ class PcmAudioPlayer {
if (_state == PcmPlayerState.paused) {
// Resume from pause - need to re-initialize since we released on pause
_userPaused = false;
_logger.log('PcmAudioPlayer: Resuming from pause at ${elapsedTime.inSeconds}s');
_state = PcmPlayerState.resuming;
@@ -551,7 +547,6 @@ class PcmAudioPlayer {
}
_logger.log('PcmAudioPlayer: Pause requested');
_userPaused = true;
// Set pausing state FIRST to stop feed loop from starting new feeds
_state = PcmPlayerState.pausing;
+22 -1
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'debug_logger.dart';
/// Single source of truth for playback position tracking.
///
@@ -12,6 +13,8 @@ import 'package:flutter/foundation.dart';
/// All consumers (UI, notification) should listen to [positionStream]
/// instead of calculating position themselves.
class PositionTracker {
final DebugLogger _logger = DebugLogger();
// Current state
String? _playerId;
bool _isPlaying = false;
@@ -97,6 +100,7 @@ class PositionTracker {
Duration? duration,
double? serverTimestamp,
}) {
_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;
@@ -175,8 +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.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.debug('⏱️ PositionTracker: Anchor NOT updated (suspiciousReset=$isSuspiciousReset, staleTimestamp=$hasStaleTimestamp, interpolated=${currentInterpolated.toStringAsFixed(1)}s)');
}
_isPlaying = isPlaying;
@@ -220,6 +227,7 @@ class PositionTracker {
/// Clear tracker state (e.g., when disconnecting)
void clear() {
_logger.debug('⏱️ PositionTracker.clear() called');
_stopInterpolationTimer();
_playerId = null;
_isPlaying = false;
@@ -229,9 +237,20 @@ class PositionTracker {
}
void _startInterpolationTimer() {
if (_interpolationTimer != null) return;
if (_interpolationTimer != null) {
_logger.debug('⏱️ PositionTracker: Timer already running, skipping start');
return;
}
_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.debug('⏱️ PositionTracker: Timer tick ${timer.tick}, pos=${pos.inSeconds}s unchanged');
}
}
_emitPosition();
});
@@ -240,6 +259,7 @@ class PositionTracker {
}
void _stopInterpolationTimer() {
_logger.debug('⏱️ PositionTracker: Stopping interpolation timer (was ${_interpolationTimer != null ? "running" : "null"})');
_interpolationTimer?.cancel();
_interpolationTimer = null;
}
@@ -251,6 +271,7 @@ class PositionTracker {
// Only emit if second changed (reduces unnecessary updates)
if (seconds != _lastEmittedSeconds) {
_lastEmittedSeconds = seconds;
_logger.debug('⏱️ PositionTracker: Emitting position ${seconds}s (anchor=${_anchorPosition.toStringAsFixed(1)}s, playing=$_isPlaying)');
_positionController.add(pos);
}
}
+2
View File
@@ -321,6 +321,8 @@ class SendspinService {
final data = jsonDecode(message) as Map<String, dynamic>;
final type = data['type'] as String?;
_logger.log('Sendspin: Received message type: $type');
switch (type) {
case 'server/hello':
// Server acknowledged our client/hello - we're registered!
+2 -7
View File
@@ -81,7 +81,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
String? _lastImageUrl;
int _colorExtractionId = 0;
// Queue state
PlayerQueue? _queue;
@@ -758,17 +757,13 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
Future<void> _extractColors(String imageUrl) async {
if (_lastImageUrl == imageUrl) return;
_lastImageUrl = imageUrl;
final extractionId = ++_colorExtractionId;
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
if (!mounted) return;
if (extractionId != _colorExtractionId) return;
if (colorSchemes != null) {
if (colorSchemes != null && mounted) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
@@ -2061,7 +2056,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
: colorScheme.primaryContainer;
// Create a darker shade for the "unplayed" portion of progress bar
final collapsedBgUnplayed = Color.lerp(collapsedBg, Colors.black, 0.3)!;
final expandedBg = adaptiveScheme?.surface ?? colorScheme.surface;
final expandedBg = adaptiveScheme?.surface ?? const Color(0xFF121212);
final expandedPrimary = adaptiveScheme?.primary;
// Always update to current value - don't preserve stale adaptive colors
_currentExpandedBgColor = expandedBg;
+2 -2
View File
@@ -403,7 +403,7 @@ class _MediaContextMenuOverlayState extends State<_MediaContextMenuOverlay>
switch (widget.mediaType) {
case ContextMenuMediaType.artist:
final artist = widget.item as Artist;
await maProvider.playArtistRadio(player.playerId, artist);
await maProvider.api?.playArtistRadio(player.playerId, artist);
break;
case ContextMenuMediaType.track:
final track = widget.item as Track;
@@ -437,7 +437,7 @@ class _MediaContextMenuOverlayState extends State<_MediaContextMenuOverlay>
switch (widget.mediaType) {
case ContextMenuMediaType.artist:
final artist = widget.item as Artist;
await maProvider.playArtistRadio(player.playerId, artist);
await maProvider.api?.playArtistRadio(player.playerId, artist);
break;
case ContextMenuMediaType.track:
final track = widget.item as Track;
-1
View File
@@ -238,7 +238,6 @@ class _VolumeControlState extends State<VolumeControl> {
children: [
// The slider with gesture detection for precision mode
GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (details) {
setState(() {
_isDragging = true;
+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'