Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf0336e99d | ||
|
|
73fd1dbdcd | ||
|
|
2eb22cf524 |
@@ -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
|
||||
|
||||
|
||||
@@ -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 '../..'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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}';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user