Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7fe6dfbbe | ||
|
|
cb07e55d69 | ||
|
|
cf0336e99d | ||
|
|
73fd1dbdcd | ||
|
|
2eb22cf524 |
@@ -0,0 +1,167 @@
|
|||||||
|
# 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,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 huge thank you to my sponsors! 💖
|
||||||
|
|
||||||
<a href="https://github.com/pcsokonay"><img src="https://github.com/pcsokonay.png" width="60px" alt="pcsokonay" /></a>
|
<a href="https://github.com/pcsokonay"><img src="https://github.com/pcsokonay.png" width="60px" alt="pcsokonay" /></a>
|
||||||
|
<a href="https://github.com/githubchb"><img src="https://github.com/githubchb.png" width="60px" alt="githubchb" /></a>
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,16 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def abiCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86_64': 3]
|
||||||
|
android.applicationVariants.configureEach { variant ->
|
||||||
|
variant.outputs.each { output ->
|
||||||
|
def abiVersionCode = abiCodes[output.getFilter('ABI')]
|
||||||
|
if (abiVersionCode != null) {
|
||||||
|
output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
source '../..'
|
source '../..'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,12 +54,6 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<provider
|
|
||||||
android:name=".ArtworkContentProvider"
|
|
||||||
android:authorities="com.collotsspot.ensemble.artwork"
|
|
||||||
android:exported="true"
|
|
||||||
android:grantUriPermissions="true" />
|
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.android.gms.car.application"
|
android:name="com.google.android.gms.car.application"
|
||||||
android:resource="@xml/automotive_app_desc" />
|
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
|
package com.collotsspot.ensemble
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.database.ContentObserver
|
import android.database.ContentObserver
|
||||||
import android.hardware.display.DisplayManager
|
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.os.Build
|
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.app.UiModeManager
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Display
|
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import com.ryanheise.audioservice.AudioServiceActivity
|
import com.ryanheise.audioservice.AudioServiceActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
@@ -24,10 +17,7 @@ import io.flutter.plugin.common.MethodChannel
|
|||||||
class MainActivity: AudioServiceActivity() {
|
class MainActivity: AudioServiceActivity() {
|
||||||
private val TAG = "EnsembleVolume"
|
private val TAG = "EnsembleVolume"
|
||||||
private val CHANNEL = "com.collotsspot.ensemble/volume_buttons"
|
private val CHANNEL = "com.collotsspot.ensemble/volume_buttons"
|
||||||
private val AA_CHANNEL = "com.collotsspot.ensemble/android_auto"
|
|
||||||
private var methodChannel: MethodChannel? = null
|
private var methodChannel: MethodChannel? = null
|
||||||
private var aaChannel: MethodChannel? = null
|
|
||||||
private var carModeReceiver: BroadcastReceiver? = null
|
|
||||||
private var isListening = false
|
private var isListening = false
|
||||||
|
|
||||||
// Volume observer for lockscreen volume changes
|
// 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
|
// Guard flag to ignore volume changes triggered by our own setStreamVolume calls
|
||||||
private var ignoringVolumeChange = false
|
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) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
Log.d(TAG, "Configuring Flutter engine, setting up MethodChannel")
|
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.
|
/// Start observing system STREAM_MUSIC volume changes.
|
||||||
@@ -297,13 +224,6 @@ class MainActivity: AudioServiceActivity() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
stopVolumeObserver()
|
stopVolumeObserver()
|
||||||
carModeReceiver?.let {
|
|
||||||
try { applicationContext.unregisterReceiver(it) } catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
displayListener?.let {
|
|
||||||
val dm = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
|
||||||
dm.unregisterDisplayListener(it)
|
|
||||||
}
|
|
||||||
super.onDestroy()
|
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',
|
androidNotificationIcon: 'drawable/ic_notification',
|
||||||
androidShowNotificationBadge: false,
|
androidShowNotificationBadge: false,
|
||||||
androidStopForegroundOnPause: false, // Keep service alive when paused for background playback
|
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');
|
_logger.log('🎵 AudioService initialized - background playback and media notifications ENABLED');
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:math';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/painting.dart';
|
import 'package:flutter/painting.dart';
|
||||||
import 'package:audio_service/audio_service.dart' as audio_service;
|
import 'package:audio_service/audio_service.dart' as audio_service;
|
||||||
@@ -100,14 +99,6 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
Timer? _idleServiceTimer;
|
Timer? _idleServiceTimer;
|
||||||
static const _idleServiceTimeout = Duration(minutes: 30);
|
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
|
// Local player state
|
||||||
bool _isLocalPlayerPowered = true;
|
bool _isLocalPlayerPowered = true;
|
||||||
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
|
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
|
||||||
@@ -126,7 +117,6 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
TrackMetadata? _pendingTrackMetadata;
|
TrackMetadata? _pendingTrackMetadata;
|
||||||
TrackMetadata? _currentNotificationMetadata;
|
TrackMetadata? _currentNotificationMetadata;
|
||||||
Completer<void>? _registrationInProgress;
|
Completer<void>? _registrationInProgress;
|
||||||
Completer<void>? _reconnectInProgress;
|
|
||||||
|
|
||||||
// Local player service
|
// Local player service
|
||||||
late final LocalPlayerService _localPlayer;
|
late final LocalPlayerService _localPlayer;
|
||||||
@@ -240,22 +230,6 @@ 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)
|
/// Player filter from MA user settings (empty = all players allowed)
|
||||||
List<String> get playerFilter => _playerFilter;
|
List<String> get playerFilter => _playerFilter;
|
||||||
|
|
||||||
@@ -707,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;
|
String get lastSearchQuery => _lastSearchQuery;
|
||||||
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
|
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
|
||||||
|
|
||||||
@@ -945,30 +885,14 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// Restore selected player from settings
|
// Restore selected player from settings
|
||||||
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
||||||
if (lastSelectedPlayerId != null) {
|
if (lastSelectedPlayerId != null) {
|
||||||
_selectedPlayer = players.cast<Player?>().firstWhere(
|
try {
|
||||||
(p) => p!.playerId == lastSelectedPlayerId,
|
_selectedPlayer = players.firstWhere((p) => p.playerId == lastSelectedPlayerId);
|
||||||
orElse: () => null,
|
_cacheService.setCachedSelectedPlayer(_selectedPlayer);
|
||||||
);
|
|
||||||
if (_selectedPlayer != null) {
|
|
||||||
_logger.log('📦 Restored selected player from database: ${_selectedPlayer?.name}');
|
_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)');
|
_logger.log('📦 Loaded ${players.length} players from database (instant)');
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -1029,9 +953,6 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_logger.log('📦 Pre-loaded library for favorites: ${_albums.length} albums, ${_artists.length} artists');
|
_logger.log('📦 Pre-loaded library for favorites: ${_albums.length} albums, ${_artists.length} artists');
|
||||||
_syncLibraryStatusToService();
|
_syncLibraryStatusToService();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
audioHandler.invalidateAutoChildren(const [
|
|
||||||
'cat|artists', 'cat|albums', 'cat|playlists', 'cat|home',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('⚠️ Error loading library from cache: $e');
|
_logger.log('⚠️ Error loading library from cache: $e');
|
||||||
@@ -2025,12 +1946,6 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> checkAndReconnect() async {
|
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');
|
_logger.log('🔄 checkAndReconnect called - state: $_connectionState');
|
||||||
|
|
||||||
if (_serverUrl == null) {
|
if (_serverUrl == null) {
|
||||||
@@ -2038,82 +1953,60 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
return;
|
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 {
|
// Sort cached players immediately so list appears in correct order
|
||||||
// IMMEDIATELY load cached players for instant UI display
|
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||||
// This makes mini player and device button appear instantly on app resume
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) {
|
_sortPlayersSync(_availablePlayers, smartSort, builtinPlayerId);
|
||||||
_availablePlayers = _cacheService.getCachedPlayers()!;
|
|
||||||
|
|
||||||
// Sort cached players immediately so list appears in correct order
|
_selectedPlayer = _cacheService.getCachedSelectedPlayer();
|
||||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
// Also try to restore from settings if cache doesn't have selected player
|
||||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
if (_selectedPlayer == null && _availablePlayers.isNotEmpty) {
|
||||||
_sortPlayersSync(_availablePlayers, smartSort, builtinPlayerId);
|
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
||||||
|
if (lastSelectedPlayerId != null) {
|
||||||
_selectedPlayer = _cacheService.getCachedSelectedPlayer();
|
try {
|
||||||
// Also try to restore from settings if cache doesn't have selected player
|
_selectedPlayer = _availablePlayers.firstWhere(
|
||||||
if (_selectedPlayer == null && _availablePlayers.isNotEmpty) {
|
(p) => p.playerId == lastSelectedPlayerId,
|
||||||
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
|
||||||
if (lastSelectedPlayerId != null) {
|
|
||||||
_selectedPlayer = _availablePlayers.cast<Player?>().firstWhere(
|
|
||||||
(p) => p!.playerId == lastSelectedPlayerId,
|
|
||||||
orElse: () => null,
|
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_selectedPlayer = _availablePlayers.first;
|
||||||
}
|
}
|
||||||
// Fallback to local/builtin player
|
} else {
|
||||||
if (_selectedPlayer == null) {
|
_selectedPlayer = _availablePlayers.first;
|
||||||
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 &&
|
if (_connectionState != MAConnectionState.connected &&
|
||||||
_connectionState != MAConnectionState.authenticated) {
|
_connectionState != MAConnectionState.authenticated) {
|
||||||
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
|
_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 {
|
try {
|
||||||
await connectToServer(_serverUrl!);
|
await connectToServer(_serverUrl!);
|
||||||
_logger.log('🔄 Reconnection successful');
|
} catch (reconnectError) {
|
||||||
} catch (e) {
|
_logger.log('🔄 Reconnection failed: $reconnectError');
|
||||||
_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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2136,43 +2029,16 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
};
|
};
|
||||||
audioHandler.onPlay = () {
|
audioHandler.onPlay = () {
|
||||||
_logger.log('🎵 Notification: Play pressed');
|
_logger.log('🎵 Notification: Play pressed');
|
||||||
if (_selectedPlayer != null) {
|
playPauseSelectedPlayer();
|
||||||
resumePlayer(_selectedPlayer!.playerId);
|
|
||||||
unawaited(refreshPlayers());
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
audioHandler.onPause = () {
|
audioHandler.onPause = () {
|
||||||
_logger.log('🎵 Notification: Pause pressed');
|
_logger.log('🎵 Notification: Pause pressed');
|
||||||
if (_selectedPlayer != null) {
|
playPauseSelectedPlayer();
|
||||||
pausePlayer(_selectedPlayer!.playerId);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
audioHandler.onSwitchPlayer = () {
|
audioHandler.onSwitchPlayer = () {
|
||||||
_logger.log('🎵 Notification: Switch player pressed');
|
_logger.log('🎵 Notification: Switch player pressed');
|
||||||
selectNextPlayer();
|
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()
|
// Player registration is now handled in _initializeAfterConnection()
|
||||||
// which runs after authentication completes (when auth is required)
|
// which runs after authentication completes (when auth is required)
|
||||||
@@ -2600,11 +2466,6 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
/// 2. Start the foreground service to prevent background throttling
|
/// 2. Start the foreground service to prevent background throttling
|
||||||
/// 3. Reset position for new track and start position timer
|
/// 3. Reset position for new track and start position timer
|
||||||
void _handleSendspinStreamStart(Map<String, dynamic>? trackInfo) async {
|
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');
|
_logger.log('🎵 Sendspin: Stream starting');
|
||||||
|
|
||||||
// Ensure PCM player is initialized and ready
|
// Ensure PCM player is initialized and ready
|
||||||
@@ -2647,46 +2508,25 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Keep the foreground service active to prevent Android from throttling
|
// Keep the foreground service active to prevent Android from throttling
|
||||||
// the PCM audio playback when the app goes to background.
|
// the PCM audio playback when the app goes to background.
|
||||||
// Use updateLocalModeNotification (NOT setRemotePlaybackState) to avoid
|
// We use setRemotePlaybackState to maintain the notification without
|
||||||
// conflicting mediaItem.add() calls — _updatePlayerState() also calls
|
// actually playing audio through just_audio.
|
||||||
// updateLocalModeNotification with the real track data.
|
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();
|
_cancelIdleServiceTimer();
|
||||||
if (_currentTrack != null) {
|
audioHandler.setRemotePlaybackState(
|
||||||
final track = _currentTrack!;
|
item: mediaItem,
|
||||||
final trackArtworkUrl = _api?.getImageUrl(track, size: 512);
|
playing: true,
|
||||||
final displayArtist = _trackDisplayArtist(track);
|
position: Duration.zero,
|
||||||
final artistWithPlayer = '${displayArtist} • ${_selectedPlayer?.name ?? ''}';
|
duration: mediaItem.duration,
|
||||||
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
|
// Start notification position timer for Sendspin PCM
|
||||||
_manageNotificationPositionTimer();
|
_manageNotificationPositionTimer();
|
||||||
@@ -2697,8 +2537,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
/// Handle Sendspin stream end - server stopped sending PCM audio data
|
/// Handle Sendspin stream end - server stopped sending PCM audio data
|
||||||
/// This is called when audio streaming ends (pause, stop, track end, etc.)
|
/// This is called when audio streaming ends (pause, stop, track end, etc.)
|
||||||
void _handleSendspinStreamEnd() async {
|
void _handleSendspinStreamEnd() async {
|
||||||
// Use position tracker (server-synced) instead of PCM elapsed time which resets to 0 each stream
|
// Capture current position before stopping
|
||||||
final lastPosition = _positionTracker.currentPosition;
|
final lastPosition = _pcmAudioPlayer?.elapsedTime ?? Duration.zero;
|
||||||
_logger.log('🎵 Sendspin: Stream ended at position ${lastPosition.inSeconds}s');
|
_logger.log('🎵 Sendspin: Stream ended at position ${lastPosition.inSeconds}s');
|
||||||
|
|
||||||
// Stop notification position timer
|
// Stop notification position timer
|
||||||
@@ -2707,46 +2547,27 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// Pause PCM playback (preserves position) instead of stop (resets position)
|
// Pause PCM playback (preserves position) instead of stop (resets position)
|
||||||
await _pcmAudioPlayer?.pause();
|
await _pcmAudioPlayer?.pause();
|
||||||
|
|
||||||
// Update foreground service to show paused state with last position.
|
// Update foreground service to show paused/stopped state with last position
|
||||||
// Use updateLocalModeNotification (NOT setRemotePlaybackState) to avoid
|
// Don't completely clear it - keep showing the notification
|
||||||
// conflicting mediaItem.add() calls with _updatePlayerState().
|
// 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();
|
_cancelIdleServiceTimer();
|
||||||
if (_currentTrack != null) {
|
audioHandler.setRemotePlaybackState(
|
||||||
final track = _currentTrack!;
|
item: mediaItem,
|
||||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
playing: false,
|
||||||
final displayArtist = _trackDisplayArtist(track);
|
position: lastPosition,
|
||||||
final artistWithPlayer = '${displayArtist} • ${_selectedPlayer?.name ?? ''}';
|
duration: mediaItem.duration,
|
||||||
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() {
|
void _startReportingLocalPlayerState() {
|
||||||
@@ -2950,7 +2771,6 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
final mediaType = event['media_type'] as String?;
|
final mediaType = event['media_type'] as String?;
|
||||||
_cacheService.invalidateSearchCache();
|
_cacheService.invalidateSearchCache();
|
||||||
_scheduleLibraryRefresh(mediaType ?? 'artist');
|
_scheduleLibraryRefresh(mediaType ?? 'artist');
|
||||||
audioHandler.invalidateAutoChildren(const ['cat|favorites', 'cat|home', 'cat|fav_tracks', 'cat|fav_albums', 'cat|fav_artists']);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Error handling media item added event: $e');
|
_logger.log('Error handling media item added event: $e');
|
||||||
}
|
}
|
||||||
@@ -2963,7 +2783,6 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
final mediaType = event['media_type'] as String?;
|
final mediaType = event['media_type'] as String?;
|
||||||
_cacheService.invalidateSearchCache();
|
_cacheService.invalidateSearchCache();
|
||||||
_scheduleLibraryRefresh(mediaType ?? 'artist');
|
_scheduleLibraryRefresh(mediaType ?? 'artist');
|
||||||
audioHandler.invalidateAutoChildren(const ['cat|favorites', 'cat|home', 'cat|fav_tracks', 'cat|fav_albums', 'cat|fav_artists']);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Error handling media item deleted event: $e');
|
_logger.log('Error handling media item deleted event: $e');
|
||||||
}
|
}
|
||||||
@@ -4002,17 +3821,6 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
if (_api == null) return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
|
if (_api == null) return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
|
||||||
|
|
||||||
try {
|
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"...');
|
_logger.log('🔄 Fetching albums for artist "$artistName"...');
|
||||||
|
|
||||||
// Use already-loaded library albums instead of re-fetching from API
|
// Use already-loaded library albums instead of re-fetching from API
|
||||||
@@ -4041,11 +3849,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only cache if the library is loaded — empty results during cold start
|
_cacheService.setCachedArtistAlbums(cacheKey, allAlbums);
|
||||||
// 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"');
|
_logger.log('✅ Cached ${allAlbums.length} albums for artist "$artistName"');
|
||||||
return allAlbums;
|
return allAlbums;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -4187,8 +3991,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// Smart sort: local player first, then playing, then on, then off
|
// Smart sort: local player first, then playing, then on, then off
|
||||||
players.sort((a, b) {
|
players.sort((a, b) {
|
||||||
// Local player always first
|
// Local player always first
|
||||||
final aIsLocal = builtinPlayerId != null && _isBuiltinPlayer(a.playerId, builtinPlayerId);
|
final aIsLocal = builtinPlayerId != null && a.playerId == builtinPlayerId;
|
||||||
final bIsLocal = builtinPlayerId != null && _isBuiltinPlayer(b.playerId, builtinPlayerId);
|
final bIsLocal = builtinPlayerId != null && b.playerId == builtinPlayerId;
|
||||||
if (aIsLocal && !bIsLocal) return -1;
|
if (aIsLocal && !bIsLocal) return -1;
|
||||||
if (bIsLocal && !aIsLocal) return 1;
|
if (bIsLocal && !aIsLocal) return 1;
|
||||||
|
|
||||||
@@ -4265,8 +4069,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_isEnsemblePlayer(player.playerId)) {
|
if (player.playerId.startsWith('ensemble_')) {
|
||||||
if (builtinPlayerId == null || !_isBuiltinPlayer(player.playerId, builtinPlayerId)) {
|
if (builtinPlayerId == null || player.playerId != builtinPlayerId) {
|
||||||
_logger.log('🚫 Filtering out other device\'s player: ${player.name}');
|
_logger.log('🚫 Filtering out other device\'s player: ${player.name}');
|
||||||
filteredCount++;
|
filteredCount++;
|
||||||
return false;
|
return false;
|
||||||
@@ -4274,7 +4078,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!player.available) {
|
if (!player.available) {
|
||||||
if (builtinPlayerId != null && _isBuiltinPlayer(player.playerId, builtinPlayerId)) {
|
if (builtinPlayerId != null && player.playerId == builtinPlayerId) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
filteredCount++;
|
filteredCount++;
|
||||||
@@ -4495,31 +4299,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: Local player (default fallback)
|
// Priority 2: Local player (default fallback)
|
||||||
// Try available first, then accept unavailable (Sendspin may still be registering)
|
|
||||||
if (playerToSelect == null && builtinPlayerId != null) {
|
if (playerToSelect == null && builtinPlayerId != null) {
|
||||||
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
try {
|
||||||
(p) => _isBuiltinPlayer(p!.playerId, builtinPlayerId) && p.available,
|
playerToSelect = _availablePlayers.firstWhere(
|
||||||
orElse: () => null,
|
(p) => p.playerId == builtinPlayerId && p.available,
|
||||||
);
|
|
||||||
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) => _isBuiltinPlayer(p!.playerId, builtinPlayerId),
|
|
||||||
orElse: () => null,
|
|
||||||
);
|
);
|
||||||
if (playerToSelect != null) {
|
_logger.log('📱 Selected local player: ${playerToSelect.name}');
|
||||||
_logger.log('📱 Selected local player (pending availability): ${playerToSelect.name}');
|
} catch (e) {
|
||||||
} else {
|
_logger.log('⚠️ Local player not available');
|
||||||
_logger.log('⚠️ Local player not in player list yet — waiting for Sendspin registration');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 3: First available player (only if no local player configured)
|
// Priority 3: First available player
|
||||||
// Don't fall back to another player if builtin player is configured but not yet registered
|
if (playerToSelect == null) {
|
||||||
if (playerToSelect == null && builtinPlayerId == null) {
|
|
||||||
playerToSelect = _availablePlayers.firstWhere(
|
playerToSelect = _availablePlayers.firstWhere(
|
||||||
(p) => p.available,
|
(p) => p.available,
|
||||||
orElse: () => _availablePlayers.first,
|
orElse: () => _availablePlayers.first,
|
||||||
@@ -4527,13 +4319,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
|
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playerToSelect != null) {
|
selectPlayer(playerToSelect);
|
||||||
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
|
// Preload track data and images in background for swipe gestures
|
||||||
@@ -4584,22 +4370,23 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Switch audio handler mode based on player type
|
// Switch audio handler mode based on player type
|
||||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
final isBuiltinPlayer = builtinPlayerId != null && _isBuiltinPlayer(player.playerId, builtinPlayerId);
|
final isBuiltinPlayer = builtinPlayerId != null && player.playerId == builtinPlayerId;
|
||||||
if (isBuiltinPlayer) {
|
if (isBuiltinPlayer) {
|
||||||
audioHandler.setLocalMode();
|
audioHandler.setLocalMode();
|
||||||
// Update notification for builtin player using local mode method (keeps pause working)
|
// Update notification for builtin player using local mode method (keeps pause working)
|
||||||
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
|
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
|
||||||
final track = _currentTrack!;
|
final track = _currentTrack!;
|
||||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
||||||
final displayArtist = _trackDisplayArtist(track);
|
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||||
final artistWithPlayer = '${displayArtist} • ${player.name}';
|
? '${track.artistsString} • ${player.name}'
|
||||||
|
: player.name;
|
||||||
final mediaItem = audio_service.MediaItem(
|
final mediaItem = audio_service.MediaItem(
|
||||||
id: track.uri ?? track.itemId,
|
id: track.uri ?? track.itemId,
|
||||||
title: track.name,
|
title: track.name,
|
||||||
artist: artistWithPlayer,
|
artist: artistWithPlayer,
|
||||||
album: track.album?.name ?? '',
|
album: track.album?.name ?? '',
|
||||||
duration: track.duration,
|
duration: track.duration,
|
||||||
artUri: _contentArtUri(artworkUrl),
|
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
|
||||||
);
|
);
|
||||||
// Position comes from actual player in updateLocalModeNotification
|
// Position comes from actual player in updateLocalModeNotification
|
||||||
audioHandler.updateLocalModeNotification(
|
audioHandler.updateLocalModeNotification(
|
||||||
@@ -4618,29 +4405,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
item: mediaItem,
|
item: mediaItem,
|
||||||
playing: player.state == 'playing',
|
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 {
|
} else {
|
||||||
// Builtin player is idle with no track - clear notification
|
// Builtin player is idle - clear notification (fixes issue #42)
|
||||||
audioHandler.clearRemotePlaybackState();
|
audioHandler.clearRemotePlaybackState();
|
||||||
_startIdleServiceTimer();
|
_startIdleServiceTimer();
|
||||||
}
|
}
|
||||||
@@ -4651,15 +4417,16 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
final track = _currentTrack!;
|
final track = _currentTrack!;
|
||||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
||||||
// Include player name in artist line: "Artist • Player Name"
|
// Include player name in artist line: "Artist • Player Name"
|
||||||
final displayArtist = _trackDisplayArtist(track);
|
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||||
final artistWithPlayer = '${displayArtist} • ${player.name}';
|
? '${track.artistsString} • ${player.name}'
|
||||||
|
: player.name;
|
||||||
final mediaItem = audio_service.MediaItem(
|
final mediaItem = audio_service.MediaItem(
|
||||||
id: track.uri ?? track.itemId,
|
id: track.uri ?? track.itemId,
|
||||||
title: track.name,
|
title: track.name,
|
||||||
artist: artistWithPlayer,
|
artist: artistWithPlayer,
|
||||||
album: track.album?.name ?? '',
|
album: track.album?.name ?? '',
|
||||||
duration: track.duration,
|
duration: track.duration,
|
||||||
artUri: _contentArtUri(artworkUrl),
|
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
|
||||||
);
|
);
|
||||||
// Use position tracker for consistent position
|
// Use position tracker for consistent position
|
||||||
final position = _positionTracker.currentPosition;
|
final position = _positionTracker.currentPosition;
|
||||||
@@ -4789,6 +4556,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Sendspin PCM player - continue to start timer
|
// Sendspin PCM player - continue to start timer
|
||||||
|
_logger.debug('🔔 Starting notification timer for Sendspin PCM player');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only run timer if player is playing
|
// Only run timer if player is playing
|
||||||
@@ -4831,37 +4599,25 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
||||||
final displayArtist = _trackDisplayArtist(track);
|
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||||
final artistWithPlayer = '${displayArtist} • ${_selectedPlayer!.name}';
|
? '${track.artistsString} • ${_selectedPlayer!.name}'
|
||||||
|
: _selectedPlayer!.name;
|
||||||
final mediaItem = audio_service.MediaItem(
|
final mediaItem = audio_service.MediaItem(
|
||||||
id: track.uri ?? track.itemId,
|
id: track.uri ?? track.itemId,
|
||||||
title: track.name,
|
title: track.name,
|
||||||
artist: artistWithPlayer,
|
artist: artistWithPlayer,
|
||||||
album: track.album?.name ?? '',
|
album: track.album?.name ?? '',
|
||||||
duration: track.duration,
|
duration: track.duration,
|
||||||
artUri: _contentArtUri(artworkUrl),
|
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
_cancelIdleServiceTimer();
|
_cancelIdleServiceTimer();
|
||||||
|
audioHandler.setRemotePlaybackState(
|
||||||
// Use updateLocalModeNotification for builtin players to avoid
|
item: mediaItem,
|
||||||
// conflicting mediaItem.add() calls with _updatePlayerState()
|
playing: isPlaying,
|
||||||
final isBuiltinPlayer = _sendspinConnected && _pcmAudioPlayer != null;
|
position: position,
|
||||||
if (isBuiltinPlayer) {
|
duration: track.duration,
|
||||||
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 {
|
Future<void> _preloadAdjacentPlayers({bool preloadAll = false}) async {
|
||||||
@@ -5232,7 +4988,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
final isPlayingOrPaused = _selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused';
|
final isPlayingOrPaused = _selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused';
|
||||||
final isIdleWithContent = _selectedPlayer!.state == 'idle' && _selectedPlayer!.powered;
|
final isIdleWithContent = _selectedPlayer!.state == 'idle' && _selectedPlayer!.powered;
|
||||||
final shouldShowTrack = _selectedPlayer!.available && (isPlayingOrPaused || isIdleWithContent || isPcmPlaying);
|
final shouldShowTrack = _selectedPlayer!.available && (isPlayingOrPaused || isIdleWithContent);
|
||||||
|
|
||||||
if (!shouldShowTrack) {
|
if (!shouldShowTrack) {
|
||||||
if (_currentTrack != null) {
|
if (_currentTrack != null) {
|
||||||
@@ -5276,10 +5032,6 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (queue != null && queue.currentItem != null) {
|
if (queue != null && queue.currentItem != null) {
|
||||||
if (queue.currentIndex != null) {
|
|
||||||
audioHandler.updateQueueIndex(queue.currentIndex!);
|
|
||||||
}
|
|
||||||
|
|
||||||
final queueTrack = queue.currentItem!.track;
|
final queueTrack = queue.currentItem!.track;
|
||||||
final trackChanged = _currentTrack == null ||
|
final trackChanged = _currentTrack == null ||
|
||||||
_currentTrack!.uri != queueTrack.uri ||
|
_currentTrack!.uri != queueTrack.uri ||
|
||||||
@@ -5371,19 +5123,20 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
final track = _currentTrack!;
|
final track = _currentTrack!;
|
||||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
||||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
final isBuiltinPlayer = builtinPlayerId != null && _isBuiltinPlayer(_selectedPlayer!.playerId, builtinPlayerId);
|
final isBuiltinPlayer = builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId;
|
||||||
|
|
||||||
if (isBuiltinPlayer) {
|
if (isBuiltinPlayer) {
|
||||||
// Local playback - use local mode notification (keeps pause working)
|
// Local playback - use local mode notification (keeps pause working)
|
||||||
final displayArtist = _trackDisplayArtist(track);
|
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||||
final artistWithPlayer = '$displayArtist • ${_selectedPlayer!.name}';
|
? '${track.artistsString} • ${_selectedPlayer!.name}'
|
||||||
|
: _selectedPlayer!.name;
|
||||||
final mediaItem = audio_service.MediaItem(
|
final mediaItem = audio_service.MediaItem(
|
||||||
id: track.uri ?? track.itemId,
|
id: track.uri ?? track.itemId,
|
||||||
title: track.name,
|
title: track.name,
|
||||||
artist: artistWithPlayer,
|
artist: artistWithPlayer,
|
||||||
album: track.album?.name ?? '',
|
album: track.album?.name ?? '',
|
||||||
duration: track.duration,
|
duration: track.duration,
|
||||||
artUri: _contentArtUri(artworkUrl),
|
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
|
||||||
);
|
);
|
||||||
// Position comes from actual player in updateLocalModeNotification
|
// Position comes from actual player in updateLocalModeNotification
|
||||||
audioHandler.updateLocalModeNotification(
|
audioHandler.updateLocalModeNotification(
|
||||||
@@ -5394,15 +5147,16 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
// Remote MA player - show notification via remote mode
|
// Remote MA player - show notification via remote mode
|
||||||
// Include player name in artist line: "Artist • Player Name"
|
// Include player name in artist line: "Artist • Player Name"
|
||||||
final displayArtist = _trackDisplayArtist(track);
|
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||||
final artistWithPlayer = '$displayArtist • ${_selectedPlayer!.name}';
|
? '${track.artistsString} • ${_selectedPlayer!.name}'
|
||||||
|
: _selectedPlayer!.name;
|
||||||
final mediaItem = audio_service.MediaItem(
|
final mediaItem = audio_service.MediaItem(
|
||||||
id: track.uri ?? track.itemId,
|
id: track.uri ?? track.itemId,
|
||||||
title: track.name,
|
title: track.name,
|
||||||
artist: artistWithPlayer,
|
artist: artistWithPlayer,
|
||||||
album: track.album?.name ?? '',
|
album: track.album?.name ?? '',
|
||||||
duration: track.duration,
|
duration: track.duration,
|
||||||
artUri: _contentArtUri(artworkUrl),
|
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
|
||||||
);
|
);
|
||||||
// Use position tracker for consistent position (single source of truth)
|
// Use position tracker for consistent position (single source of truth)
|
||||||
final position = _positionTracker.currentPosition;
|
final position = _positionTracker.currentPosition;
|
||||||
@@ -5417,7 +5171,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
// No queue data available from API
|
// No queue data available from API
|
||||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
final isBuiltinPlayer = builtinPlayerId != null && _isBuiltinPlayer(_selectedPlayer!.playerId, builtinPlayerId);
|
final isBuiltinPlayer = builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId;
|
||||||
|
|
||||||
// For builtin player, try to preserve cached track data since queue API doesn't return it
|
// 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
|
// The cached track was set in selectPlayer() and contains valid data
|
||||||
@@ -5468,9 +5222,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
final mediaItem = audio_service.MediaItem(
|
final mediaItem = audio_service.MediaItem(
|
||||||
id: track.uri ?? track.itemId,
|
id: track.uri ?? track.itemId,
|
||||||
title: track.name,
|
title: track.name,
|
||||||
artist: _trackDisplayArtist(track),
|
artist: track.artistsString,
|
||||||
album: track.album?.name,
|
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,
|
duration: track.duration,
|
||||||
);
|
);
|
||||||
audioHandler.updateLocalModeNotification(
|
audioHandler.updateLocalModeNotification(
|
||||||
@@ -5505,23 +5259,15 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// For pause: Don't refresh - we already did optimistic UI update
|
// For pause: Don't refresh - we already did optimistic UI update
|
||||||
// The player_updated event from MA will handle final state sync
|
// The player_updated event from MA will handle final state sync
|
||||||
} else {
|
} else {
|
||||||
// Always try resume first — even if player reports "idle", the server
|
// If player is idle with no active queue but has a last-known track, re-queue it
|
||||||
// queue may still be intact (e.g., Sendspin sends "stopped" on pause).
|
final isIdle = _selectedPlayer!.state == 'idle';
|
||||||
// Only fall back to re-queuing if resume fails.
|
final lastKnown = _cacheService.getLastKnownTrack(_selectedPlayer!.playerId);
|
||||||
final playerId = _selectedPlayer!.playerId;
|
if (isIdle && lastKnown != null) {
|
||||||
await resumePlayer(playerId);
|
_logger.log('🔄 Player idle with last-known track - re-queuing "${lastKnown.name}"');
|
||||||
|
await playTrack(_selectedPlayer!.playerId, lastKnown);
|
||||||
// Sendspin PCM streaming has ~5s buffering delay on resume.
|
} else {
|
||||||
// For audiobooks, seek back to compensate so the user doesn't miss narration.
|
await resumePlayer(_selectedPlayer!.playerId);
|
||||||
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
|
// For resume: Refresh in background to get updated track info
|
||||||
unawaited(refreshPlayers());
|
unawaited(refreshPlayers());
|
||||||
}
|
}
|
||||||
@@ -5629,9 +5375,6 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_syncLibraryStatusToService();
|
_syncLibraryStatusToService();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_prefetchAlbumImages();
|
_prefetchAlbumImages();
|
||||||
audioHandler.invalidateAutoChildren(const [
|
|
||||||
'cat|artists', 'cat|albums', 'cat|playlists', 'cat|home',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
syncService.removeListener(onSyncComplete);
|
syncService.removeListener(onSyncComplete);
|
||||||
}
|
}
|
||||||
@@ -5987,6 +5730,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
final itemJsonList = queue.items.map((item) => jsonEncode(item.toJson())).toList();
|
final itemJsonList = queue.items.map((item) => jsonEncode(item.toJson())).toList();
|
||||||
await DatabaseService.instance.saveQueue(playerId, itemJsonList);
|
await DatabaseService.instance.saveQueue(playerId, itemJsonList);
|
||||||
|
_logger.debug('💾 Persisted ${queue.items.length} queue items to database');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('⚠️ Error persisting queue to database: $e');
|
_logger.log('⚠️ Error persisting queue to database: $e');
|
||||||
}
|
}
|
||||||
@@ -6051,16 +5795,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
await _api?.playRadio(playerId, track);
|
await _api?.playRadio(playerId, track);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Radio failed, falling back to single track: $e');
|
final errorInfo = ErrorHandler.handleError(e, context: 'Play radio');
|
||||||
try {
|
_error = errorInfo.userMessage;
|
||||||
await playTrack(playerId, track);
|
ErrorHandler.logError('Play radio', e);
|
||||||
} catch (fallbackError) {
|
notifyListeners();
|
||||||
final errorInfo = ErrorHandler.handleError(fallbackError, context: 'Play radio fallback');
|
rethrow;
|
||||||
_error = errorInfo.userMessage;
|
|
||||||
ErrorHandler.logError('Play radio fallback', fallbackError);
|
|
||||||
notifyListeners();
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6068,21 +5807,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
await _api?.playArtistRadio(playerId, artist);
|
await _api?.playArtistRadio(playerId, artist);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Artist radio failed, falling back to shuffled tracks: $e');
|
final errorInfo = ErrorHandler.handleError(e, context: 'Play artist radio');
|
||||||
try {
|
_error = errorInfo.userMessage;
|
||||||
final results = await searchWithCache(artist.name);
|
ErrorHandler.logError('Play artist radio', e);
|
||||||
final tracks = (results['tracks'] ?? []).whereType<Track>().toList();
|
notifyListeners();
|
||||||
if (tracks.isEmpty) rethrow;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6095,7 +5824,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// Get builtin player ID - this is cached so should be fast
|
// Get builtin player ID - this is cached so should be fast
|
||||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
|
|
||||||
if (builtinPlayerId != null && _isBuiltinPlayer(playerId, builtinPlayerId) && _sendspinConnected) {
|
if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) {
|
||||||
_logger.log('⏸️ Non-blocking local pause for builtin player');
|
_logger.log('⏸️ Non-blocking local pause for builtin player');
|
||||||
|
|
||||||
// CRITICAL: Don't await these - they can block the UI thread
|
// CRITICAL: Don't await these - they can block the UI thread
|
||||||
@@ -6197,7 +5926,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
// Optimistic local stop for builtin player on skip - non-blocking
|
// Optimistic local stop for builtin player on skip - non-blocking
|
||||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
if (builtinPlayerId != null && _isBuiltinPlayer(playerId, builtinPlayerId) && _sendspinConnected) {
|
if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) {
|
||||||
_logger.log('⏭️ Non-blocking local stop for skip on builtin player');
|
_logger.log('⏭️ Non-blocking local stop for skip on builtin player');
|
||||||
// Stop current audio immediately - fire and forget, but log errors
|
// Stop current audio immediately - fire and forget, but log errors
|
||||||
unawaited((_pcmAudioPlayer?.pause() ?? Future.value()).catchError(
|
unawaited((_pcmAudioPlayer?.pause() ?? Future.value()).catchError(
|
||||||
@@ -6216,7 +5945,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
// Optimistic local stop for builtin player on previous - non-blocking
|
// Optimistic local stop for builtin player on previous - non-blocking
|
||||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
if (builtinPlayerId != null && _isBuiltinPlayer(playerId, builtinPlayerId) && _sendspinConnected) {
|
if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) {
|
||||||
_logger.log('⏮️ Non-blocking local stop for previous on builtin player');
|
_logger.log('⏮️ Non-blocking local stop for previous on builtin player');
|
||||||
// Stop current audio immediately - fire and forget, but log errors
|
// Stop current audio immediately - fire and forget, but log errors
|
||||||
unawaited((_pcmAudioPlayer?.pause() ?? Future.value()).catchError(
|
unawaited((_pcmAudioPlayer?.pause() ?? Future.value()).catchError(
|
||||||
@@ -6630,7 +6359,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
if (builtinPlayerId != null && _isBuiltinPlayer(playerId, builtinPlayerId)) {
|
if (builtinPlayerId != null && playerId == builtinPlayerId) {
|
||||||
_localPlayerVolume = volumeLevel;
|
_localPlayerVolume = volumeLevel;
|
||||||
FlutterVolumeController.setVolume(volumeLevel / 100.0);
|
FlutterVolumeController.setVolume(volumeLevel / 100.0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -430,28 +430,29 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Bottom fade gradient to mask content behind mini player (4+ rows only)
|
// Bottom fade gradient to mask content behind mini player
|
||||||
if (_countEnabledRows() >= 4)
|
Positioned(
|
||||||
Positioned(
|
left: 0,
|
||||||
left: 0,
|
right: 0,
|
||||||
right: 0,
|
bottom: 0,
|
||||||
bottom: 0,
|
height: BottomSpacing.miniPlayerHeight + 22.0,
|
||||||
height: BottomSpacing.miniPlayerHeight,
|
child: IgnorePointer(
|
||||||
child: IgnorePointer(
|
child: DecoratedBox(
|
||||||
child: DecoratedBox(
|
decoration: BoxDecoration(
|
||||||
decoration: BoxDecoration(
|
gradient: LinearGradient(
|
||||||
gradient: LinearGradient(
|
begin: Alignment.topCenter,
|
||||||
begin: Alignment.topCenter,
|
end: Alignment.bottomCenter,
|
||||||
end: Alignment.bottomCenter,
|
colors: [
|
||||||
colors: [
|
colorScheme.background.withOpacity(0.0),
|
||||||
colorScheme.background.withOpacity(0.0),
|
colorScheme.background.withOpacity(0.25),
|
||||||
colorScheme.background.withOpacity(0.85),
|
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) {
|
builder: (context, constraints) {
|
||||||
// Each row is always 1/3 of screen height
|
// Each row is always 1/3 of screen height
|
||||||
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll
|
// 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.
|
// With extendBody: true, constraints include the area behind the nav bar.
|
||||||
final miniPlayerSpace = BottomSpacing.miniPlayerHeight + 22.0;
|
// 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;
|
final availableHeight = constraints.maxHeight - miniPlayerSpace;
|
||||||
|
|
||||||
// Account for margins between rows (2px each, 2 margins for 3 rows).
|
// 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)
|
// PERF: Separate ValueNotifier for efficient UI updates (RestorableInt doesn't implement ValueListenable)
|
||||||
final ValueNotifier<int> _tabIndexNotifier = ValueNotifier<int>(0);
|
final ValueNotifier<int> _tabIndexNotifier = ValueNotifier<int>(0);
|
||||||
|
|
||||||
// Scroll-to-hide filter bars (0.0 = hidden, 1.0 = visible)
|
// Scroll-to-hide filter bars
|
||||||
final ValueNotifier<double> _filterBarVisibility = ValueNotifier<double>(1.0);
|
bool _isFilterBarVisible = true;
|
||||||
|
double _lastScrollOffset = 0;
|
||||||
|
static const double _scrollThreshold = 10.0;
|
||||||
bool _isLetterScrollbarDragging = false; // Disable scroll-to-hide while dragging
|
bool _isLetterScrollbarDragging = false; // Disable scroll-to-hide while dragging
|
||||||
|
|
||||||
// Options menu overlay - stored to close on navigation
|
// Options menu overlay - stored to close on navigation
|
||||||
@@ -857,11 +859,17 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (notification is ScrollUpdateNotification) {
|
if (notification is ScrollUpdateNotification) {
|
||||||
final delta = notification.scrollDelta ?? 0.0;
|
final currentOffset = notification.metrics.pixels;
|
||||||
if (notification.metrics.pixels <= 0) {
|
final delta = currentOffset - _lastScrollOffset;
|
||||||
_filterBarVisibility.value = 1.0;
|
|
||||||
} else {
|
if (delta.abs() > _scrollThreshold) {
|
||||||
_filterBarVisibility.value = (_filterBarVisibility.value - delta / _filterRowHeight).clamp(0.0, 1.0);
|
final shouldShow = delta < 0 || currentOffset <= 0;
|
||||||
|
if (shouldShow != _isFilterBarVisible) {
|
||||||
|
setState(() {
|
||||||
|
_isFilterBarVisible = shouldShow;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_lastScrollOffset = currentOffset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -903,11 +911,13 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onLetterScrollbarDragChanged(bool isDragging) {
|
void _onLetterScrollbarDragChanged(bool isDragging) {
|
||||||
_isLetterScrollbarDragging = isDragging;
|
setState(() {
|
||||||
// Show the filter bar when starting to drag
|
_isLetterScrollbarDragging = isDragging;
|
||||||
if (isDragging) {
|
// Show the filter bar when starting to drag
|
||||||
_filterBarVisibility.value = 1.0;
|
if (isDragging) {
|
||||||
}
|
_isFilterBarVisible = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _animateToCategory(int index) {
|
void _animateToCategory(int index) {
|
||||||
@@ -932,7 +942,6 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
_pageController.dispose();
|
_pageController.dispose();
|
||||||
_selectedTabIndex.dispose();
|
_selectedTabIndex.dispose();
|
||||||
_tabIndexNotifier.dispose();
|
_tabIndexNotifier.dispose();
|
||||||
_filterBarVisibility.dispose();
|
|
||||||
_artistsScrollController.dispose();
|
_artistsScrollController.dispose();
|
||||||
_albumsScrollController.dispose();
|
_albumsScrollController.dispose();
|
||||||
_playlistsScrollController.dispose();
|
_playlistsScrollController.dispose();
|
||||||
@@ -1440,12 +1449,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Two-row filter: Row 1 = Media types (hides on scroll), Row 2 = Sub-categories (always visible)
|
// Two-row filter: Row 1 = Media types (hides on scroll), Row 2 = Sub-categories (always visible)
|
||||||
ValueListenableBuilder<double>(
|
_buildFilterRows(colorScheme, l10n, showLibraryTypeRow: _isFilterBarVisible),
|
||||||
valueListenable: _filterBarVisibility,
|
|
||||||
builder: (context, visibility, _) {
|
|
||||||
return _buildFilterRows(colorScheme, l10n, barVisibility: visibility);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
// Connecting banner when showing cached data
|
// Connecting banner when showing cached data
|
||||||
// Hide when we have cached players - UI is functional during background reconnect
|
// Hide when we have cached players - UI is functional during background reconnect
|
||||||
if (!isConnected && syncService.hasCache && !context.read<MusicAssistantProvider>().hasCachedPlayers)
|
if (!isConnected && syncService.hasCache && !context.read<MusicAssistantProvider>().hasCachedPlayers)
|
||||||
@@ -1542,25 +1546,23 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
// Consistent height for filter rows
|
// Consistent height for filter rows
|
||||||
static const double _filterRowHeight = 36.0;
|
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(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Row 1: Media type chips (hides when scrolling)
|
// Row 1: Media type chips (hides when scrolling)
|
||||||
ClipRect(
|
AnimatedContainer(
|
||||||
child: Align(
|
duration: const Duration(milliseconds: 200),
|
||||||
alignment: Alignment.bottomCenter,
|
curve: Curves.easeOutCubic,
|
||||||
heightFactor: barVisibility,
|
height: showLibraryTypeRow ? _filterRowHeight : 0,
|
||||||
child: SizedBox(
|
clipBehavior: Clip.hardEdge,
|
||||||
height: _filterRowHeight,
|
decoration: const BoxDecoration(),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: _buildMediaTypeChips(colorScheme, l10n),
|
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
|
// Row 2: Sub-category chips (left) + action buttons (right) - always visible
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: _filterRowHeight,
|
height: _filterRowHeight,
|
||||||
@@ -4912,7 +4914,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Start radio from this track
|
// Start radio from this track
|
||||||
await maProvider.playRadio(player.playerId, track);
|
await maProvider.api?.playRadio(player.playerId, track);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
@@ -130,8 +130,10 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
// Advanced search scorer with fuzzy matching, stopword removal, etc.
|
// Advanced search scorer with fuzzy matching, stopword removal, etc.
|
||||||
final SearchScorer _searchScorer = SearchScorer();
|
final SearchScorer _searchScorer = SearchScorer();
|
||||||
|
|
||||||
// Scroll-to-hide search bar (0.0 = hidden, 1.0 = visible)
|
// Scroll-to-hide search bar (vertical scroll only)
|
||||||
final ValueNotifier<double> _searchBarVisibility = ValueNotifier<double>(1.0);
|
bool _isSearchBarVisible = true;
|
||||||
|
double _lastVerticalScrollOffset = 0;
|
||||||
|
static const double _scrollThreshold = 10.0;
|
||||||
|
|
||||||
// Filter chip position tracking for animated sliding highlight
|
// Filter chip position tracking for animated sliding highlight
|
||||||
final Map<String, GlobalKey> _filterKeys = {};
|
final Map<String, GlobalKey> _filterKeys = {};
|
||||||
@@ -312,7 +314,10 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
|
|
||||||
void requestFocus() {
|
void requestFocus() {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_searchBarVisibility.value = 1.0;
|
setState(() {
|
||||||
|
_isSearchBarVisible = true;
|
||||||
|
_lastVerticalScrollOffset = 0;
|
||||||
|
});
|
||||||
_focusNode.requestFocus();
|
_focusNode.requestFocus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,7 +336,6 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
_pageController.dispose();
|
_pageController.dispose();
|
||||||
_filterScrollController.dispose();
|
_filterScrollController.dispose();
|
||||||
_activeFilterNotifier.dispose();
|
_activeFilterNotifier.dispose();
|
||||||
_searchBarVisibility.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,12 +347,17 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (notification is ScrollUpdateNotification) {
|
if (notification is ScrollUpdateNotification) {
|
||||||
final delta = notification.scrollDelta ?? 0.0;
|
final currentOffset = notification.metrics.pixels;
|
||||||
const maxHeight = kToolbarHeight + 16;
|
final delta = currentOffset - _lastVerticalScrollOffset;
|
||||||
if (notification.metrics.pixels <= 0) {
|
|
||||||
_searchBarVisibility.value = 1.0;
|
if (delta.abs() > _scrollThreshold) {
|
||||||
} else {
|
final shouldShow = delta < 0 || currentOffset <= 0;
|
||||||
_searchBarVisibility.value = (_searchBarVisibility.value - delta / maxHeight).clamp(0.0, 1.0);
|
if (shouldShow != _isSearchBarVisible) {
|
||||||
|
setState(() {
|
||||||
|
_isSearchBarVisible = shouldShow;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_lastVerticalScrollOffset = currentOffset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -525,23 +534,16 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Search bar - slides away with scroll
|
// Animated search bar - hides on scroll down
|
||||||
ValueListenableBuilder<double>(
|
AnimatedContainer(
|
||||||
valueListenable: _searchBarVisibility,
|
duration: const Duration(milliseconds: 200),
|
||||||
builder: (context, visibility, child) {
|
curve: Curves.easeOutCubic,
|
||||||
return ClipRect(
|
height: _isSearchBarVisible ? kToolbarHeight + 16 : 0,
|
||||||
child: Align(
|
clipBehavior: Clip.hardEdge,
|
||||||
alignment: Alignment.bottomCenter,
|
decoration: const BoxDecoration(),
|
||||||
heightFactor: visibility,
|
child: Padding(
|
||||||
child: child!,
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
),
|
child: Row(
|
||||||
);
|
|
||||||
},
|
|
||||||
child: SizedBox(
|
|
||||||
height: kToolbarHeight + 16,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
children: [
|
||||||
// Search field container
|
// Search field container
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -645,7 +647,6 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
// Main content
|
// Main content
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -68,14 +68,6 @@ class MusicAssistantAPI {
|
|||||||
|
|
||||||
MusicAssistantAPI(this.serverUrl, this.authManager);
|
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
|
// Guard to prevent multiple simultaneous connection attempts
|
||||||
Completer<void>? _connectionInProgress;
|
Completer<void>? _connectionInProgress;
|
||||||
bool _isDisposed = false;
|
bool _isDisposed = false;
|
||||||
@@ -153,7 +145,7 @@ class MusicAssistantAPI {
|
|||||||
queryParameters: {'client_id': clientId},
|
queryParameters: {'client_id': clientId},
|
||||||
);
|
);
|
||||||
_logger.log('Using custom WebSocket port from settings: $_cachedCustomPort');
|
_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
|
// Port is already specified in URL, keep it
|
||||||
finalUri = Uri(
|
finalUri = Uri(
|
||||||
scheme: uri.scheme,
|
scheme: uri.scheme,
|
||||||
@@ -259,6 +251,7 @@ class MusicAssistantAPI {
|
|||||||
void _handleMessage(dynamic message) {
|
void _handleMessage(dynamic message) {
|
||||||
try {
|
try {
|
||||||
final data = jsonDecode(message as String) as Map<String, dynamic>;
|
final data = jsonDecode(message as String) as Map<String, dynamic>;
|
||||||
|
_logger.debug('Received message: ${data.keys}');
|
||||||
|
|
||||||
// Check for server info message (first message on connect)
|
// Check for server info message (first message on connect)
|
||||||
if (data.containsKey('server_version')) {
|
if (data.containsKey('server_version')) {
|
||||||
@@ -1988,6 +1981,7 @@ class MusicAssistantAPI {
|
|||||||
currentIndex = queueResult['current_index'] as int?;
|
currentIndex = queueResult['current_index'] as int?;
|
||||||
shuffleEnabled = queueResult['shuffle_enabled'] as bool?;
|
shuffleEnabled = queueResult['shuffle_enabled'] as bool?;
|
||||||
repeatMode = queueResult['repeat_mode'] as String?;
|
repeatMode = queueResult['repeat_mode'] as String?;
|
||||||
|
_logger.debug('🔀 Queue metadata: shuffle_enabled=$shuffleEnabled, repeat_mode=$repeatMode');
|
||||||
|
|
||||||
final currentItemData = queueResult['current_item'] as Map<String, dynamic>?;
|
final currentItemData = queueResult['current_item'] as Map<String, dynamic>?;
|
||||||
final currentItemName = currentItemData?['name'] as String?;
|
final currentItemName = currentItemData?['name'] as String?;
|
||||||
@@ -2490,7 +2484,7 @@ class MusicAssistantAPI {
|
|||||||
port: _cachedCustomPort,
|
port: _cachedCustomPort,
|
||||||
path: '/flow/$playerId/$streamId.$extension',
|
path: '/flow/$playerId/$streamId.$extension',
|
||||||
);
|
);
|
||||||
} else if (_hasExplicitPort(serverUrl)) {
|
} else if (uri.hasPort) {
|
||||||
// Use port from URL
|
// Use port from URL
|
||||||
finalUri = Uri(
|
finalUri = Uri(
|
||||||
scheme: uri.scheme,
|
scheme: uri.scheme,
|
||||||
@@ -3202,7 +3196,7 @@ class MusicAssistantAPI {
|
|||||||
if (_cachedCustomPort != null) {
|
if (_cachedCustomPort != null) {
|
||||||
// Use cached custom port from settings
|
// Use cached custom port from settings
|
||||||
baseUrl = '${uriObj.scheme}://${uriObj.host}:$_cachedCustomPort';
|
baseUrl = '${uriObj.scheme}://${uriObj.host}:$_cachedCustomPort';
|
||||||
} else if (!_hasExplicitPort(serverUrl)) {
|
} else if (!uriObj.hasPort) {
|
||||||
if (useSecure) {
|
if (useSecure) {
|
||||||
// For HTTPS, don't add port 443 (it's the default)
|
// For HTTPS, don't add port 443 (it's the default)
|
||||||
baseUrl = '${uriObj.scheme}://${uriObj.host}';
|
baseUrl = '${uriObj.scheme}://${uriObj.host}';
|
||||||
@@ -3309,7 +3303,7 @@ class MusicAssistantAPI {
|
|||||||
if (_cachedCustomPort != null) {
|
if (_cachedCustomPort != null) {
|
||||||
// Use cached custom port from settings
|
// Use cached custom port from settings
|
||||||
baseUrl = '${uri.scheme}://${uri.host}:$_cachedCustomPort';
|
baseUrl = '${uri.scheme}://${uri.host}:$_cachedCustomPort';
|
||||||
} else if (!_hasExplicitPort(serverUrl)) {
|
} else if (!uri.hasPort) {
|
||||||
if (useSecure) {
|
if (useSecure) {
|
||||||
// For HTTPS, don't add port 443 (it's the default)
|
// For HTTPS, don't add port 443 (it's the default)
|
||||||
baseUrl = '${uri.scheme}://${uri.host}';
|
baseUrl = '${uri.scheme}://${uri.host}';
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ class PcmAudioPlayer {
|
|||||||
bool _isFeeding = false;
|
bool _isFeeding = false;
|
||||||
bool _isStarted = false;
|
bool _isStarted = false;
|
||||||
bool _isAutoRecovering = false; // Tracks auto-recovery from setup errors
|
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
|
Completer<void>? _feedCompleter; // Tracks when current feed operation completes
|
||||||
|
|
||||||
// Error callback for operation failures
|
// Error callback for operation failures
|
||||||
@@ -236,9 +235,7 @@ class PcmAudioPlayer {
|
|||||||
if (_shouldBlockFeeding) {
|
if (_shouldBlockFeeding) {
|
||||||
// If we're paused and audio is arriving, this is likely a new stream
|
// If we're paused and audio is arriving, this is likely a new stream
|
||||||
// starting before the stream/start message. Queue for auto-recovery.
|
// starting before the stream/start message. Queue for auto-recovery.
|
||||||
// BUT: don't auto-recover if the user explicitly paused — the arriving
|
if (_state == PcmPlayerState.paused && !_isAutoRecovering) {
|
||||||
// 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');
|
_logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery');
|
||||||
_isAutoRecovering = true;
|
_isAutoRecovering = true;
|
||||||
_addToBuffer(audioData);
|
_addToBuffer(audioData);
|
||||||
@@ -495,7 +492,6 @@ class PcmAudioPlayer {
|
|||||||
|
|
||||||
if (_state == PcmPlayerState.paused) {
|
if (_state == PcmPlayerState.paused) {
|
||||||
// Resume from pause - need to re-initialize since we released on pause
|
// Resume from pause - need to re-initialize since we released on pause
|
||||||
_userPaused = false;
|
|
||||||
_logger.log('PcmAudioPlayer: Resuming from pause at ${elapsedTime.inSeconds}s');
|
_logger.log('PcmAudioPlayer: Resuming from pause at ${elapsedTime.inSeconds}s');
|
||||||
_state = PcmPlayerState.resuming;
|
_state = PcmPlayerState.resuming;
|
||||||
|
|
||||||
@@ -551,7 +547,6 @@ class PcmAudioPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('PcmAudioPlayer: Pause requested');
|
_logger.log('PcmAudioPlayer: Pause requested');
|
||||||
_userPaused = true;
|
|
||||||
|
|
||||||
// Set pausing state FIRST to stop feed loop from starting new feeds
|
// Set pausing state FIRST to stop feed loop from starting new feeds
|
||||||
_state = PcmPlayerState.pausing;
|
_state = PcmPlayerState.pausing;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'debug_logger.dart';
|
||||||
|
|
||||||
/// Single source of truth for playback position tracking.
|
/// 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]
|
/// All consumers (UI, notification) should listen to [positionStream]
|
||||||
/// instead of calculating position themselves.
|
/// instead of calculating position themselves.
|
||||||
class PositionTracker {
|
class PositionTracker {
|
||||||
|
final DebugLogger _logger = DebugLogger();
|
||||||
|
|
||||||
// Current state
|
// Current state
|
||||||
String? _playerId;
|
String? _playerId;
|
||||||
bool _isPlaying = false;
|
bool _isPlaying = false;
|
||||||
@@ -97,6 +100,7 @@ class PositionTracker {
|
|||||||
Duration? duration,
|
Duration? duration,
|
||||||
double? serverTimestamp,
|
double? serverTimestamp,
|
||||||
}) {
|
}) {
|
||||||
|
_logger.debug('⏱️ PositionTracker.updateFromServer: pos=${position.toStringAsFixed(1)}s, isPlaying=$isPlaying, playerId=$playerId');
|
||||||
final bool playerChanged = _playerId != playerId;
|
final bool playerChanged = _playerId != playerId;
|
||||||
final bool playStateChanged = _isPlaying != isPlaying;
|
final bool playStateChanged = _isPlaying != isPlaying;
|
||||||
final bool durationChanged = duration != null && _duration != duration;
|
final bool durationChanged = duration != null && _duration != duration;
|
||||||
@@ -175,8 +179,11 @@ class PositionTracker {
|
|||||||
final newAnchorPos = anchorIsGettingStale && !playerChanged && !playStateChanged && isPlaying
|
final newAnchorPos = anchorIsGettingStale && !playerChanged && !playStateChanged && isPlaying
|
||||||
? currentInterpolated // Keep our interpolated position, just refresh the anchor time
|
? currentInterpolated // Keep our interpolated position, just refresh the anchor time
|
||||||
: anchorPos; // Use server position for other cases
|
: anchorPos; // Use server position for other cases
|
||||||
|
_logger.debug('⏱️ PositionTracker: Anchor updated ${_anchorPosition.toStringAsFixed(1)}s -> ${newAnchorPos.toStringAsFixed(1)}s (playerChanged=$playerChanged, playStateChanged=$playStateChanged, positionDiff=${positionDiff.toStringAsFixed(1)}, suspiciousReset=$isSuspiciousReset, anchorStale=$anchorIsGettingStale)');
|
||||||
_anchorPosition = newAnchorPos;
|
_anchorPosition = newAnchorPos;
|
||||||
_anchorTime = DateTime.now();
|
_anchorTime = DateTime.now();
|
||||||
|
} else if (positionDiff > 2) {
|
||||||
|
_logger.debug('⏱️ PositionTracker: Anchor NOT updated (suspiciousReset=$isSuspiciousReset, staleTimestamp=$hasStaleTimestamp, interpolated=${currentInterpolated.toStringAsFixed(1)}s)');
|
||||||
}
|
}
|
||||||
|
|
||||||
_isPlaying = isPlaying;
|
_isPlaying = isPlaying;
|
||||||
@@ -220,6 +227,7 @@ class PositionTracker {
|
|||||||
|
|
||||||
/// Clear tracker state (e.g., when disconnecting)
|
/// Clear tracker state (e.g., when disconnecting)
|
||||||
void clear() {
|
void clear() {
|
||||||
|
_logger.debug('⏱️ PositionTracker.clear() called');
|
||||||
_stopInterpolationTimer();
|
_stopInterpolationTimer();
|
||||||
_playerId = null;
|
_playerId = null;
|
||||||
_isPlaying = false;
|
_isPlaying = false;
|
||||||
@@ -229,9 +237,20 @@ class PositionTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _startInterpolationTimer() {
|
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) {
|
_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();
|
_emitPosition();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -240,6 +259,7 @@ class PositionTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _stopInterpolationTimer() {
|
void _stopInterpolationTimer() {
|
||||||
|
_logger.debug('⏱️ PositionTracker: Stopping interpolation timer (was ${_interpolationTimer != null ? "running" : "null"})');
|
||||||
_interpolationTimer?.cancel();
|
_interpolationTimer?.cancel();
|
||||||
_interpolationTimer = null;
|
_interpolationTimer = null;
|
||||||
}
|
}
|
||||||
@@ -251,6 +271,7 @@ class PositionTracker {
|
|||||||
// Only emit if second changed (reduces unnecessary updates)
|
// Only emit if second changed (reduces unnecessary updates)
|
||||||
if (seconds != _lastEmittedSeconds) {
|
if (seconds != _lastEmittedSeconds) {
|
||||||
_lastEmittedSeconds = seconds;
|
_lastEmittedSeconds = seconds;
|
||||||
|
_logger.debug('⏱️ PositionTracker: Emitting position ${seconds}s (anchor=${_anchorPosition.toStringAsFixed(1)}s, playing=$_isPlaying)');
|
||||||
_positionController.add(pos);
|
_positionController.add(pos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,6 +321,8 @@ class SendspinService {
|
|||||||
final data = jsonDecode(message) as Map<String, dynamic>;
|
final data = jsonDecode(message) as Map<String, dynamic>;
|
||||||
final type = data['type'] as String?;
|
final type = data['type'] as String?;
|
||||||
|
|
||||||
|
_logger.log('Sendspin: Received message type: $type');
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'server/hello':
|
case 'server/hello':
|
||||||
// Server acknowledged our client/hello - we're registered!
|
// Server acknowledged our client/hello - we're registered!
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
ColorScheme? _lightColorScheme;
|
ColorScheme? _lightColorScheme;
|
||||||
ColorScheme? _darkColorScheme;
|
ColorScheme? _darkColorScheme;
|
||||||
String? _lastImageUrl;
|
String? _lastImageUrl;
|
||||||
int _colorExtractionId = 0;
|
|
||||||
|
|
||||||
// Queue state
|
// Queue state
|
||||||
PlayerQueue? _queue;
|
PlayerQueue? _queue;
|
||||||
@@ -758,17 +757,13 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
Future<void> _extractColors(String imageUrl) async {
|
Future<void> _extractColors(String imageUrl) async {
|
||||||
if (_lastImageUrl == imageUrl) return;
|
if (_lastImageUrl == imageUrl) return;
|
||||||
_lastImageUrl = imageUrl;
|
_lastImageUrl = imageUrl;
|
||||||
final extractionId = ++_colorExtractionId;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final colorSchemes = await PaletteHelper.extractColorSchemes(
|
final colorSchemes = await PaletteHelper.extractColorSchemes(
|
||||||
CachedNetworkImageProvider(imageUrl),
|
CachedNetworkImageProvider(imageUrl),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (colorSchemes != null && mounted) {
|
||||||
if (extractionId != _colorExtractionId) return;
|
|
||||||
|
|
||||||
if (colorSchemes != null) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_lightColorScheme = colorSchemes.$1;
|
_lightColorScheme = colorSchemes.$1;
|
||||||
_darkColorScheme = colorSchemes.$2;
|
_darkColorScheme = colorSchemes.$2;
|
||||||
@@ -2061,7 +2056,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
: colorScheme.primaryContainer;
|
: colorScheme.primaryContainer;
|
||||||
// Create a darker shade for the "unplayed" portion of progress bar
|
// Create a darker shade for the "unplayed" portion of progress bar
|
||||||
final collapsedBgUnplayed = Color.lerp(collapsedBg, Colors.black, 0.3)!;
|
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;
|
final expandedPrimary = adaptiveScheme?.primary;
|
||||||
// Always update to current value - don't preserve stale adaptive colors
|
// Always update to current value - don't preserve stale adaptive colors
|
||||||
_currentExpandedBgColor = expandedBg;
|
_currentExpandedBgColor = expandedBg;
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ class _MediaContextMenuOverlayState extends State<_MediaContextMenuOverlay>
|
|||||||
switch (widget.mediaType) {
|
switch (widget.mediaType) {
|
||||||
case ContextMenuMediaType.artist:
|
case ContextMenuMediaType.artist:
|
||||||
final artist = widget.item as Artist;
|
final artist = widget.item as Artist;
|
||||||
await maProvider.playArtistRadio(player.playerId, artist);
|
await maProvider.api?.playArtistRadio(player.playerId, artist);
|
||||||
break;
|
break;
|
||||||
case ContextMenuMediaType.track:
|
case ContextMenuMediaType.track:
|
||||||
final track = widget.item as Track;
|
final track = widget.item as Track;
|
||||||
@@ -437,7 +437,7 @@ class _MediaContextMenuOverlayState extends State<_MediaContextMenuOverlay>
|
|||||||
switch (widget.mediaType) {
|
switch (widget.mediaType) {
|
||||||
case ContextMenuMediaType.artist:
|
case ContextMenuMediaType.artist:
|
||||||
final artist = widget.item as Artist;
|
final artist = widget.item as Artist;
|
||||||
await maProvider.playArtistRadio(player.playerId, artist);
|
await maProvider.api?.playArtistRadio(player.playerId, artist);
|
||||||
break;
|
break;
|
||||||
case ContextMenuMediaType.track:
|
case ContextMenuMediaType.track:
|
||||||
final track = widget.item as Track;
|
final track = widget.item as Track;
|
||||||
|
|||||||
@@ -238,7 +238,6 @@ class _VolumeControlState extends State<VolumeControl> {
|
|||||||
children: [
|
children: [
|
||||||
// The slider with gesture detection for precision mode
|
// The slider with gesture detection for precision mode
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onHorizontalDragStart: (details) {
|
onHorizontalDragStart: (details) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDragging = true;
|
_isDragging = true;
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: ensemble
|
name: ensemble
|
||||||
description: A minimalistic Music Assistant client built with Flutter
|
description: A minimalistic Music Assistant client built with Flutter
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 3.0.4+56
|
version: 3.0.5+57
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|||||||
Reference in New Issue
Block a user