Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8686edd8d7 | ||
|
|
8e5d4ab002 | ||
|
|
40a2e2f43a | ||
|
|
e15cfb545e | ||
|
|
76d0f0b80e | ||
|
|
a3eb3db8a2 | ||
|
|
1877205034 | ||
|
|
bb46a2dee1 | ||
|
|
2c07134404 | ||
|
|
ea99b2bb11 | ||
|
|
9cd27553c1 | ||
|
|
0db9bb3df7 | ||
|
|
0a88d147ed | ||
|
|
c3f6710dd1 | ||
|
|
68e3b74d5d | ||
|
|
b3aa55a021 | ||
|
|
823b95ec1a | ||
|
|
59d48deaf7 | ||
|
|
753f5161fb | ||
|
|
e4cdb40fde | ||
|
|
ad5ecf59b0 |
@@ -1,167 +0,0 @@
|
||||
# Music Assistant 2.8.0 Beta 14 — Impact on Ensemble
|
||||
|
||||
**Date:** 2026-03-03
|
||||
**Current server:** 2.8.0 beta 12
|
||||
**Target server:** 2.8.0 beta 14 (released March 2, 2026)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Beta 14 is a large release (50+ PRs). The headline change — merging multi-protocol players into a single entity — directly overlaps with Ensemble's client-side Cast↔Sendspin consolidation logic. Sendspin also received a major v4.0 overhaul. Most changes should degrade gracefully but player ID persistence will break on upgrade.
|
||||
|
||||
---
|
||||
|
||||
## Breaking: Player Merging (PR #3150)
|
||||
|
||||
MA now merges players that support multiple protocols (e.g. a Chromecast with Cast + Sendspin) into a **single player entity** server-side. Previously these appeared as two separate players.
|
||||
|
||||
> *"After this change, all your players will be reset. If you previously had multiple players in Music Assistant per protocol, this will now show up as one single player."*
|
||||
|
||||
### What Ensemble does today
|
||||
|
||||
Ensemble handles this client-side in `music_assistant_provider.dart:4271-4406`:
|
||||
|
||||
- `_castToSendspinIdMap` maps Cast IDs → Sendspin IDs
|
||||
- When Sendspin is grouped: hides Cast player, shows Sendspin (renamed to base name)
|
||||
- When ungrouped: hides Sendspin, shows original Cast
|
||||
- Mappings persisted to database for cold start restoration
|
||||
|
||||
### Impact on Ensemble
|
||||
|
||||
| Component | Location | Impact | Severity |
|
||||
|---|---|---|---|
|
||||
| `_castToSendspinIdMap` + consolidation logic | `provider:4271-4406` | Dead code — server no longer sends separate players | Medium |
|
||||
| `isManuallySyncedPlayer()` ID translation | `provider:533-595` | Lookups against stale map fail silently (returns `false`) | Low |
|
||||
| Player selection persistence | Settings + DB | **Stored player IDs invalid after upgrade** — all players reset | **High** |
|
||||
| `_handlePlayerUpdatedEvent()` ID matching | `provider:2956-3358` | Events arrive with new merged IDs, old translation won't match | Medium |
|
||||
| Database-persisted Cast↔Sendspin mappings | `DatabaseService` | Stale data, won't match anything | Low |
|
||||
|
||||
### Assessment
|
||||
|
||||
The consolidation logic should degrade gracefully — it won't find Sendspin suffixes to strip or Cast pairs to merge, so the server's already-merged players pass through `filterPlayers()` as-is. The main risk is **player selection breaking** because saved player IDs no longer exist.
|
||||
|
||||
### Action required
|
||||
|
||||
1. Verify the existing fallback logic (`provider:4427-4465`) gracefully re-selects when the stored `selectedPlayerId` doesn't exist in the player list
|
||||
2. Remove or gate the Cast↔Sendspin consolidation code behind a server version check (on beta 14+ it's unnecessary and could cause confusion)
|
||||
3. Clear stale database mappings on first connection to a beta 14+ server
|
||||
|
||||
---
|
||||
|
||||
## Sendspin v4.0 (PR #3158)
|
||||
|
||||
Major rewrite of the Sendspin server.
|
||||
|
||||
### Changes
|
||||
|
||||
- New `SendspinPlaybackSession` coordinator architecture
|
||||
- Per-member DSP pipelines, dynamic group membership, late-join audio backfill
|
||||
- Max buffer capacity 5 → 30 seconds
|
||||
- Multiple Sendspin servers with automatic failover
|
||||
- Per-client codec/sample-rate/bit-depth options
|
||||
- Album artwork persists during skip/pause
|
||||
- Reduced latency for live streams
|
||||
|
||||
### Impact on Ensemble
|
||||
|
||||
| Change | Impact |
|
||||
|---|---|
|
||||
| New session architecture | **Likely compatible** — sendspin-js only bumped 2.0.0 → 2.0.1 (minor), suggesting wire protocol didn't fundamentally change |
|
||||
| Per-client codec options | `stream/start` may contain new fields — safe if Ensemble ignores unknown fields |
|
||||
| Multiple servers with failover | Ensemble's 3-tier connection strategy may need awareness |
|
||||
| Buffer increase | No client impact |
|
||||
|
||||
### Action required
|
||||
|
||||
1. Test Sendspin connection against beta 14 — verify `client/hello`, `stream/start`, state reporting all still work
|
||||
2. Confirm unknown fields in protocol messages are ignored gracefully
|
||||
|
||||
---
|
||||
|
||||
## Sendspin-Chromecast Bridge (PR #3255)
|
||||
|
||||
Replaces the inline experimental Sendspin mode in `ChromecastPlayer` with a dedicated bridge. Cast devices now use a Cast receiver app with an embedded JS Sendspin client.
|
||||
|
||||
### Impact on Ensemble
|
||||
|
||||
- **Direct impact: None** — Ensemble is a Sendspin client itself, not going through Cast
|
||||
- **Indirect:** Changes how grouped Cast players behave, which could affect `GroupVolumeManager`
|
||||
|
||||
---
|
||||
|
||||
## Group Volume / Grouping Fixes
|
||||
|
||||
| PR | Change | Ensemble Impact |
|
||||
|---|---|---|
|
||||
| #3192 | Fix grouping for players whose native protocol is also a protocol of other players | Bidirectional grouping translation — may affect group detection logic |
|
||||
| #3205 | Fix group mute for protocol-synced players (`player.state.group_members` vs `player.group_members`) | `GroupVolumeManager` may see different/correct member lists |
|
||||
| #3277 | Fix `volume_up`/`volume_down` for group players | Server-side fix, should improve behavior |
|
||||
| #3118 | Fix sync groups losing members on power off | Affects dynamic group management |
|
||||
|
||||
### Action required
|
||||
|
||||
1. Test group volume behavior — may "just work better" with server-side fixes
|
||||
2. Verify `GroupVolumeManager` handles any changes to `group_members` field population
|
||||
|
||||
---
|
||||
|
||||
## Sendspin Bug Fixes
|
||||
|
||||
| PR | Fix | Ensemble Relevance |
|
||||
|---|---|---|
|
||||
| #3191 | DSP not applying for Sendspin players | DSP config stored on parent player, looked up by protocol player ID — now fixed |
|
||||
| #3249 | Update to aiosendspin 4.2.0 | Library update, potential behavior changes |
|
||||
| #3250 | Metadata sending wrong progress when paused | Progress reporting fix — Ensemble's position tracker may see better data |
|
||||
| #3258 | Floating point for internal audio data | Audio quality improvement, no client impact |
|
||||
|
||||
---
|
||||
|
||||
## New APIs — Opportunities
|
||||
|
||||
| Feature | PR | Details | Ensemble Opportunity |
|
||||
|---|---|---|---|
|
||||
| Playback speed | #3198 | `player_queues/set_playback_speed`, stored in `queue.extra_attributes["playback_speed"]` | Great for audiobooks/podcasts |
|
||||
| Save queue to playlist | #3149 | `save_as_playlist` command on player queue controller | Easy feature add |
|
||||
| Mixed-media playlists | #3216 | Playlists can contain tracks + podcasts + audiobooks. DB schema bumped to v30. `get_playlist_tracks()` returns `Sequence[PlaylistPlayableItem]` | Playlist screens may need to handle heterogeneous items |
|
||||
| Genres v2 | #3164 | New browsing category with icons and SVG support | New library section |
|
||||
|
||||
---
|
||||
|
||||
## Also Changed in Beta 13 (Skipped)
|
||||
|
||||
Relevant changes from beta 13 (between current beta 12 and target beta 14):
|
||||
|
||||
- Group volume mute support (#3034)
|
||||
- Frontend prepared for multiprotocol support (#1409) — groundwork for beta 14 merging
|
||||
- IPv6 support for zeroconf, stream server, AirPlay (#3086)
|
||||
- Snapcast player availability alignment (#3104)
|
||||
- DLNA reconnection fix (#3132)
|
||||
|
||||
---
|
||||
|
||||
## Recommended Action Plan
|
||||
|
||||
### Before upgrading
|
||||
|
||||
- [ ] Verify player selection fallback handles missing player IDs gracefully
|
||||
- [ ] Back up current configuration
|
||||
|
||||
### Immediate (after upgrading)
|
||||
|
||||
- [ ] Test Sendspin protocol compatibility (connection, streaming, state reporting)
|
||||
- [ ] Test player list — confirm merged players appear correctly without client-side consolidation
|
||||
- [ ] Test group volume behavior with new server-side fixes
|
||||
- [ ] Test player selection persistence across app restarts
|
||||
|
||||
### Short-term
|
||||
|
||||
- [ ] Remove or version-gate Cast↔Sendspin consolidation code
|
||||
- [ ] Clear stale database mappings on beta 14+ detection
|
||||
- [ ] Test mixed-media playlist handling if playlists are used
|
||||
|
||||
### Opportunistic
|
||||
|
||||
- [ ] Add playback speed control (audiobook/podcast feature)
|
||||
- [ ] Add save-queue-to-playlist
|
||||
- [ ] Explore Genres v2 browsing
|
||||
@@ -175,7 +175,6 @@ 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,16 +77,6 @@ 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,6 +54,12 @@
|
||||
</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" />
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
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,12 +1,19 @@
|
||||
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
|
||||
@@ -17,7 +24,10 @@ 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
|
||||
@@ -30,6 +40,10 @@ 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")
|
||||
@@ -74,6 +88,65 @@ 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.
|
||||
@@ -224,6 +297,13 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<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>
|
||||
@@ -0,0 +1,9 @@
|
||||
<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>
|
||||
@@ -0,0 +1,11 @@
|
||||
<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>
|
||||
@@ -0,0 +1,11 @@
|
||||
<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>
|
||||
@@ -0,0 +1,9 @@
|
||||
<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>
|
||||
@@ -0,0 +1,11 @@
|
||||
<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>
|
||||
@@ -0,0 +1,13 @@
|
||||
<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,6 +70,9 @@ 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,5 +1,6 @@
|
||||
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;
|
||||
@@ -99,6 +100,14 @@ 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)
|
||||
@@ -117,6 +126,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
TrackMetadata? _pendingTrackMetadata;
|
||||
TrackMetadata? _currentNotificationMetadata;
|
||||
Completer<void>? _registrationInProgress;
|
||||
Completer<void>? _reconnectInProgress;
|
||||
|
||||
// Local player service
|
||||
late final LocalPlayerService _localPlayer;
|
||||
@@ -230,6 +240,22 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
};
|
||||
}
|
||||
|
||||
/// Check if a server player ID matches our local builtin player ID.
|
||||
/// MA beta 14+ wraps Sendspin player IDs with 'up' prefix and lowercases
|
||||
/// them when merging into universal_player entities, so we need flexible matching.
|
||||
/// e.g. local "ensemble_Chris_CP21.260" matches server "upensemble_chris_cp21.260"
|
||||
bool _isBuiltinPlayer(String serverPlayerId, String builtinPlayerId) {
|
||||
if (serverPlayerId == builtinPlayerId) return true;
|
||||
return serverPlayerId.toLowerCase() == 'up${builtinPlayerId.toLowerCase()}' ||
|
||||
serverPlayerId.toLowerCase() == builtinPlayerId.toLowerCase();
|
||||
}
|
||||
|
||||
/// Check if a server player ID is an Ensemble player (this or other device)
|
||||
bool _isEnsemblePlayer(String serverPlayerId) {
|
||||
final lower = serverPlayerId.toLowerCase();
|
||||
return lower.startsWith('ensemble_') || lower.startsWith('upensemble_');
|
||||
}
|
||||
|
||||
/// Player filter from MA user settings (empty = all players allowed)
|
||||
List<String> get playerFilter => _playerFilter;
|
||||
|
||||
@@ -681,6 +707,40 @@ 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;
|
||||
|
||||
@@ -885,14 +945,30 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// Restore selected player from settings
|
||||
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
||||
if (lastSelectedPlayerId != null) {
|
||||
try {
|
||||
_selectedPlayer = players.firstWhere((p) => p.playerId == lastSelectedPlayerId);
|
||||
_cacheService.setCachedSelectedPlayer(_selectedPlayer);
|
||||
_selectedPlayer = players.cast<Player?>().firstWhere(
|
||||
(p) => p!.playerId == lastSelectedPlayerId,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (_selectedPlayer != null) {
|
||||
_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();
|
||||
@@ -953,6 +1029,9 @@ 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');
|
||||
@@ -1946,6 +2025,12 @@ 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) {
|
||||
@@ -1953,60 +2038,82 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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()!;
|
||||
_reconnectInProgress = Completer<void>();
|
||||
|
||||
// Sort cached players immediately so list appears in correct order
|
||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
_sortPlayersSync(_availablePlayers, smartSort, builtinPlayerId);
|
||||
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()!;
|
||||
|
||||
_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,
|
||||
// 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,
|
||||
);
|
||||
} catch (e) {
|
||||
_selectedPlayer = _availablePlayers.first;
|
||||
}
|
||||
} else {
|
||||
_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
_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');
|
||||
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');
|
||||
if (_connectionState != MAConnectionState.connected &&
|
||||
_connectionState != MAConnectionState.authenticated) {
|
||||
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
|
||||
try {
|
||||
await connectToServer(_serverUrl!);
|
||||
} catch (reconnectError) {
|
||||
_logger.log('🔄 Reconnection failed: $reconnectError');
|
||||
_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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_reconnectInProgress!.complete();
|
||||
} catch (e) {
|
||||
_reconnectInProgress!.completeError(e);
|
||||
} finally {
|
||||
_reconnectInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2029,16 +2136,43 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
};
|
||||
audioHandler.onPlay = () {
|
||||
_logger.log('🎵 Notification: Play pressed');
|
||||
playPauseSelectedPlayer();
|
||||
if (_selectedPlayer != null) {
|
||||
resumePlayer(_selectedPlayer!.playerId);
|
||||
unawaited(refreshPlayers());
|
||||
}
|
||||
};
|
||||
audioHandler.onPause = () {
|
||||
_logger.log('🎵 Notification: Pause pressed');
|
||||
playPauseSelectedPlayer();
|
||||
if (_selectedPlayer != null) {
|
||||
pausePlayer(_selectedPlayer!.playerId);
|
||||
}
|
||||
};
|
||||
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)
|
||||
@@ -2466,6 +2600,11 @@ 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
|
||||
@@ -2508,25 +2647,46 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
// Keep the foreground service active to prevent Android from throttling
|
||||
// the PCM audio playback when the app goes to background.
|
||||
// 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
|
||||
// Use updateLocalModeNotification (NOT setRemotePlaybackState) to avoid
|
||||
// conflicting mediaItem.add() calls — _updatePlayerState() also calls
|
||||
// updateLocalModeNotification with the real track data.
|
||||
_cancelIdleServiceTimer();
|
||||
audioHandler.setRemotePlaybackState(
|
||||
item: mediaItem,
|
||||
playing: true,
|
||||
position: Duration.zero,
|
||||
duration: mediaItem.duration,
|
||||
);
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
// Start notification position timer for Sendspin PCM
|
||||
_manageNotificationPositionTimer();
|
||||
@@ -2537,8 +2697,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 {
|
||||
// Capture current position before stopping
|
||||
final lastPosition = _pcmAudioPlayer?.elapsedTime ?? Duration.zero;
|
||||
// Use position tracker (server-synced) instead of PCM elapsed time which resets to 0 each stream
|
||||
final lastPosition = _positionTracker.currentPosition;
|
||||
_logger.log('🎵 Sendspin: Stream ended at position ${lastPosition.inSeconds}s');
|
||||
|
||||
// Stop notification position timer
|
||||
@@ -2547,27 +2707,46 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// Pause PCM playback (preserves position) instead of stop (resets position)
|
||||
await _pcmAudioPlayer?.pause();
|
||||
|
||||
// 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
|
||||
// Update foreground service to show paused state with last position.
|
||||
// Use updateLocalModeNotification (NOT setRemotePlaybackState) to avoid
|
||||
// conflicting mediaItem.add() calls with _updatePlayerState().
|
||||
_cancelIdleServiceTimer();
|
||||
audioHandler.setRemotePlaybackState(
|
||||
item: mediaItem,
|
||||
playing: false,
|
||||
position: lastPosition,
|
||||
duration: mediaItem.duration,
|
||||
);
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _startReportingLocalPlayerState() {
|
||||
@@ -2771,6 +2950,7 @@ 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');
|
||||
}
|
||||
@@ -2783,6 +2963,7 @@ 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');
|
||||
}
|
||||
@@ -3821,6 +4002,17 @@ 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
|
||||
@@ -3849,7 +4041,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
_cacheService.setCachedArtistAlbums(cacheKey, allAlbums);
|
||||
// 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);
|
||||
}
|
||||
_logger.log('✅ Cached ${allAlbums.length} albums for artist "$artistName"');
|
||||
return allAlbums;
|
||||
} catch (e) {
|
||||
@@ -3991,8 +4187,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// Smart sort: local player first, then playing, then on, then off
|
||||
players.sort((a, b) {
|
||||
// Local player always first
|
||||
final aIsLocal = builtinPlayerId != null && a.playerId == builtinPlayerId;
|
||||
final bIsLocal = builtinPlayerId != null && b.playerId == builtinPlayerId;
|
||||
final aIsLocal = builtinPlayerId != null && _isBuiltinPlayer(a.playerId, builtinPlayerId);
|
||||
final bIsLocal = builtinPlayerId != null && _isBuiltinPlayer(b.playerId, builtinPlayerId);
|
||||
if (aIsLocal && !bIsLocal) return -1;
|
||||
if (bIsLocal && !aIsLocal) return 1;
|
||||
|
||||
@@ -4069,8 +4265,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (player.playerId.startsWith('ensemble_')) {
|
||||
if (builtinPlayerId == null || player.playerId != builtinPlayerId) {
|
||||
if (_isEnsemblePlayer(player.playerId)) {
|
||||
if (builtinPlayerId == null || !_isBuiltinPlayer(player.playerId, builtinPlayerId)) {
|
||||
_logger.log('🚫 Filtering out other device\'s player: ${player.name}');
|
||||
filteredCount++;
|
||||
return false;
|
||||
@@ -4078,7 +4274,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
if (!player.available) {
|
||||
if (builtinPlayerId != null && player.playerId == builtinPlayerId) {
|
||||
if (builtinPlayerId != null && _isBuiltinPlayer(player.playerId, builtinPlayerId)) {
|
||||
return true;
|
||||
}
|
||||
filteredCount++;
|
||||
@@ -4299,19 +4495,31 @@ 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) {
|
||||
try {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == builtinPlayerId && p.available,
|
||||
);
|
||||
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
||||
(p) => _isBuiltinPlayer(p!.playerId, builtinPlayerId) && p.available,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (playerToSelect != null) {
|
||||
_logger.log('📱 Selected local player: ${playerToSelect.name}');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Local player not available');
|
||||
} else {
|
||||
// Player exists but not yet available (Sendspin registration in progress)
|
||||
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
||||
(p) => _isBuiltinPlayer(p!.playerId, builtinPlayerId),
|
||||
orElse: () => null,
|
||||
);
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: First available player
|
||||
if (playerToSelect == null) {
|
||||
// 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) {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.available,
|
||||
orElse: () => _availablePlayers.first,
|
||||
@@ -4319,7 +4527,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
|
||||
}
|
||||
|
||||
selectPlayer(playerToSelect);
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// Preload track data and images in background for swipe gestures
|
||||
@@ -4370,23 +4584,22 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
// Switch audio handler mode based on player type
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
final isBuiltinPlayer = builtinPlayerId != null && player.playerId == builtinPlayerId;
|
||||
final isBuiltinPlayer = builtinPlayerId != null && _isBuiltinPlayer(player.playerId, builtinPlayerId);
|
||||
if (isBuiltinPlayer) {
|
||||
audioHandler.setLocalMode();
|
||||
// Update notification for builtin player using local mode method (keeps pause working)
|
||||
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
|
||||
final track = _currentTrack!;
|
||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
||||
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||
? '${track.artistsString} • ${player.name}'
|
||||
: player.name;
|
||||
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: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
|
||||
artUri: _contentArtUri(artworkUrl),
|
||||
);
|
||||
// Position comes from actual player in updateLocalModeNotification
|
||||
audioHandler.updateLocalModeNotification(
|
||||
@@ -4405,8 +4618,29 @@ 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 - clear notification (fixes issue #42)
|
||||
// Builtin player is idle with no track - clear notification
|
||||
audioHandler.clearRemotePlaybackState();
|
||||
_startIdleServiceTimer();
|
||||
}
|
||||
@@ -4417,16 +4651,15 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
final track = _currentTrack!;
|
||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
||||
// Include player name in artist line: "Artist • Player Name"
|
||||
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||
? '${track.artistsString} • ${player.name}'
|
||||
: player.name;
|
||||
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: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
|
||||
artUri: _contentArtUri(artworkUrl),
|
||||
);
|
||||
// Use position tracker for consistent position
|
||||
final position = _positionTracker.currentPosition;
|
||||
@@ -4556,7 +4789,6 @@ 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
|
||||
@@ -4599,25 +4831,37 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
||||
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||
? '${track.artistsString} • ${_selectedPlayer!.name}'
|
||||
: _selectedPlayer!.name;
|
||||
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: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
|
||||
artUri: _contentArtUri(artworkUrl),
|
||||
);
|
||||
|
||||
_cancelIdleServiceTimer();
|
||||
audioHandler.setRemotePlaybackState(
|
||||
item: mediaItem,
|
||||
playing: isPlaying,
|
||||
position: position,
|
||||
duration: track.duration,
|
||||
);
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _preloadAdjacentPlayers({bool preloadAll = false}) async {
|
||||
@@ -4988,7 +5232,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);
|
||||
final shouldShowTrack = _selectedPlayer!.available && (isPlayingOrPaused || isIdleWithContent || isPcmPlaying);
|
||||
|
||||
if (!shouldShowTrack) {
|
||||
if (_currentTrack != null) {
|
||||
@@ -5032,6 +5276,10 @@ 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 ||
|
||||
@@ -5123,20 +5371,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
final track = _currentTrack!;
|
||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
final isBuiltinPlayer = builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId;
|
||||
final isBuiltinPlayer = builtinPlayerId != null && _isBuiltinPlayer(_selectedPlayer!.playerId, builtinPlayerId);
|
||||
|
||||
if (isBuiltinPlayer) {
|
||||
// Local playback - use local mode notification (keeps pause working)
|
||||
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||
? '${track.artistsString} • ${_selectedPlayer!.name}'
|
||||
: _selectedPlayer!.name;
|
||||
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: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
|
||||
artUri: _contentArtUri(artworkUrl),
|
||||
);
|
||||
// Position comes from actual player in updateLocalModeNotification
|
||||
audioHandler.updateLocalModeNotification(
|
||||
@@ -5147,16 +5394,15 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
} else {
|
||||
// Remote MA player - show notification via remote mode
|
||||
// Include player name in artist line: "Artist • Player Name"
|
||||
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||
? '${track.artistsString} • ${_selectedPlayer!.name}'
|
||||
: _selectedPlayer!.name;
|
||||
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: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
|
||||
artUri: _contentArtUri(artworkUrl),
|
||||
);
|
||||
// Use position tracker for consistent position (single source of truth)
|
||||
final position = _positionTracker.currentPosition;
|
||||
@@ -5171,7 +5417,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
} else {
|
||||
// No queue data available from API
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
final isBuiltinPlayer = builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId;
|
||||
final isBuiltinPlayer = builtinPlayerId != null && _isBuiltinPlayer(_selectedPlayer!.playerId, builtinPlayerId);
|
||||
|
||||
// For builtin player, try to preserve cached track data since queue API doesn't return it
|
||||
// The cached track was set in selectPlayer() and contains valid data
|
||||
@@ -5222,9 +5468,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
final mediaItem = audio_service.MediaItem(
|
||||
id: track.uri ?? track.itemId,
|
||||
title: track.name,
|
||||
artist: track.artistsString,
|
||||
artist: _trackDisplayArtist(track),
|
||||
album: track.album?.name,
|
||||
artUri: _api != null ? Uri.tryParse(_api!.getImageUrl(track, size: 512) ?? '') : null,
|
||||
artUri: _contentArtUri(_api?.getImageUrl(track, size: 512)),
|
||||
duration: track.duration,
|
||||
);
|
||||
audioHandler.updateLocalModeNotification(
|
||||
@@ -5259,15 +5505,23 @@ 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 {
|
||||
// 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);
|
||||
// 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 && _isBuiltinPlayer(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));
|
||||
}
|
||||
}
|
||||
|
||||
// For resume: Refresh in background to get updated track info
|
||||
unawaited(refreshPlayers());
|
||||
}
|
||||
@@ -5375,6 +5629,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_syncLibraryStatusToService();
|
||||
notifyListeners();
|
||||
_prefetchAlbumImages();
|
||||
audioHandler.invalidateAutoChildren(const [
|
||||
'cat|artists', 'cat|albums', 'cat|playlists', 'cat|home',
|
||||
]);
|
||||
}
|
||||
syncService.removeListener(onSyncComplete);
|
||||
}
|
||||
@@ -5730,7 +5987,6 @@ 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');
|
||||
}
|
||||
@@ -5795,11 +6051,16 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
try {
|
||||
await _api?.playRadio(playerId, track);
|
||||
} catch (e) {
|
||||
final errorInfo = ErrorHandler.handleError(e, context: 'Play radio');
|
||||
_error = errorInfo.userMessage;
|
||||
ErrorHandler.logError('Play radio', e);
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5807,11 +6068,21 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
try {
|
||||
await _api?.playArtistRadio(playerId, artist);
|
||||
} catch (e) {
|
||||
final errorInfo = ErrorHandler.handleError(e, context: 'Play artist radio');
|
||||
_error = errorInfo.userMessage;
|
||||
ErrorHandler.logError('Play artist radio', e);
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5824,7 +6095,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// Get builtin player ID - this is cached so should be fast
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
|
||||
if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) {
|
||||
if (builtinPlayerId != null && _isBuiltinPlayer(playerId, builtinPlayerId) && _sendspinConnected) {
|
||||
_logger.log('⏸️ Non-blocking local pause for builtin player');
|
||||
|
||||
// CRITICAL: Don't await these - they can block the UI thread
|
||||
@@ -5926,7 +6197,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
try {
|
||||
// Optimistic local stop for builtin player on skip - non-blocking
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) {
|
||||
if (builtinPlayerId != null && _isBuiltinPlayer(playerId, builtinPlayerId) && _sendspinConnected) {
|
||||
_logger.log('⏭️ Non-blocking local stop for skip on builtin player');
|
||||
// Stop current audio immediately - fire and forget, but log errors
|
||||
unawaited((_pcmAudioPlayer?.pause() ?? Future.value()).catchError(
|
||||
@@ -5945,7 +6216,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
try {
|
||||
// Optimistic local stop for builtin player on previous - non-blocking
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) {
|
||||
if (builtinPlayerId != null && _isBuiltinPlayer(playerId, builtinPlayerId) && _sendspinConnected) {
|
||||
_logger.log('⏮️ Non-blocking local stop for previous on builtin player');
|
||||
// Stop current audio immediately - fire and forget, but log errors
|
||||
unawaited((_pcmAudioPlayer?.pause() ?? Future.value()).catchError(
|
||||
@@ -6359,7 +6630,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
if (builtinPlayerId != null && playerId == builtinPlayerId) {
|
||||
if (builtinPlayerId != null && _isBuiltinPlayer(playerId, builtinPlayerId)) {
|
||||
_localPlayerVolume = volumeLevel;
|
||||
FlutterVolumeController.setVolume(volumeLevel / 100.0);
|
||||
}
|
||||
|
||||
@@ -430,29 +430,28 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
),
|
||||
),
|
||||
),
|
||||
// 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],
|
||||
// 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -503,9 +502,8 @@ 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
|
||||
// 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
|
||||
// Reserve space for the mini player overlay at the bottom.
|
||||
final miniPlayerSpace = BottomSpacing.miniPlayerHeight + 22.0;
|
||||
final availableHeight = constraints.maxHeight - miniPlayerSpace;
|
||||
|
||||
// Account for margins between rows (2px each, 2 margins for 3 rows).
|
||||
|
||||
@@ -159,10 +159,8 @@ 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
|
||||
bool _isFilterBarVisible = true;
|
||||
double _lastScrollOffset = 0;
|
||||
static const double _scrollThreshold = 10.0;
|
||||
// Scroll-to-hide filter bars (0.0 = hidden, 1.0 = visible)
|
||||
final ValueNotifier<double> _filterBarVisibility = ValueNotifier<double>(1.0);
|
||||
bool _isLetterScrollbarDragging = false; // Disable scroll-to-hide while dragging
|
||||
|
||||
// Options menu overlay - stored to close on navigation
|
||||
@@ -859,17 +857,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
}
|
||||
|
||||
if (notification is ScrollUpdateNotification) {
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -911,13 +903,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
}
|
||||
|
||||
void _onLetterScrollbarDragChanged(bool isDragging) {
|
||||
setState(() {
|
||||
_isLetterScrollbarDragging = isDragging;
|
||||
// Show the filter bar when starting to drag
|
||||
if (isDragging) {
|
||||
_isFilterBarVisible = true;
|
||||
}
|
||||
});
|
||||
_isLetterScrollbarDragging = isDragging;
|
||||
// Show the filter bar when starting to drag
|
||||
if (isDragging) {
|
||||
_filterBarVisibility.value = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
void _animateToCategory(int index) {
|
||||
@@ -942,6 +932,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
_pageController.dispose();
|
||||
_selectedTabIndex.dispose();
|
||||
_tabIndexNotifier.dispose();
|
||||
_filterBarVisibility.dispose();
|
||||
_artistsScrollController.dispose();
|
||||
_albumsScrollController.dispose();
|
||||
_playlistsScrollController.dispose();
|
||||
@@ -1449,7 +1440,12 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
child: Column(
|
||||
children: [
|
||||
// Two-row filter: Row 1 = Media types (hides on scroll), Row 2 = Sub-categories (always visible)
|
||||
_buildFilterRows(colorScheme, l10n, showLibraryTypeRow: _isFilterBarVisible),
|
||||
ValueListenableBuilder<double>(
|
||||
valueListenable: _filterBarVisibility,
|
||||
builder: (context, visibility, _) {
|
||||
return _buildFilterRows(colorScheme, l10n, barVisibility: visibility);
|
||||
},
|
||||
),
|
||||
// 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)
|
||||
@@ -1546,23 +1542,25 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
// Consistent height for filter rows
|
||||
static const double _filterRowHeight = 36.0;
|
||||
|
||||
Widget _buildFilterRows(ColorScheme colorScheme, S l10n, {required bool showLibraryTypeRow}) {
|
||||
Widget _buildFilterRows(ColorScheme colorScheme, S l10n, {required double barVisibility}) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Row 1: Media type chips (hides when scrolling)
|
||||
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),
|
||||
ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
heightFactor: barVisibility,
|
||||
child: SizedBox(
|
||||
height: _filterRowHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: _buildMediaTypeChips(colorScheme, l10n),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showLibraryTypeRow) const SizedBox(height: 12), // Space between rows
|
||||
SizedBox(height: 12 * barVisibility), // Space between rows
|
||||
// Row 2: Sub-category chips (left) + action buttons (right) - always visible
|
||||
SizedBox(
|
||||
height: _filterRowHeight,
|
||||
@@ -4914,7 +4912,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
|
||||
try {
|
||||
// Start radio from this track
|
||||
await maProvider.api?.playRadio(player.playerId, track);
|
||||
await maProvider.playRadio(player.playerId, track);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@@ -130,10 +130,8 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
// Advanced search scorer with fuzzy matching, stopword removal, etc.
|
||||
final SearchScorer _searchScorer = SearchScorer();
|
||||
|
||||
// Scroll-to-hide search bar (vertical scroll only)
|
||||
bool _isSearchBarVisible = true;
|
||||
double _lastVerticalScrollOffset = 0;
|
||||
static const double _scrollThreshold = 10.0;
|
||||
// Scroll-to-hide search bar (0.0 = hidden, 1.0 = visible)
|
||||
final ValueNotifier<double> _searchBarVisibility = ValueNotifier<double>(1.0);
|
||||
|
||||
// Filter chip position tracking for animated sliding highlight
|
||||
final Map<String, GlobalKey> _filterKeys = {};
|
||||
@@ -314,10 +312,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
|
||||
void requestFocus() {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSearchBarVisible = true;
|
||||
_lastVerticalScrollOffset = 0;
|
||||
});
|
||||
_searchBarVisibility.value = 1.0;
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
@@ -336,6 +331,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
_pageController.dispose();
|
||||
_filterScrollController.dispose();
|
||||
_activeFilterNotifier.dispose();
|
||||
_searchBarVisibility.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -347,17 +343,12 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
}
|
||||
|
||||
if (notification is ScrollUpdateNotification) {
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -534,16 +525,23 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 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(
|
||||
// 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(
|
||||
children: [
|
||||
// Search field container
|
||||
Expanded(
|
||||
@@ -647,6 +645,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Main content
|
||||
Expanded(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,14 @@ 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;
|
||||
@@ -145,7 +153,7 @@ class MusicAssistantAPI {
|
||||
queryParameters: {'client_id': clientId},
|
||||
);
|
||||
_logger.log('Using custom WebSocket port from settings: $_cachedCustomPort');
|
||||
} else if (uri.hasPort) {
|
||||
} else if (_hasExplicitPort(serverUrl)) {
|
||||
// Port is already specified in URL, keep it
|
||||
finalUri = Uri(
|
||||
scheme: uri.scheme,
|
||||
@@ -251,7 +259,6 @@ 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')) {
|
||||
@@ -1981,7 +1988,6 @@ 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?;
|
||||
@@ -2484,7 +2490,7 @@ class MusicAssistantAPI {
|
||||
port: _cachedCustomPort,
|
||||
path: '/flow/$playerId/$streamId.$extension',
|
||||
);
|
||||
} else if (uri.hasPort) {
|
||||
} else if (_hasExplicitPort(serverUrl)) {
|
||||
// Use port from URL
|
||||
finalUri = Uri(
|
||||
scheme: uri.scheme,
|
||||
@@ -3196,7 +3202,7 @@ class MusicAssistantAPI {
|
||||
if (_cachedCustomPort != null) {
|
||||
// Use cached custom port from settings
|
||||
baseUrl = '${uriObj.scheme}://${uriObj.host}:$_cachedCustomPort';
|
||||
} else if (!uriObj.hasPort) {
|
||||
} else if (!_hasExplicitPort(serverUrl)) {
|
||||
if (useSecure) {
|
||||
// For HTTPS, don't add port 443 (it's the default)
|
||||
baseUrl = '${uriObj.scheme}://${uriObj.host}';
|
||||
@@ -3303,7 +3309,7 @@ class MusicAssistantAPI {
|
||||
if (_cachedCustomPort != null) {
|
||||
// Use cached custom port from settings
|
||||
baseUrl = '${uri.scheme}://${uri.host}:$_cachedCustomPort';
|
||||
} else if (!uri.hasPort) {
|
||||
} else if (!_hasExplicitPort(serverUrl)) {
|
||||
if (useSecure) {
|
||||
// For HTTPS, don't add port 443 (it's the default)
|
||||
baseUrl = '${uri.scheme}://${uri.host}';
|
||||
|
||||
@@ -69,6 +69,7 @@ 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
|
||||
@@ -235,7 +236,9 @@ 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.
|
||||
if (_state == PcmPlayerState.paused && !_isAutoRecovering) {
|
||||
// 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) {
|
||||
_logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery');
|
||||
_isAutoRecovering = true;
|
||||
_addToBuffer(audioData);
|
||||
@@ -492,6 +495,7 @@ 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;
|
||||
|
||||
@@ -547,6 +551,7 @@ 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,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'debug_logger.dart';
|
||||
|
||||
/// Single source of truth for playback position tracking.
|
||||
///
|
||||
@@ -13,8 +12,6 @@ import 'debug_logger.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;
|
||||
@@ -100,7 +97,6 @@ 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;
|
||||
@@ -179,11 +175,8 @@ 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;
|
||||
@@ -227,7 +220,6 @@ class PositionTracker {
|
||||
|
||||
/// Clear tracker state (e.g., when disconnecting)
|
||||
void clear() {
|
||||
_logger.debug('⏱️ PositionTracker.clear() called');
|
||||
_stopInterpolationTimer();
|
||||
_playerId = null;
|
||||
_isPlaying = false;
|
||||
@@ -237,20 +229,9 @@ class PositionTracker {
|
||||
}
|
||||
|
||||
void _startInterpolationTimer() {
|
||||
if (_interpolationTimer != null) {
|
||||
_logger.debug('⏱️ PositionTracker: Timer already running, skipping start');
|
||||
return;
|
||||
}
|
||||
if (_interpolationTimer != null) 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();
|
||||
});
|
||||
|
||||
@@ -259,7 +240,6 @@ class PositionTracker {
|
||||
}
|
||||
|
||||
void _stopInterpolationTimer() {
|
||||
_logger.debug('⏱️ PositionTracker: Stopping interpolation timer (was ${_interpolationTimer != null ? "running" : "null"})');
|
||||
_interpolationTimer?.cancel();
|
||||
_interpolationTimer = null;
|
||||
}
|
||||
@@ -271,7 +251,6 @@ 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,8 +321,6 @@ 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,6 +81,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
ColorScheme? _lightColorScheme;
|
||||
ColorScheme? _darkColorScheme;
|
||||
String? _lastImageUrl;
|
||||
int _colorExtractionId = 0;
|
||||
|
||||
// Queue state
|
||||
PlayerQueue? _queue;
|
||||
@@ -757,13 +758,17 @@ 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 (colorSchemes != null && mounted) {
|
||||
if (!mounted) return;
|
||||
if (extractionId != _colorExtractionId) return;
|
||||
|
||||
if (colorSchemes != null) {
|
||||
setState(() {
|
||||
_lightColorScheme = colorSchemes.$1;
|
||||
_darkColorScheme = colorSchemes.$2;
|
||||
@@ -2056,7 +2061,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 ?? const Color(0xFF121212);
|
||||
final expandedBg = adaptiveScheme?.surface ?? colorScheme.surface;
|
||||
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.api?.playArtistRadio(player.playerId, artist);
|
||||
await maProvider.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.api?.playArtistRadio(player.playerId, artist);
|
||||
await maProvider.playArtistRadio(player.playerId, artist);
|
||||
break;
|
||||
case ContextMenuMediaType.track:
|
||||
final track = widget.item as Track;
|
||||
|
||||
@@ -238,6 +238,7 @@ 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.5+57
|
||||
version: 3.0.4+56
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
Reference in New Issue
Block a user