Compare commits

..
21 Commits
Author SHA1 Message Date
Chris 8686edd8d7 On fix/aa-images-and-flickering: MA beta 14 player merging WIP 2026-05-05 06:03:45 +01:00
Chris 8e5d4ab002 index on fix/aa-images-and-flickering: 40a2e2f Fix AA shuffle/repeat buttons and artist images 2026-05-05 06:03:45 +01:00
ChrisandClaude Opus 4.6 40a2e2f43a Fix AA shuffle/repeat buttons and artist images
Build Android APK / Build APK (pull_request) Failing after 23m4s
Shuffle/repeat buttons showed disabled after starting playback because
_refreshQueueAfterDelay only updated queue items without syncing the
shuffle/repeat state. Now syncs both flags and refreshes playback state.

Artist images failed in AA because _autoBuildArtistList used the basic
getImageUrl which returns null for artists without MA metadata images.
Now uses getArtistImageUrlWithFallback which tries external sources.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:27:14 +00:00
ChrisandClaude Opus 4.6 e15cfb545e Smooth scroll-to-hide for library filter bar and search bar
Replace boolean toggle with continuous ValueNotifier<double> so bars
slide 1:1 with finger movement instead of snapping. Use
ValueListenableBuilder to avoid full-screen rebuilds on every scroll
frame, and Align(heightFactor) for uniform top-clip instead of
SizedBox squishing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 06:47:18 +00:00
ChrisandClaude Opus 4.6 76d0f0b80e Sync shuffle/repeat state from queue when AA connects
The local _shuffleOn and _repeatMode flags were initialized to off,
so AA buttons always showed disabled on connect even when shuffle/repeat
were actually enabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 06:14:36 +00:00
ChrisandClaude Opus 4.6 a3eb3db8a2 Fix 4 regressions: BT/AA pause, AA buttons, artist cache, AA disconnect
- Make onPlay/onPause callbacks non-toggle (resumePlayer/pausePlayer
  instead of playPauseSelectedPlayer) so AA buttons work correctly
- Fix interruption guard to treat builtin-active as "was playing" for
  PCM/Sendspin mode where playbackState can be stale
- Keep media session alive during transient idle when PCM is playing
- Only cache non-empty artist album results to prevent stale 0 counts
- Only pause builtin player on AA disconnect (not remote speakers)
- Auto-select builtin player on AA connect broadcast

Fixes #25

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:54:47 +00:00
ChrisandClaude Opus 4.6 1877205034 Add Start Radio button to artist albums page in Android Auto
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:12:08 +00:00
ChrisandClaude Opus 4.6 bb46a2dee1 Remove noisy periodic logging that filled the 1000-entry bug report buffer
Position tracker, queue persistence, notification timer, and message
receipt logs fired every few seconds, pushing out meaningful events
(AA lifecycle, playback state changes) within minutes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:51:03 +00:00
ChrisandClaude Opus 4.6 2c07134404 Fix volume slider grab area by adding opaque hit test behavior
Without HitTestBehavior.opaque, the GestureDetector only registered
hits on the 20px painted track area instead of the full 48px touch target.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:12:03 +00:00
ChrisandClaude Opus 4.6 ea99b2bb11 Fix AA cold-start: async list builders and cache invalidation
List builders for artists, albums, and playlists now await
SyncService.loadFromCache() when data is empty (matching the existing
audiobook pattern). getArtistAlbumsWithCache also falls back to
SyncService when _albums is empty. After library loads (both from cache
and background sync), AA browse children are invalidated so stale
empty results get replaced.

Fixes #25

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 07:50:11 +00:00
ChrisandClaude Opus 4.6 9cd27553c1 Fix AA cold-start caching empty artist albums
Don't cache empty artist album results when the library hasn't loaded
yet. On cold start, SyncService may not have finished populating the
album list, causing getArtistAlbumsWithCache to return and cache 0
results permanently until the cache expires.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 06:54:43 +00:00
ChrisandClaude Opus 4.6 0db9bb3df7 Fix AA detection: trigger from any getChildren/playFromMediaId, not just root
AA caches the browse tree after first session, so getChildren for the
browsable root is never called on subsequent connects. This meant
_isAndroidAutoConnected was never set, breaking both disconnect detection
(display listener ignored) and playback button refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:54:56 +00:00
ChrisandClaude Opus 4.6 0a88d147ed Fix AA disconnect: detect via display removal, prevent auto-resume
Android Auto on projection-mode devices (com.google.android.projection.gearhead)
doesn't trigger ACTION_EXIT_CAR_MODE, so the disconnect was never detected.

- Add DisplayManager.DisplayListener to detect AA disconnect when virtual
  displays are removed (projection mode)
- Add bidirectional method channel so Dart notifies native of AA connection
- Add onAADisconnected callback that force-pauses (not toggles) the player
- Suppress Sendspin stream/start for 2s after disconnect to handle the race
  where the server sends audio before processing our pause command
- Guard interruption begin handler to only pause/toggle when actually playing,
  preventing the interrupted-while-paused -> accidental resume bug

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:56:13 +00:00
ChrisandClaude Opus 4.6 c3f6710dd1 Hide switch player in AA, fix resume on disconnect, rename Smart Shuffle
- Detect Android Auto via car mode broadcasts and getChildren; hide
  switch player button from controls while AA is connected
- Suppress audio auto-resume after becoming noisy / AA disconnect to
  prevent music playing from phone speaker when car turns off
- Rename "Smart Shuffle" to "Start Radio" with radio icon

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:03:16 +00:00
ChrisandClaude Opus 4.6 68e3b74d5d Add Smart Shuffle to Android Auto and radio fallback to shuffled playback
Radio calls now fall back gracefully when the provider doesn't support radio:
playRadio falls back to playing the single track, playArtistRadio falls back
to shuffled artist tracks via search. Direct API calls in context menu and
library screen routed through provider to pick up the fallback. Android Auto
gets a "Smart Shuffle" item at the top of track lists (albums, playlists,
favorites) with a custom sparkle-shuffle icon that attempts radio then falls
back to shuffled playback. Also fixes builtin player selection to avoid
premature fallback before Sendspin registration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 06:18:08 +00:00
ChrisandClaude Opus 4.6 b3aa55a021 Fix podcast/audiobook metadata in notifications and AA, position tracking
- Add _trackDisplayArtist() helper to show podcast name, audiobook author,
  or radio station instead of "Unknown Artist" in all notification/AA contexts
- Auto-restore audiobook context from cached data when URI matches
- Set podcast name context when playing episodes from AA browse tree
- Encode podcast ID in episode media IDs for context lookup
- Fix Sendspin pause position using server-synced position tracker instead
  of PCM elapsed time (which resets to 0 each stream)
- Fix Sendspin resume position to use position tracker instead of Duration.zero
- Add 10s seek-back on audiobook resume to compensate for Sendspin buffering
- Populate AA queue for podcast and radio playback via _refreshQueueAfterDelay
- Show podcast name as artist in AA episode browse list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:53:07 +00:00
ChrisandClaude Opus 4.6 823b95ec1a Fix builtin player notification conflicts and player selection fallback
Use updateLocalModeNotification instead of setRemotePlaybackState for
Sendspin/builtin players to avoid conflicting mediaItem.add() calls that
cause artwork flickering. Add fallback to builtin player when last
selected player is unavailable. Extract shared _controls getter, use
MediaControl.custom for switch player, and only claim audio focus on
play transitions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 06:45:06 +00:00
ChrisandClaude Opus 4.6 59d48deaf7 Android Auto audit: artwork ContentProvider, queue, search, polish
- Add ArtworkContentProvider to serve artwork via content:// URIs
  (fixes broken images on many AA head units that reject HTTP URLs)
- Wrap all artUri sites in handler and provider with content:// URIs
- Populate AA play queue after playTracks for queue view support
- Wire updateQueueIndex from provider's _updatePlayerState
- Add error state communication to AA on playback failures
- Rewrite search() to return grouped results (artists, albums, tracks)
- Add playFromSearch() for voice command support ("Play X on Ensemble")
- Add search race condition guard
- Declare SEARCH_SUPPORTED in browsable root extras for search button
- Add invalidateAutoChildren for library add/delete events
- Add cache size limit (50 entries) with LRU eviction for track cache
- Clean up track cache and children subjects in dispose()
- Add audiobook completion status extras (progress bars in AA)
- Add subtitle link extras on track items (tappable artist)
- Reset idle timer on AA browse activity
- Fix PCM player auto-recovery triggering on user-initiated pause

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:16:21 +00:00
ChrisandClaude Opus 4.6 753f5161fb Fix AA reconnect race condition and audio focus handling
Make checkAndReconnect idempotent: concurrent callers now join the
existing reconnect attempt instead of each creating a new WebSocket
connection. This prevents the cascading "API disposed" errors seen
when Android Auto browses multiple categories simultaneously.

Activate audio session when Sendspin/remote playback starts so Android
delivers audio focus interruption events. Ensemble now pauses when
another app (Spotify, YouTube, etc.) takes audio focus, and resumes
when focus returns.

Remove switch-player button from Android Auto controls (not useful
in car context).

Fixes #25

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:07:17 +00:00
ChrisandClaude Opus 4.6 e4cdb40fde Fix home screen gradient obscuring third row text
Only show the bottom fade gradient when 4+ rows are enabled, since
with ≤3 rows the content doesn't scroll behind the mini player. Reduce
gradient height to mini player height only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:11:22 +00:00
ChrisandClaude Opus 4.6 ad5ecf59b0 Fix Android Auto images and player background flickering
Fix imageproxy URLs using wrong port: Dart's Uri.hasPort returns false
for default ports (80/443) even when explicitly specified, causing
fallback to port 8095. Use raw string check instead.

Fix player background flash: use theme surface color as fallback instead
of hardcoded black, and add race guard to color extraction to prevent
stale results on rapid track changes.

Fixes #25, fixes #73

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 10:05:31 +00:00
27 changed files with 1534 additions and 536 deletions
-167
View File
@@ -1,167 +0,0 @@
# Music Assistant 2.8.0 Beta 14 — Impact on Ensemble
**Date:** 2026-03-03
**Current server:** 2.8.0 beta 12
**Target server:** 2.8.0 beta 14 (released March 2, 2026)
---
## Summary
Beta 14 is a large release (50+ PRs). The headline change — merging multi-protocol players into a single entity — directly overlaps with Ensemble's client-side Cast↔Sendspin consolidation logic. Sendspin also received a major v4.0 overhaul. Most changes should degrade gracefully but player ID persistence will break on upgrade.
---
## Breaking: Player Merging (PR #3150)
MA now merges players that support multiple protocols (e.g. a Chromecast with Cast + Sendspin) into a **single player entity** server-side. Previously these appeared as two separate players.
> *"After this change, all your players will be reset. If you previously had multiple players in Music Assistant per protocol, this will now show up as one single player."*
### What Ensemble does today
Ensemble handles this client-side in `music_assistant_provider.dart:4271-4406`:
- `_castToSendspinIdMap` maps Cast IDs → Sendspin IDs
- When Sendspin is grouped: hides Cast player, shows Sendspin (renamed to base name)
- When ungrouped: hides Sendspin, shows original Cast
- Mappings persisted to database for cold start restoration
### Impact on Ensemble
| Component | Location | Impact | Severity |
|---|---|---|---|
| `_castToSendspinIdMap` + consolidation logic | `provider:4271-4406` | Dead code — server no longer sends separate players | Medium |
| `isManuallySyncedPlayer()` ID translation | `provider:533-595` | Lookups against stale map fail silently (returns `false`) | Low |
| Player selection persistence | Settings + DB | **Stored player IDs invalid after upgrade** — all players reset | **High** |
| `_handlePlayerUpdatedEvent()` ID matching | `provider:2956-3358` | Events arrive with new merged IDs, old translation won't match | Medium |
| Database-persisted Cast↔Sendspin mappings | `DatabaseService` | Stale data, won't match anything | Low |
### Assessment
The consolidation logic should degrade gracefully — it won't find Sendspin suffixes to strip or Cast pairs to merge, so the server's already-merged players pass through `filterPlayers()` as-is. The main risk is **player selection breaking** because saved player IDs no longer exist.
### Action required
1. Verify the existing fallback logic (`provider:4427-4465`) gracefully re-selects when the stored `selectedPlayerId` doesn't exist in the player list
2. Remove or gate the Cast↔Sendspin consolidation code behind a server version check (on beta 14+ it's unnecessary and could cause confusion)
3. Clear stale database mappings on first connection to a beta 14+ server
---
## Sendspin v4.0 (PR #3158)
Major rewrite of the Sendspin server.
### Changes
- New `SendspinPlaybackSession` coordinator architecture
- Per-member DSP pipelines, dynamic group membership, late-join audio backfill
- Max buffer capacity 5 → 30 seconds
- Multiple Sendspin servers with automatic failover
- Per-client codec/sample-rate/bit-depth options
- Album artwork persists during skip/pause
- Reduced latency for live streams
### Impact on Ensemble
| Change | Impact |
|---|---|
| New session architecture | **Likely compatible** — sendspin-js only bumped 2.0.0 → 2.0.1 (minor), suggesting wire protocol didn't fundamentally change |
| Per-client codec options | `stream/start` may contain new fields — safe if Ensemble ignores unknown fields |
| Multiple servers with failover | Ensemble's 3-tier connection strategy may need awareness |
| Buffer increase | No client impact |
### Action required
1. Test Sendspin connection against beta 14 — verify `client/hello`, `stream/start`, state reporting all still work
2. Confirm unknown fields in protocol messages are ignored gracefully
---
## Sendspin-Chromecast Bridge (PR #3255)
Replaces the inline experimental Sendspin mode in `ChromecastPlayer` with a dedicated bridge. Cast devices now use a Cast receiver app with an embedded JS Sendspin client.
### Impact on Ensemble
- **Direct impact: None** — Ensemble is a Sendspin client itself, not going through Cast
- **Indirect:** Changes how grouped Cast players behave, which could affect `GroupVolumeManager`
---
## Group Volume / Grouping Fixes
| PR | Change | Ensemble Impact |
|---|---|---|
| #3192 | Fix grouping for players whose native protocol is also a protocol of other players | Bidirectional grouping translation — may affect group detection logic |
| #3205 | Fix group mute for protocol-synced players (`player.state.group_members` vs `player.group_members`) | `GroupVolumeManager` may see different/correct member lists |
| #3277 | Fix `volume_up`/`volume_down` for group players | Server-side fix, should improve behavior |
| #3118 | Fix sync groups losing members on power off | Affects dynamic group management |
### Action required
1. Test group volume behavior — may "just work better" with server-side fixes
2. Verify `GroupVolumeManager` handles any changes to `group_members` field population
---
## Sendspin Bug Fixes
| PR | Fix | Ensemble Relevance |
|---|---|---|
| #3191 | DSP not applying for Sendspin players | DSP config stored on parent player, looked up by protocol player ID — now fixed |
| #3249 | Update to aiosendspin 4.2.0 | Library update, potential behavior changes |
| #3250 | Metadata sending wrong progress when paused | Progress reporting fix — Ensemble's position tracker may see better data |
| #3258 | Floating point for internal audio data | Audio quality improvement, no client impact |
---
## New APIs — Opportunities
| Feature | PR | Details | Ensemble Opportunity |
|---|---|---|---|
| Playback speed | #3198 | `player_queues/set_playback_speed`, stored in `queue.extra_attributes["playback_speed"]` | Great for audiobooks/podcasts |
| Save queue to playlist | #3149 | `save_as_playlist` command on player queue controller | Easy feature add |
| Mixed-media playlists | #3216 | Playlists can contain tracks + podcasts + audiobooks. DB schema bumped to v30. `get_playlist_tracks()` returns `Sequence[PlaylistPlayableItem]` | Playlist screens may need to handle heterogeneous items |
| Genres v2 | #3164 | New browsing category with icons and SVG support | New library section |
---
## Also Changed in Beta 13 (Skipped)
Relevant changes from beta 13 (between current beta 12 and target beta 14):
- Group volume mute support (#3034)
- Frontend prepared for multiprotocol support (#1409) — groundwork for beta 14 merging
- IPv6 support for zeroconf, stream server, AirPlay (#3086)
- Snapcast player availability alignment (#3104)
- DLNA reconnection fix (#3132)
---
## Recommended Action Plan
### Before upgrading
- [ ] Verify player selection fallback handles missing player IDs gracefully
- [ ] Back up current configuration
### Immediate (after upgrading)
- [ ] Test Sendspin protocol compatibility (connection, streaming, state reporting)
- [ ] Test player list — confirm merged players appear correctly without client-side consolidation
- [ ] Test group volume behavior with new server-side fixes
- [ ] Test player selection persistence across app restarts
### Short-term
- [ ] Remove or version-gate Cast↔Sendspin consolidation code
- [ ] Clear stale database mappings on beta 14+ detection
- [ ] Test mixed-media playlist handling if playlists are used
### Opportunistic
- [ ] Add playback speed control (audiobook/podcast feature)
- [ ] Add save-queue-to-playlist
- [ ] Explore Genres v2 browsing
-1
View File
@@ -175,7 +175,6 @@ For access outside your home network, you'll need to expose Music Assistant thro
A huge thank you to my sponsors! 💖
<a href="https://github.com/pcsokonay"><img src="https://github.com/pcsokonay.png" width="60px" alt="pcsokonay" /></a>
<a href="https://github.com/githubchb"><img src="https://github.com/githubchb.png" width="60px" alt="githubchb" /></a>
## License
-10
View File
@@ -77,16 +77,6 @@ android {
}
}
def abiCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86_64': 3]
android.applicationVariants.configureEach { variant ->
variant.outputs.each { output ->
def abiVersionCode = abiCodes[output.getFilter('ABI')]
if (abiVersionCode != null) {
output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode
}
}
}
flutter {
source '../..'
}
+6
View File
@@ -54,6 +54,12 @@
</intent-filter>
</receiver>
<provider
android:name=".ArtworkContentProvider"
android:authorities="com.collotsspot.ensemble.artwork"
android:exported="true"
android:grantUriPermissions="true" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
@@ -0,0 +1,84 @@
package com.collotsspot.ensemble
import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.util.Base64
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
/**
* ContentProvider that serves artwork images to Android Auto.
*
* Android Auto only supports content:// and android.resource:// URIs for artwork.
* This provider accepts a base64url-encoded HTTP URL in the path, downloads the
* image (with disk caching), and returns a ParcelFileDescriptor.
*
* URI format: content://com.collotsspot.ensemble.artwork/{base64url_encoded_http_url}
*/
class ArtworkContentProvider : ContentProvider() {
companion object {
private const val CONNECT_TIMEOUT_MS = 5000
private const val READ_TIMEOUT_MS = 10000
}
override fun onCreate(): Boolean = true
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
val encoded = uri.lastPathSegment ?: return null
val httpUrl: String
try {
httpUrl = String(Base64.decode(encoded, Base64.URL_SAFE or Base64.NO_WRAP))
} catch (e: Exception) {
return null
}
val cacheDir = File(context?.cacheDir, "artwork")
if (!cacheDir.exists()) cacheDir.mkdirs()
val hash = md5(httpUrl)
val cacheFile = File(cacheDir, hash)
if (!cacheFile.exists()) {
try {
val connection = URL(httpUrl).openConnection() as HttpURLConnection
connection.connectTimeout = CONNECT_TIMEOUT_MS
connection.readTimeout = READ_TIMEOUT_MS
connection.instanceFollowRedirects = true
try {
connection.inputStream.use { input ->
cacheFile.outputStream().use { output ->
input.copyTo(output)
}
}
} finally {
connection.disconnect()
}
} catch (e: Exception) {
cacheFile.delete()
return null
}
}
return ParcelFileDescriptor.open(cacheFile, ParcelFileDescriptor.MODE_READ_ONLY)
}
override fun query(uri: Uri, projection: Array<String>?, selection: String?,
selectionArgs: Array<String>?, sortOrder: String?): Cursor? = null
override fun getType(uri: Uri): String = "image/*"
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = 0
override fun update(uri: Uri, values: ContentValues?, selection: String?,
selectionArgs: Array<String>?): Int = 0
private fun md5(input: String): String {
val digest = MessageDigest.getInstance("MD5")
val bytes = digest.digest(input.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
}
@@ -1,12 +1,19 @@
package com.collotsspot.ensemble
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.database.ContentObserver
import android.hardware.display.DisplayManager
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.app.UiModeManager
import android.util.Log
import android.view.Display
import android.view.KeyEvent
import com.ryanheise.audioservice.AudioServiceActivity
import io.flutter.embedding.engine.FlutterEngine
@@ -17,7 +24,10 @@ import io.flutter.plugin.common.MethodChannel
class MainActivity: AudioServiceActivity() {
private val TAG = "EnsembleVolume"
private val CHANNEL = "com.collotsspot.ensemble/volume_buttons"
private val AA_CHANNEL = "com.collotsspot.ensemble/android_auto"
private var methodChannel: MethodChannel? = null
private var aaChannel: MethodChannel? = null
private var carModeReceiver: BroadcastReceiver? = null
private var isListening = false
// Volume observer for lockscreen volume changes
@@ -30,6 +40,10 @@ class MainActivity: AudioServiceActivity() {
// Guard flag to ignore volume changes triggered by our own setStreamVolume calls
private var ignoringVolumeChange = false
// Android Auto projection tracking via display listener
private var isAATracked = false
private var displayListener: DisplayManager.DisplayListener? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Log.d(TAG, "Configuring Flutter engine, setting up MethodChannel")
@@ -74,6 +88,65 @@ class MainActivity: AudioServiceActivity() {
}
}
}
// Android Auto / car mode detection
aaChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, AA_CHANNEL)
// Receive AA connection signal from Dart side
aaChannel?.setMethodCallHandler { call, result ->
when (call.method) {
"notifyAAConnected" -> {
Log.d(TAG, "AA tracked via Dart notifyAAConnected")
isAATracked = true
result.success(null)
}
else -> result.notImplemented()
}
}
carModeReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
UiModeManager.ACTION_ENTER_CAR_MODE -> {
Log.d(TAG, "Car mode ENTERED")
isAATracked = true
aaChannel?.invokeMethod("onAndroidAutoConnected", null)
}
UiModeManager.ACTION_EXIT_CAR_MODE -> {
Log.d(TAG, "Car mode EXITED")
aaChannel?.invokeMethod("onAndroidAutoDisconnected", null)
}
}
}
}
val carModeFilter = IntentFilter().apply {
addAction(UiModeManager.ACTION_ENTER_CAR_MODE)
addAction(UiModeManager.ACTION_EXIT_CAR_MODE)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
applicationContext.registerReceiver(carModeReceiver, carModeFilter, Context.RECEIVER_EXPORTED)
} else {
applicationContext.registerReceiver(carModeReceiver, carModeFilter)
}
// Detect AA disconnect via display removal (projection mode)
val dm = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) {}
override fun onDisplayChanged(displayId: Int) {}
override fun onDisplayRemoved(displayId: Int) {
if (!isAATracked) return
val hasExtraDisplay = dm.displays.any {
it.displayId != Display.DEFAULT_DISPLAY
}
if (!hasExtraDisplay) {
Log.d(TAG, "AA projection ended (all extra displays removed)")
isAATracked = false
aaChannel?.invokeMethod("onAndroidAutoDisconnected", null)
}
}
}
dm.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
}
/// Start observing system STREAM_MUSIC volume changes.
@@ -224,6 +297,13 @@ class MainActivity: AudioServiceActivity() {
override fun onDestroy() {
stopVolumeObserver()
carModeReceiver?.let {
try { applicationContext.unregisterReceiver(it) } catch (_: Exception) {}
}
displayListener?.let {
val dm = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
dm.unregisterDisplayListener(it)
}
super.onDestroy()
}
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M16.5,3c-1.74,0 -3.41,0.81 -4.5,2.09C10.91,3.81 9.24,3 7.5,3 4.42,3 2,5.42 2,8.5c0,3.78 3.4,6.86 8.55,11.54L12,21.35l1.45,-1.32C18.6,15.36 22,12.28 22,8.5 22,5.42 19.58,3 16.5,3zM12.1,18.55l-0.1,0.1 -0.1,-0.1C7.14,14.24 4,11.39 4,8.5 4,6.5 5.5,5 7.5,5c1.54,0 3.04,0.99 3.57,2.36h1.87C13.46,5.99 14.96,5 16.5,5c2,0 3.5,1.5 3.5,3.5 0,2.89 -3.14,5.74 -7.9,10.05z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
<path android:fillColor="#FFFFFF"
android:pathData="M4,22h16v2H4z"/>
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
<path android:fillColor="#FFFFFF"
android:pathData="M13,15V9h-1l-2,1v1h1.5v4z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5V4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20H20v-5.5l-2.04,2.04 -3.13,-3.13z"/>
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5V4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20H20v-5.5l-2.04,2.04 -3.13,-3.13z"/>
<path android:fillColor="#FFFFFF"
android:pathData="M4,22h16v2H4z"/>
</vector>
@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<!-- Shuffle arrows -->
<path android:fillColor="#FFFFFF"
android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5V4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20H20v-5.5l-2.04,2.04 -3.13,-3.13z"/>
<!-- Sparkle -->
<path android:fillColor="#FFFFFF"
android:pathData="M19,1l-1.26,2.74L15,5l2.74,1.26L19,9l1.26,-2.74L23,5l-2.74,-1.26z"/>
</vector>
+3
View File
@@ -70,6 +70,9 @@ void main() {
androidNotificationIcon: 'drawable/ic_notification',
androidShowNotificationBadge: false,
androidStopForegroundOnPause: false, // Keep service alive when paused for background playback
androidBrowsableRootExtras: {
'android.media.browse.SEARCH_SUPPORTED': true,
},
),
);
_logger.log('🎵 AudioService initialized - background playback and media notifications ENABLED');
+433 -162
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:audio_service/audio_service.dart' as audio_service;
@@ -99,6 +100,14 @@ class MusicAssistantProvider with ChangeNotifier {
Timer? _idleServiceTimer;
static const _idleServiceTimeout = Duration(minutes: 30);
// Artwork content:// URI helper for Android Auto compatibility
static const _artworkAuthority = 'com.collotsspot.ensemble.artwork';
static Uri? _contentArtUri(String? httpUrl) {
if (httpUrl == null) return null;
final encoded = base64Url.encode(utf8.encode(httpUrl));
return Uri.parse('content://$_artworkAuthority/$encoded');
}
// Local player state
bool _isLocalPlayerPowered = true;
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
@@ -117,6 +126,7 @@ class MusicAssistantProvider with ChangeNotifier {
TrackMetadata? _pendingTrackMetadata;
TrackMetadata? _currentNotificationMetadata;
Completer<void>? _registrationInProgress;
Completer<void>? _reconnectInProgress;
// Local player service
late final LocalPlayerService _localPlayer;
@@ -230,6 +240,22 @@ class MusicAssistantProvider with ChangeNotifier {
};
}
/// Check if a server player ID matches our local builtin player ID.
/// MA beta 14+ wraps Sendspin player IDs with 'up' prefix and lowercases
/// them when merging into universal_player entities, so we need flexible matching.
/// e.g. local "ensemble_Chris_CP21.260" matches server "upensemble_chris_cp21.260"
bool _isBuiltinPlayer(String serverPlayerId, String builtinPlayerId) {
if (serverPlayerId == builtinPlayerId) return true;
return serverPlayerId.toLowerCase() == 'up${builtinPlayerId.toLowerCase()}' ||
serverPlayerId.toLowerCase() == builtinPlayerId.toLowerCase();
}
/// Check if a server player ID is an Ensemble player (this or other device)
bool _isEnsemblePlayer(String serverPlayerId) {
final lower = serverPlayerId.toLowerCase();
return lower.startsWith('ensemble_') || lower.startsWith('upensemble_');
}
/// Player filter from MA user settings (empty = all players allowed)
List<String> get playerFilter => _playerFilter;
@@ -681,6 +707,40 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Get the display artist string for a track, using podcast/radio/audiobook context when applicable
String _trackDisplayArtist(Track track) {
if (isPlayingAudiobook) {
return _currentAudiobook!.authorsString;
}
if (isPlayingPodcast) {
return currentPodcastName ?? 'Podcasts';
}
if (isPlayingRadio) {
return currentRadioStationName ?? track.artistsString;
}
// Fallback: check if track URI looks like an audiobook chapter
final uri = track.uri ?? '';
if (uri.contains('audiobook') || uri.contains('/chapter/')) {
// Try to find audiobook from cached data
final book = _findAudiobookForTrack(track);
if (book != null) {
_currentAudiobook = book;
_logger.log('📚 Auto-restored audiobook context: ${book.name}');
return book.authorsString;
}
}
return track.artistsString;
}
/// Try to find the audiobook that owns this track/chapter from cached data
Audiobook? _findAudiobookForTrack(Track track) {
final uri = track.uri ?? '';
for (final book in SyncService.instance.cachedAudiobooks) {
if (uri.contains(book.itemId)) return book;
}
return null;
}
String get lastSearchQuery => _lastSearchQuery;
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
@@ -885,14 +945,30 @@ class MusicAssistantProvider with ChangeNotifier {
// Restore selected player from settings
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
if (lastSelectedPlayerId != null) {
try {
_selectedPlayer = players.firstWhere((p) => p.playerId == lastSelectedPlayerId);
_cacheService.setCachedSelectedPlayer(_selectedPlayer);
_selectedPlayer = players.cast<Player?>().firstWhere(
(p) => p!.playerId == lastSelectedPlayerId,
orElse: () => null,
);
if (_selectedPlayer != null) {
_logger.log('📦 Restored selected player from database: ${_selectedPlayer?.name}');
} catch (_) {
// Player not in cached list
}
}
// Fallback to local/builtin player
if (_selectedPlayer == null) {
final builtinId = await SettingsService.getBuiltinPlayerId();
if (builtinId != null) {
_selectedPlayer = players.cast<Player?>().firstWhere(
(p) => p!.playerId == builtinId,
orElse: () => null,
);
if (_selectedPlayer != null) {
_logger.log('📦 Defaulted to local player: ${_selectedPlayer?.name}');
}
}
}
if (_selectedPlayer != null) {
_cacheService.setCachedSelectedPlayer(_selectedPlayer);
}
_logger.log('📦 Loaded ${players.length} players from database (instant)');
notifyListeners();
@@ -953,6 +1029,9 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('📦 Pre-loaded library for favorites: ${_albums.length} albums, ${_artists.length} artists');
_syncLibraryStatusToService();
notifyListeners();
audioHandler.invalidateAutoChildren(const [
'cat|artists', 'cat|albums', 'cat|playlists', 'cat|home',
]);
}
} catch (e) {
_logger.log('⚠️ Error loading library from cache: $e');
@@ -1946,6 +2025,12 @@ class MusicAssistantProvider with ChangeNotifier {
}
Future<void> checkAndReconnect() async {
// Deduplicate: if a reconnect is already in progress, join it
if (_reconnectInProgress != null) {
_logger.log('🔄 checkAndReconnect called — already in progress, joining existing attempt');
return _reconnectInProgress!.future;
}
_logger.log('🔄 checkAndReconnect called - state: $_connectionState');
if (_serverUrl == null) {
@@ -1953,60 +2038,82 @@ class MusicAssistantProvider with ChangeNotifier {
return;
}
// IMMEDIATELY load cached players for instant UI display
// This makes mini player and device button appear instantly on app resume
if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) {
_availablePlayers = _cacheService.getCachedPlayers()!;
_reconnectInProgress = Completer<void>();
// Sort cached players immediately so list appears in correct order
final smartSort = await SettingsService.getSmartSortPlayers();
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
_sortPlayersSync(_availablePlayers, smartSort, builtinPlayerId);
try {
// IMMEDIATELY load cached players for instant UI display
// This makes mini player and device button appear instantly on app resume
if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) {
_availablePlayers = _cacheService.getCachedPlayers()!;
_selectedPlayer = _cacheService.getCachedSelectedPlayer();
// Also try to restore from settings if cache doesn't have selected player
if (_selectedPlayer == null && _availablePlayers.isNotEmpty) {
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
if (lastSelectedPlayerId != null) {
try {
_selectedPlayer = _availablePlayers.firstWhere(
(p) => p.playerId == lastSelectedPlayerId,
// Sort cached players immediately so list appears in correct order
final smartSort = await SettingsService.getSmartSortPlayers();
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
_sortPlayersSync(_availablePlayers, smartSort, builtinPlayerId);
_selectedPlayer = _cacheService.getCachedSelectedPlayer();
// Also try to restore from settings if cache doesn't have selected player
if (_selectedPlayer == null && _availablePlayers.isNotEmpty) {
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
if (lastSelectedPlayerId != null) {
_selectedPlayer = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == lastSelectedPlayerId,
orElse: () => null,
);
} catch (e) {
_selectedPlayer = _availablePlayers.first;
}
} else {
_selectedPlayer = _availablePlayers.first;
// Fallback to local/builtin player
if (_selectedPlayer == null) {
final builtinId = await SettingsService.getBuiltinPlayerId();
if (builtinId != null) {
_selectedPlayer = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == builtinId,
orElse: () => null,
);
}
}
// Last resort: first available (only if no builtin player configured)
if (_selectedPlayer == null) {
final builtinConfigured = await SettingsService.getBuiltinPlayerId();
if (builtinConfigured == null) {
_selectedPlayer = _availablePlayers.first;
}
}
}
_logger.log('⚡ Loaded ${_availablePlayers.length} cached players instantly (sorted)');
notifyListeners(); // Update UI immediately with cached data
}
_logger.log('⚡ Loaded ${_availablePlayers.length} cached players instantly (sorted)');
notifyListeners(); // Update UI immediately with cached data
}
if (_connectionState != MAConnectionState.connected &&
_connectionState != MAConnectionState.authenticated) {
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
try {
await connectToServer(_serverUrl!);
_logger.log('🔄 Reconnection successful');
} catch (e) {
_logger.log('🔄 Reconnection failed: $e');
}
} else {
_logger.log('🔄 Already connected, verifying connection...');
try {
await refreshPlayers();
await _updatePlayerState();
// Note: _preloadAdjacentPlayers is already called in refreshPlayers() -> _loadAndSelectPlayers()
_logger.log('🔄 Connection verified, players and state refreshed');
} catch (e) {
_logger.log('🔄 Connection verification failed, reconnecting: $e');
if (_connectionState != MAConnectionState.connected &&
_connectionState != MAConnectionState.authenticated) {
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
try {
await connectToServer(_serverUrl!);
} catch (reconnectError) {
_logger.log('🔄 Reconnection failed: $reconnectError');
_logger.log('🔄 Reconnection successful');
} catch (e) {
_logger.log('🔄 Reconnection failed: $e');
}
} else {
_logger.log('🔄 Already connected, verifying connection...');
try {
await refreshPlayers();
await _updatePlayerState();
// Note: _preloadAdjacentPlayers is already called in refreshPlayers() -> _loadAndSelectPlayers()
_logger.log('🔄 Connection verified, players and state refreshed');
} catch (e) {
_logger.log('🔄 Connection verification failed, reconnecting: $e');
try {
await connectToServer(_serverUrl!);
} catch (reconnectError) {
_logger.log('🔄 Reconnection failed: $reconnectError');
}
}
}
_reconnectInProgress!.complete();
} catch (e) {
_reconnectInProgress!.completeError(e);
} finally {
_reconnectInProgress = null;
}
}
@@ -2029,16 +2136,43 @@ class MusicAssistantProvider with ChangeNotifier {
};
audioHandler.onPlay = () {
_logger.log('🎵 Notification: Play pressed');
playPauseSelectedPlayer();
if (_selectedPlayer != null) {
resumePlayer(_selectedPlayer!.playerId);
unawaited(refreshPlayers());
}
};
audioHandler.onPause = () {
_logger.log('🎵 Notification: Pause pressed');
playPauseSelectedPlayer();
if (_selectedPlayer != null) {
pausePlayer(_selectedPlayer!.playerId);
}
};
audioHandler.onSwitchPlayer = () {
_logger.log('🎵 Notification: Switch player pressed');
selectNextPlayer();
};
audioHandler.onBrowseActivity = () {
_cancelIdleServiceTimer();
};
audioHandler.onAAConnected = () async {
final playerId = await SettingsService.getBuiltinPlayerId();
if (playerId != null && _selectedPlayer?.playerId != playerId) {
final builtinPlayer = _availablePlayers
.where((p) => p.playerId == playerId)
.firstOrNull;
if (builtinPlayer != null) {
_logger.log('🎵 AA connected: auto-selecting builtin player "${builtinPlayer.name}"');
selectPlayer(builtinPlayer);
}
}
};
audioHandler.onAADisconnected = () async {
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null) {
_logger.log('🎵 AA disconnected: pausing builtin player');
pausePlayer(builtinPlayerId);
}
};
// Player registration is now handled in _initializeAfterConnection()
// which runs after authentication completes (when auth is required)
@@ -2466,6 +2600,11 @@ class MusicAssistantProvider with ChangeNotifier {
/// 2. Start the foreground service to prevent background throttling
/// 3. Reset position for new track and start position timer
void _handleSendspinStreamStart(Map<String, dynamic>? trackInfo) async {
final aaDisc = audioHandler.aaDisconnectedAt;
if (aaDisc != null && DateTime.now().difference(aaDisc).inSeconds < 2) {
_logger.log('🎵 Sendspin: Ignoring stream/start (AA disconnected ${DateTime.now().difference(aaDisc).inMilliseconds}ms ago)');
return;
}
_logger.log('🎵 Sendspin: Stream starting');
// Ensure PCM player is initialized and ready
@@ -2508,25 +2647,46 @@ class MusicAssistantProvider with ChangeNotifier {
// Keep the foreground service active to prevent Android from throttling
// the PCM audio playback when the app goes to background.
// We use setRemotePlaybackState to maintain the notification without
// actually playing audio through just_audio.
final mediaItem = audio_service.MediaItem(
id: 'sendspin_pcm_stream',
title: title ?? 'Playing via Sendspin',
artist: artist ?? 'Music Assistant',
album: album,
artUri: artworkUrl != null ? Uri.parse(artworkUrl) : null,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
);
// Initialize notification with position 0
// Use updateLocalModeNotification (NOT setRemotePlaybackState) to avoid
// conflicting mediaItem.add() calls — _updatePlayerState() also calls
// updateLocalModeNotification with the real track data.
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: true,
position: Duration.zero,
duration: mediaItem.duration,
);
if (_currentTrack != null) {
final track = _currentTrack!;
final trackArtworkUrl = _api?.getImageUrl(track, size: 512);
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '${displayArtist} • ${_selectedPlayer?.name ?? ''}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(trackArtworkUrl),
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: true,
duration: track.duration,
position: _positionTracker.currentPosition,
);
} else {
// No track data yet — use stream metadata as fallback
final mediaItem = audio_service.MediaItem(
id: 'sendspin_pcm_stream',
title: title ?? 'Playing via Sendspin',
artist: artist ?? 'Music Assistant',
album: album,
artUri: _contentArtUri(artworkUrl),
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: true,
duration: mediaItem.duration,
position: _positionTracker.currentPosition,
);
}
// Start notification position timer for Sendspin PCM
_manageNotificationPositionTimer();
@@ -2537,8 +2697,8 @@ class MusicAssistantProvider with ChangeNotifier {
/// Handle Sendspin stream end - server stopped sending PCM audio data
/// This is called when audio streaming ends (pause, stop, track end, etc.)
void _handleSendspinStreamEnd() async {
// Capture current position before stopping
final lastPosition = _pcmAudioPlayer?.elapsedTime ?? Duration.zero;
// Use position tracker (server-synced) instead of PCM elapsed time which resets to 0 each stream
final lastPosition = _positionTracker.currentPosition;
_logger.log('🎵 Sendspin: Stream ended at position ${lastPosition.inSeconds}s');
// Stop notification position timer
@@ -2547,27 +2707,46 @@ class MusicAssistantProvider with ChangeNotifier {
// Pause PCM playback (preserves position) instead of stop (resets position)
await _pcmAudioPlayer?.pause();
// Update foreground service to show paused/stopped state with last position
// Don't completely clear it - keep showing the notification
// in case user wants to resume
final metadata = _currentNotificationMetadata;
final mediaItem = audio_service.MediaItem(
id: 'sendspin_pcm_stream',
title: metadata?.title ?? 'Music Assistant',
artist: metadata?.artist ?? 'Paused',
album: metadata?.album,
artUri: metadata?.artworkUrl != null ? Uri.parse(metadata!.artworkUrl!) : null,
duration: metadata?.duration,
);
// Show paused state with preserved position
// Update foreground service to show paused state with last position.
// Use updateLocalModeNotification (NOT setRemotePlaybackState) to avoid
// conflicting mediaItem.add() calls with _updatePlayerState().
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: false,
position: lastPosition,
duration: mediaItem.duration,
);
if (_currentTrack != null) {
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: 512);
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '${displayArtist} • ${_selectedPlayer?.name ?? ''}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(artworkUrl),
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: false,
duration: track.duration,
position: lastPosition,
);
} else {
final metadata = _currentNotificationMetadata;
final mediaItem = audio_service.MediaItem(
id: 'sendspin_pcm_stream',
title: metadata?.title ?? 'Music Assistant',
artist: metadata?.artist ?? 'Paused',
album: metadata?.album,
artUri: _contentArtUri(metadata?.artworkUrl),
duration: metadata?.duration,
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: false,
duration: mediaItem.duration,
position: lastPosition,
);
}
}
void _startReportingLocalPlayerState() {
@@ -2771,6 +2950,7 @@ class MusicAssistantProvider with ChangeNotifier {
final mediaType = event['media_type'] as String?;
_cacheService.invalidateSearchCache();
_scheduleLibraryRefresh(mediaType ?? 'artist');
audioHandler.invalidateAutoChildren(const ['cat|favorites', 'cat|home', 'cat|fav_tracks', 'cat|fav_albums', 'cat|fav_artists']);
} catch (e) {
_logger.log('Error handling media item added event: $e');
}
@@ -2783,6 +2963,7 @@ class MusicAssistantProvider with ChangeNotifier {
final mediaType = event['media_type'] as String?;
_cacheService.invalidateSearchCache();
_scheduleLibraryRefresh(mediaType ?? 'artist');
audioHandler.invalidateAutoChildren(const ['cat|favorites', 'cat|home', 'cat|fav_tracks', 'cat|fav_albums', 'cat|fav_artists']);
} catch (e) {
_logger.log('Error handling media item deleted event: $e');
}
@@ -3821,6 +4002,17 @@ class MusicAssistantProvider with ChangeNotifier {
if (_api == null) return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
try {
// Ensure library is loaded for artist matching
if (_albums.isEmpty) {
final syncService = SyncService.instance;
if (!syncService.hasCache) {
await syncService.loadFromCache();
}
if (syncService.cachedAlbums.isNotEmpty) {
_albums = syncService.cachedAlbums;
}
}
_logger.log('🔄 Fetching albums for artist "$artistName"...');
// Use already-loaded library albums instead of re-fetching from API
@@ -3849,7 +4041,11 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
_cacheService.setCachedArtistAlbums(cacheKey, allAlbums);
// Only cache if the library is loaded — empty results during cold start
// are likely due to SyncService not having finished yet
if (allAlbums.isNotEmpty) {
_cacheService.setCachedArtistAlbums(cacheKey, allAlbums);
}
_logger.log('✅ Cached ${allAlbums.length} albums for artist "$artistName"');
return allAlbums;
} catch (e) {
@@ -3991,8 +4187,8 @@ class MusicAssistantProvider with ChangeNotifier {
// Smart sort: local player first, then playing, then on, then off
players.sort((a, b) {
// Local player always first
final aIsLocal = builtinPlayerId != null && a.playerId == builtinPlayerId;
final bIsLocal = builtinPlayerId != null && b.playerId == builtinPlayerId;
final aIsLocal = builtinPlayerId != null && _isBuiltinPlayer(a.playerId, builtinPlayerId);
final bIsLocal = builtinPlayerId != null && _isBuiltinPlayer(b.playerId, builtinPlayerId);
if (aIsLocal && !bIsLocal) return -1;
if (bIsLocal && !aIsLocal) return 1;
@@ -4069,8 +4265,8 @@ class MusicAssistantProvider with ChangeNotifier {
return false;
}
if (player.playerId.startsWith('ensemble_')) {
if (builtinPlayerId == null || player.playerId != builtinPlayerId) {
if (_isEnsemblePlayer(player.playerId)) {
if (builtinPlayerId == null || !_isBuiltinPlayer(player.playerId, builtinPlayerId)) {
_logger.log('🚫 Filtering out other device\'s player: ${player.name}');
filteredCount++;
return false;
@@ -4078,7 +4274,7 @@ class MusicAssistantProvider with ChangeNotifier {
}
if (!player.available) {
if (builtinPlayerId != null && player.playerId == builtinPlayerId) {
if (builtinPlayerId != null && _isBuiltinPlayer(player.playerId, builtinPlayerId)) {
return true;
}
filteredCount++;
@@ -4299,19 +4495,31 @@ class MusicAssistantProvider with ChangeNotifier {
}
// Priority 2: Local player (default fallback)
// Try available first, then accept unavailable (Sendspin may still be registering)
if (playerToSelect == null && builtinPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == builtinPlayerId && p.available,
);
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => _isBuiltinPlayer(p!.playerId, builtinPlayerId) && p.available,
orElse: () => null,
);
if (playerToSelect != null) {
_logger.log('📱 Selected local player: ${playerToSelect.name}');
} catch (e) {
_logger.log('⚠️ Local player not available');
} else {
// Player exists but not yet available (Sendspin registration in progress)
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => _isBuiltinPlayer(p!.playerId, builtinPlayerId),
orElse: () => null,
);
if (playerToSelect != null) {
_logger.log('📱 Selected local player (pending availability): ${playerToSelect.name}');
} else {
_logger.log('⚠️ Local player not in player list yet — waiting for Sendspin registration');
}
}
}
// Priority 3: First available player
if (playerToSelect == null) {
// Priority 3: First available player (only if no local player configured)
// Don't fall back to another player if builtin player is configured but not yet registered
if (playerToSelect == null && builtinPlayerId == null) {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.available,
orElse: () => _availablePlayers.first,
@@ -4319,7 +4527,13 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
}
selectPlayer(playerToSelect);
if (playerToSelect != null) {
selectPlayer(playerToSelect);
} else {
// Builtin player not yet registered — keep current selection if any,
// otherwise leave unselected until Sendspin registration completes
_logger.log('⏳ No player selected — awaiting builtin player registration');
}
}
// Preload track data and images in background for swipe gestures
@@ -4370,23 +4584,22 @@ class MusicAssistantProvider with ChangeNotifier {
// Switch audio handler mode based on player type
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
final isBuiltinPlayer = builtinPlayerId != null && player.playerId == builtinPlayerId;
final isBuiltinPlayer = builtinPlayerId != null && _isBuiltinPlayer(player.playerId, builtinPlayerId);
if (isBuiltinPlayer) {
audioHandler.setLocalMode();
// Update notification for builtin player using local mode method (keeps pause working)
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: 512);
final artistWithPlayer = track.artistsString.isNotEmpty
? '${track.artistsString} • ${player.name}'
: player.name;
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '${displayArtist} • ${player.name}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
artUri: _contentArtUri(artworkUrl),
);
// Position comes from actual player in updateLocalModeNotification
audioHandler.updateLocalModeNotification(
@@ -4405,8 +4618,29 @@ class MusicAssistantProvider with ChangeNotifier {
item: mediaItem,
playing: player.state == 'playing',
);
} else if (_currentTrack != null) {
// Builtin player is idle but we have track data — show paused notification
// instead of clearing, to avoid artwork flash when AA toggles play/pause
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: 512);
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '${displayArtist} • ${player.name}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(artworkUrl),
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: false,
duration: track.duration,
);
_startIdleServiceTimer();
} else {
// Builtin player is idle - clear notification (fixes issue #42)
// Builtin player is idle with no track - clear notification
audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
}
@@ -4417,16 +4651,15 @@ class MusicAssistantProvider with ChangeNotifier {
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: 512);
// Include player name in artist line: "Artist • Player Name"
final artistWithPlayer = track.artistsString.isNotEmpty
? '${track.artistsString} • ${player.name}'
: player.name;
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '${displayArtist} • ${player.name}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
artUri: _contentArtUri(artworkUrl),
);
// Use position tracker for consistent position
final position = _positionTracker.currentPosition;
@@ -4556,7 +4789,6 @@ class MusicAssistantProvider with ChangeNotifier {
return;
}
// Sendspin PCM player - continue to start timer
_logger.debug('🔔 Starting notification timer for Sendspin PCM player');
}
// Only run timer if player is playing
@@ -4599,25 +4831,37 @@ class MusicAssistantProvider with ChangeNotifier {
}
final artworkUrl = _api?.getImageUrl(track, size: 512);
final artistWithPlayer = track.artistsString.isNotEmpty
? '${track.artistsString} • ${_selectedPlayer!.name}'
: _selectedPlayer!.name;
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '${displayArtist} • ${_selectedPlayer!.name}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
artUri: _contentArtUri(artworkUrl),
);
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: isPlaying,
position: position,
duration: track.duration,
);
// Use updateLocalModeNotification for builtin players to avoid
// conflicting mediaItem.add() calls with _updatePlayerState()
final isBuiltinPlayer = _sendspinConnected && _pcmAudioPlayer != null;
if (isBuiltinPlayer) {
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: isPlaying,
duration: track.duration,
position: position,
);
} else {
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: isPlaying,
position: position,
duration: track.duration,
);
}
}
Future<void> _preloadAdjacentPlayers({bool preloadAll = false}) async {
@@ -4988,7 +5232,7 @@ class MusicAssistantProvider with ChangeNotifier {
final isPlayingOrPaused = _selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused';
final isIdleWithContent = _selectedPlayer!.state == 'idle' && _selectedPlayer!.powered;
final shouldShowTrack = _selectedPlayer!.available && (isPlayingOrPaused || isIdleWithContent);
final shouldShowTrack = _selectedPlayer!.available && (isPlayingOrPaused || isIdleWithContent || isPcmPlaying);
if (!shouldShowTrack) {
if (_currentTrack != null) {
@@ -5032,6 +5276,10 @@ class MusicAssistantProvider with ChangeNotifier {
}
if (queue != null && queue.currentItem != null) {
if (queue.currentIndex != null) {
audioHandler.updateQueueIndex(queue.currentIndex!);
}
final queueTrack = queue.currentItem!.track;
final trackChanged = _currentTrack == null ||
_currentTrack!.uri != queueTrack.uri ||
@@ -5123,20 +5371,19 @@ class MusicAssistantProvider with ChangeNotifier {
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: 512);
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
final isBuiltinPlayer = builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId;
final isBuiltinPlayer = builtinPlayerId != null && _isBuiltinPlayer(_selectedPlayer!.playerId, builtinPlayerId);
if (isBuiltinPlayer) {
// Local playback - use local mode notification (keeps pause working)
final artistWithPlayer = track.artistsString.isNotEmpty
? '${track.artistsString} • ${_selectedPlayer!.name}'
: _selectedPlayer!.name;
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '$displayArtist • ${_selectedPlayer!.name}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
artUri: _contentArtUri(artworkUrl),
);
// Position comes from actual player in updateLocalModeNotification
audioHandler.updateLocalModeNotification(
@@ -5147,16 +5394,15 @@ class MusicAssistantProvider with ChangeNotifier {
} else {
// Remote MA player - show notification via remote mode
// Include player name in artist line: "Artist • Player Name"
final artistWithPlayer = track.artistsString.isNotEmpty
? '${track.artistsString} • ${_selectedPlayer!.name}'
: _selectedPlayer!.name;
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '$displayArtist • ${_selectedPlayer!.name}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
artUri: _contentArtUri(artworkUrl),
);
// Use position tracker for consistent position (single source of truth)
final position = _positionTracker.currentPosition;
@@ -5171,7 +5417,7 @@ class MusicAssistantProvider with ChangeNotifier {
} else {
// No queue data available from API
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
final isBuiltinPlayer = builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId;
final isBuiltinPlayer = builtinPlayerId != null && _isBuiltinPlayer(_selectedPlayer!.playerId, builtinPlayerId);
// For builtin player, try to preserve cached track data since queue API doesn't return it
// The cached track was set in selectPlayer() and contains valid data
@@ -5222,9 +5468,9 @@ class MusicAssistantProvider with ChangeNotifier {
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: track.artistsString,
artist: _trackDisplayArtist(track),
album: track.album?.name,
artUri: _api != null ? Uri.tryParse(_api!.getImageUrl(track, size: 512) ?? '') : null,
artUri: _contentArtUri(_api?.getImageUrl(track, size: 512)),
duration: track.duration,
);
audioHandler.updateLocalModeNotification(
@@ -5259,15 +5505,23 @@ class MusicAssistantProvider with ChangeNotifier {
// For pause: Don't refresh - we already did optimistic UI update
// The player_updated event from MA will handle final state sync
} else {
// If player is idle with no active queue but has a last-known track, re-queue it
final isIdle = _selectedPlayer!.state == 'idle';
final lastKnown = _cacheService.getLastKnownTrack(_selectedPlayer!.playerId);
if (isIdle && lastKnown != null) {
_logger.log('🔄 Player idle with last-known track - re-queuing "${lastKnown.name}"');
await playTrack(_selectedPlayer!.playerId, lastKnown);
} else {
await resumePlayer(_selectedPlayer!.playerId);
// Always try resume first — even if player reports "idle", the server
// queue may still be intact (e.g., Sendspin sends "stopped" on pause).
// Only fall back to re-queuing if resume fails.
final playerId = _selectedPlayer!.playerId;
await resumePlayer(playerId);
// Sendspin PCM streaming has ~5s buffering delay on resume.
// For audiobooks, seek back to compensate so the user doesn't miss narration.
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && _isBuiltinPlayer(playerId, builtinPlayerId) && _sendspinConnected && isPlayingAudiobook) {
final pos = _positionTracker.currentPosition.inSeconds;
if (pos > 10) {
_logger.log('📚 Seeking back 10s to compensate for Sendspin buffering');
unawaited(seek(playerId, pos - 10));
}
}
// For resume: Refresh in background to get updated track info
unawaited(refreshPlayers());
}
@@ -5375,6 +5629,9 @@ class MusicAssistantProvider with ChangeNotifier {
_syncLibraryStatusToService();
notifyListeners();
_prefetchAlbumImages();
audioHandler.invalidateAutoChildren(const [
'cat|artists', 'cat|albums', 'cat|playlists', 'cat|home',
]);
}
syncService.removeListener(onSyncComplete);
}
@@ -5730,7 +5987,6 @@ class MusicAssistantProvider with ChangeNotifier {
final itemJsonList = queue.items.map((item) => jsonEncode(item.toJson())).toList();
await DatabaseService.instance.saveQueue(playerId, itemJsonList);
_logger.debug('💾 Persisted ${queue.items.length} queue items to database');
} catch (e) {
_logger.log('⚠️ Error persisting queue to database: $e');
}
@@ -5795,11 +6051,16 @@ class MusicAssistantProvider with ChangeNotifier {
try {
await _api?.playRadio(playerId, track);
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Play radio');
_error = errorInfo.userMessage;
ErrorHandler.logError('Play radio', e);
notifyListeners();
rethrow;
_logger.log('Radio failed, falling back to single track: $e');
try {
await playTrack(playerId, track);
} catch (fallbackError) {
final errorInfo = ErrorHandler.handleError(fallbackError, context: 'Play radio fallback');
_error = errorInfo.userMessage;
ErrorHandler.logError('Play radio fallback', fallbackError);
notifyListeners();
rethrow;
}
}
}
@@ -5807,11 +6068,21 @@ class MusicAssistantProvider with ChangeNotifier {
try {
await _api?.playArtistRadio(playerId, artist);
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Play artist radio');
_error = errorInfo.userMessage;
ErrorHandler.logError('Play artist radio', e);
notifyListeners();
rethrow;
_logger.log('Artist radio failed, falling back to shuffled tracks: $e');
try {
final results = await searchWithCache(artist.name);
final tracks = (results['tracks'] ?? []).whereType<Track>().toList();
if (tracks.isEmpty) rethrow;
tracks.shuffle(Random());
await _api?.playTracks(playerId, tracks, startIndex: 0);
await _api?.toggleShuffle(playerId, true);
} catch (fallbackError) {
final errorInfo = ErrorHandler.handleError(fallbackError, context: 'Play artist radio fallback');
_error = errorInfo.userMessage;
ErrorHandler.logError('Play artist radio fallback', fallbackError);
notifyListeners();
rethrow;
}
}
}
@@ -5824,7 +6095,7 @@ class MusicAssistantProvider with ChangeNotifier {
// Get builtin player ID - this is cached so should be fast
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) {
if (builtinPlayerId != null && _isBuiltinPlayer(playerId, builtinPlayerId) && _sendspinConnected) {
_logger.log('⏸️ Non-blocking local pause for builtin player');
// CRITICAL: Don't await these - they can block the UI thread
@@ -5926,7 +6197,7 @@ class MusicAssistantProvider with ChangeNotifier {
try {
// Optimistic local stop for builtin player on skip - non-blocking
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) {
if (builtinPlayerId != null && _isBuiltinPlayer(playerId, builtinPlayerId) && _sendspinConnected) {
_logger.log('⏭️ Non-blocking local stop for skip on builtin player');
// Stop current audio immediately - fire and forget, but log errors
unawaited((_pcmAudioPlayer?.pause() ?? Future.value()).catchError(
@@ -5945,7 +6216,7 @@ class MusicAssistantProvider with ChangeNotifier {
try {
// Optimistic local stop for builtin player on previous - non-blocking
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) {
if (builtinPlayerId != null && _isBuiltinPlayer(playerId, builtinPlayerId) && _sendspinConnected) {
_logger.log('⏮️ Non-blocking local stop for previous on builtin player');
// Stop current audio immediately - fire and forget, but log errors
unawaited((_pcmAudioPlayer?.pause() ?? Future.value()).catchError(
@@ -6359,7 +6630,7 @@ class MusicAssistantProvider with ChangeNotifier {
}
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && playerId == builtinPlayerId) {
if (builtinPlayerId != null && _isBuiltinPlayer(playerId, builtinPlayerId)) {
_localPlayerVolume = volumeLevel;
FlutterVolumeController.setVolume(volumeLevel / 100.0);
}
+20 -22
View File
@@ -430,29 +430,28 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
),
),
),
// Bottom fade gradient to mask content behind mini player
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.miniPlayerHeight + 22.0,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.background.withOpacity(0.0),
colorScheme.background.withOpacity(0.25),
colorScheme.background.withOpacity(0.85),
],
stops: const [0.0, 0.5, 1.0],
// Bottom fade gradient to mask content behind mini player (4+ rows only)
if (_countEnabledRows() >= 4)
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.miniPlayerHeight,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.background.withOpacity(0.0),
colorScheme.background.withOpacity(0.85),
],
),
),
),
),
),
),
],
);
},
@@ -503,9 +502,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
builder: (context, constraints) {
// Each row is always 1/3 of screen height
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll
// With extendBody: true, constraints include the area behind the nav bar.
// Subtract mini player overlay space (mini player + its margins sit above the nav bar).
final miniPlayerSpace = BottomSpacing.miniPlayerHeight + 22.0; // 84 + 22 = 106
// Reserve space for the mini player overlay at the bottom.
final miniPlayerSpace = BottomSpacing.miniPlayerHeight + 22.0;
final availableHeight = constraints.maxHeight - miniPlayerSpace;
// Account for margins between rows (2px each, 2 margins for 3 rows).
+33 -35
View File
@@ -159,10 +159,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// PERF: Separate ValueNotifier for efficient UI updates (RestorableInt doesn't implement ValueListenable)
final ValueNotifier<int> _tabIndexNotifier = ValueNotifier<int>(0);
// Scroll-to-hide filter bars
bool _isFilterBarVisible = true;
double _lastScrollOffset = 0;
static const double _scrollThreshold = 10.0;
// Scroll-to-hide filter bars (0.0 = hidden, 1.0 = visible)
final ValueNotifier<double> _filterBarVisibility = ValueNotifier<double>(1.0);
bool _isLetterScrollbarDragging = false; // Disable scroll-to-hide while dragging
// Options menu overlay - stored to close on navigation
@@ -859,17 +857,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
if (notification is ScrollUpdateNotification) {
final currentOffset = notification.metrics.pixels;
final delta = currentOffset - _lastScrollOffset;
if (delta.abs() > _scrollThreshold) {
final shouldShow = delta < 0 || currentOffset <= 0;
if (shouldShow != _isFilterBarVisible) {
setState(() {
_isFilterBarVisible = shouldShow;
});
}
_lastScrollOffset = currentOffset;
final delta = notification.scrollDelta ?? 0.0;
if (notification.metrics.pixels <= 0) {
_filterBarVisibility.value = 1.0;
} else {
_filterBarVisibility.value = (_filterBarVisibility.value - delta / _filterRowHeight).clamp(0.0, 1.0);
}
}
return false;
@@ -911,13 +903,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
void _onLetterScrollbarDragChanged(bool isDragging) {
setState(() {
_isLetterScrollbarDragging = isDragging;
// Show the filter bar when starting to drag
if (isDragging) {
_isFilterBarVisible = true;
}
});
_isLetterScrollbarDragging = isDragging;
// Show the filter bar when starting to drag
if (isDragging) {
_filterBarVisibility.value = 1.0;
}
}
void _animateToCategory(int index) {
@@ -942,6 +932,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
_pageController.dispose();
_selectedTabIndex.dispose();
_tabIndexNotifier.dispose();
_filterBarVisibility.dispose();
_artistsScrollController.dispose();
_albumsScrollController.dispose();
_playlistsScrollController.dispose();
@@ -1449,7 +1440,12 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: Column(
children: [
// Two-row filter: Row 1 = Media types (hides on scroll), Row 2 = Sub-categories (always visible)
_buildFilterRows(colorScheme, l10n, showLibraryTypeRow: _isFilterBarVisible),
ValueListenableBuilder<double>(
valueListenable: _filterBarVisibility,
builder: (context, visibility, _) {
return _buildFilterRows(colorScheme, l10n, barVisibility: visibility);
},
),
// Connecting banner when showing cached data
// Hide when we have cached players - UI is functional during background reconnect
if (!isConnected && syncService.hasCache && !context.read<MusicAssistantProvider>().hasCachedPlayers)
@@ -1546,23 +1542,25 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// Consistent height for filter rows
static const double _filterRowHeight = 36.0;
Widget _buildFilterRows(ColorScheme colorScheme, S l10n, {required bool showLibraryTypeRow}) {
Widget _buildFilterRows(ColorScheme colorScheme, S l10n, {required double barVisibility}) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Row 1: Media type chips (hides when scrolling)
AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
height: showLibraryTypeRow ? _filterRowHeight : 0,
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: _buildMediaTypeChips(colorScheme, l10n),
ClipRect(
child: Align(
alignment: Alignment.bottomCenter,
heightFactor: barVisibility,
child: SizedBox(
height: _filterRowHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: _buildMediaTypeChips(colorScheme, l10n),
),
),
),
),
if (showLibraryTypeRow) const SizedBox(height: 12), // Space between rows
SizedBox(height: 12 * barVisibility), // Space between rows
// Row 2: Sub-category chips (left) + action buttons (right) - always visible
SizedBox(
height: _filterRowHeight,
@@ -4914,7 +4912,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
try {
// Start radio from this track
await maProvider.api?.playRadio(player.playerId, track);
await maProvider.playRadio(player.playerId, track);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
+28 -29
View File
@@ -130,10 +130,8 @@ class SearchScreenState extends State<SearchScreen> {
// Advanced search scorer with fuzzy matching, stopword removal, etc.
final SearchScorer _searchScorer = SearchScorer();
// Scroll-to-hide search bar (vertical scroll only)
bool _isSearchBarVisible = true;
double _lastVerticalScrollOffset = 0;
static const double _scrollThreshold = 10.0;
// Scroll-to-hide search bar (0.0 = hidden, 1.0 = visible)
final ValueNotifier<double> _searchBarVisibility = ValueNotifier<double>(1.0);
// Filter chip position tracking for animated sliding highlight
final Map<String, GlobalKey> _filterKeys = {};
@@ -314,10 +312,7 @@ class SearchScreenState extends State<SearchScreen> {
void requestFocus() {
if (mounted) {
setState(() {
_isSearchBarVisible = true;
_lastVerticalScrollOffset = 0;
});
_searchBarVisibility.value = 1.0;
_focusNode.requestFocus();
}
}
@@ -336,6 +331,7 @@ class SearchScreenState extends State<SearchScreen> {
_pageController.dispose();
_filterScrollController.dispose();
_activeFilterNotifier.dispose();
_searchBarVisibility.dispose();
super.dispose();
}
@@ -347,17 +343,12 @@ class SearchScreenState extends State<SearchScreen> {
}
if (notification is ScrollUpdateNotification) {
final currentOffset = notification.metrics.pixels;
final delta = currentOffset - _lastVerticalScrollOffset;
if (delta.abs() > _scrollThreshold) {
final shouldShow = delta < 0 || currentOffset <= 0;
if (shouldShow != _isSearchBarVisible) {
setState(() {
_isSearchBarVisible = shouldShow;
});
}
_lastVerticalScrollOffset = currentOffset;
final delta = notification.scrollDelta ?? 0.0;
const maxHeight = kToolbarHeight + 16;
if (notification.metrics.pixels <= 0) {
_searchBarVisibility.value = 1.0;
} else {
_searchBarVisibility.value = (_searchBarVisibility.value - delta / maxHeight).clamp(0.0, 1.0);
}
}
return false;
@@ -534,16 +525,23 @@ class SearchScreenState extends State<SearchScreen> {
body: SafeArea(
child: Column(
children: [
// Animated search bar - hides on scroll down
AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
height: _isSearchBarVisible ? kToolbarHeight + 16 : 0,
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
// Search bar - slides away with scroll
ValueListenableBuilder<double>(
valueListenable: _searchBarVisibility,
builder: (context, visibility, child) {
return ClipRect(
child: Align(
alignment: Alignment.bottomCenter,
heightFactor: visibility,
child: child!,
),
);
},
child: SizedBox(
height: kToolbarHeight + 16,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
// Search field container
Expanded(
@@ -647,6 +645,7 @@ class SearchScreenState extends State<SearchScreen> {
],
),
),
),
),
// Main content
Expanded(
File diff suppressed because it is too large Load Diff
+12 -6
View File
@@ -68,6 +68,14 @@ class MusicAssistantAPI {
MusicAssistantAPI(this.serverUrl, this.authManager);
/// Check if a URL string has an explicit port (e.g., ":80", ":8095").
/// Dart's Uri.hasPort returns false for default ports (80/443) even when
/// explicitly specified, so we check the raw string instead.
bool _hasExplicitPort(String url) {
final withoutScheme = url.replaceFirst(RegExp(r'^https?://'), '');
return RegExp(r'^[^/:]+:\d+').hasMatch(withoutScheme);
}
// Guard to prevent multiple simultaneous connection attempts
Completer<void>? _connectionInProgress;
bool _isDisposed = false;
@@ -145,7 +153,7 @@ class MusicAssistantAPI {
queryParameters: {'client_id': clientId},
);
_logger.log('Using custom WebSocket port from settings: $_cachedCustomPort');
} else if (uri.hasPort) {
} else if (_hasExplicitPort(serverUrl)) {
// Port is already specified in URL, keep it
finalUri = Uri(
scheme: uri.scheme,
@@ -251,7 +259,6 @@ class MusicAssistantAPI {
void _handleMessage(dynamic message) {
try {
final data = jsonDecode(message as String) as Map<String, dynamic>;
_logger.debug('Received message: ${data.keys}');
// Check for server info message (first message on connect)
if (data.containsKey('server_version')) {
@@ -1981,7 +1988,6 @@ class MusicAssistantAPI {
currentIndex = queueResult['current_index'] as int?;
shuffleEnabled = queueResult['shuffle_enabled'] as bool?;
repeatMode = queueResult['repeat_mode'] as String?;
_logger.debug('🔀 Queue metadata: shuffle_enabled=$shuffleEnabled, repeat_mode=$repeatMode');
final currentItemData = queueResult['current_item'] as Map<String, dynamic>?;
final currentItemName = currentItemData?['name'] as String?;
@@ -2484,7 +2490,7 @@ class MusicAssistantAPI {
port: _cachedCustomPort,
path: '/flow/$playerId/$streamId.$extension',
);
} else if (uri.hasPort) {
} else if (_hasExplicitPort(serverUrl)) {
// Use port from URL
finalUri = Uri(
scheme: uri.scheme,
@@ -3196,7 +3202,7 @@ class MusicAssistantAPI {
if (_cachedCustomPort != null) {
// Use cached custom port from settings
baseUrl = '${uriObj.scheme}://${uriObj.host}:$_cachedCustomPort';
} else if (!uriObj.hasPort) {
} else if (!_hasExplicitPort(serverUrl)) {
if (useSecure) {
// For HTTPS, don't add port 443 (it's the default)
baseUrl = '${uriObj.scheme}://${uriObj.host}';
@@ -3303,7 +3309,7 @@ class MusicAssistantAPI {
if (_cachedCustomPort != null) {
// Use cached custom port from settings
baseUrl = '${uri.scheme}://${uri.host}:$_cachedCustomPort';
} else if (!uri.hasPort) {
} else if (!_hasExplicitPort(serverUrl)) {
if (useSecure) {
// For HTTPS, don't add port 443 (it's the default)
baseUrl = '${uri.scheme}://${uri.host}';
+6 -1
View File
@@ -69,6 +69,7 @@ class PcmAudioPlayer {
bool _isFeeding = false;
bool _isStarted = false;
bool _isAutoRecovering = false; // Tracks auto-recovery from setup errors
bool _userPaused = false; // True when pause is user-initiated (not transitional)
Completer<void>? _feedCompleter; // Tracks when current feed operation completes
// Error callback for operation failures
@@ -235,7 +236,9 @@ class PcmAudioPlayer {
if (_shouldBlockFeeding) {
// If we're paused and audio is arriving, this is likely a new stream
// starting before the stream/start message. Queue for auto-recovery.
if (_state == PcmPlayerState.paused && !_isAutoRecovering) {
// BUT: don't auto-recover if the user explicitly paused — the arriving
// audio is just the tail end of the previous stream.
if (_state == PcmPlayerState.paused && !_isAutoRecovering && !_userPaused) {
_logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery');
_isAutoRecovering = true;
_addToBuffer(audioData);
@@ -492,6 +495,7 @@ class PcmAudioPlayer {
if (_state == PcmPlayerState.paused) {
// Resume from pause - need to re-initialize since we released on pause
_userPaused = false;
_logger.log('PcmAudioPlayer: Resuming from pause at ${elapsedTime.inSeconds}s');
_state = PcmPlayerState.resuming;
@@ -547,6 +551,7 @@ class PcmAudioPlayer {
}
_logger.log('PcmAudioPlayer: Pause requested');
_userPaused = true;
// Set pausing state FIRST to stop feed loop from starting new feeds
_state = PcmPlayerState.pausing;
+1 -22
View File
@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'debug_logger.dart';
/// Single source of truth for playback position tracking.
///
@@ -13,8 +12,6 @@ import 'debug_logger.dart';
/// All consumers (UI, notification) should listen to [positionStream]
/// instead of calculating position themselves.
class PositionTracker {
final DebugLogger _logger = DebugLogger();
// Current state
String? _playerId;
bool _isPlaying = false;
@@ -100,7 +97,6 @@ class PositionTracker {
Duration? duration,
double? serverTimestamp,
}) {
_logger.debug('⏱️ PositionTracker.updateFromServer: pos=${position.toStringAsFixed(1)}s, isPlaying=$isPlaying, playerId=$playerId');
final bool playerChanged = _playerId != playerId;
final bool playStateChanged = _isPlaying != isPlaying;
final bool durationChanged = duration != null && _duration != duration;
@@ -179,11 +175,8 @@ class PositionTracker {
final newAnchorPos = anchorIsGettingStale && !playerChanged && !playStateChanged && isPlaying
? currentInterpolated // Keep our interpolated position, just refresh the anchor time
: anchorPos; // Use server position for other cases
_logger.debug('⏱️ PositionTracker: Anchor updated ${_anchorPosition.toStringAsFixed(1)}s -> ${newAnchorPos.toStringAsFixed(1)}s (playerChanged=$playerChanged, playStateChanged=$playStateChanged, positionDiff=${positionDiff.toStringAsFixed(1)}, suspiciousReset=$isSuspiciousReset, anchorStale=$anchorIsGettingStale)');
_anchorPosition = newAnchorPos;
_anchorTime = DateTime.now();
} else if (positionDiff > 2) {
_logger.debug('⏱️ PositionTracker: Anchor NOT updated (suspiciousReset=$isSuspiciousReset, staleTimestamp=$hasStaleTimestamp, interpolated=${currentInterpolated.toStringAsFixed(1)}s)');
}
_isPlaying = isPlaying;
@@ -227,7 +220,6 @@ class PositionTracker {
/// Clear tracker state (e.g., when disconnecting)
void clear() {
_logger.debug('⏱️ PositionTracker.clear() called');
_stopInterpolationTimer();
_playerId = null;
_isPlaying = false;
@@ -237,20 +229,9 @@ class PositionTracker {
}
void _startInterpolationTimer() {
if (_interpolationTimer != null) {
_logger.debug('⏱️ PositionTracker: Timer already running, skipping start');
return;
}
if (_interpolationTimer != null) return;
_logger.debug('⏱️ PositionTracker: Starting interpolation timer (anchor=${_anchorPosition.toStringAsFixed(1)}s)');
_interpolationTimer = Timer.periodic(const Duration(milliseconds: 250), (timer) {
// Log every 4 ticks (1 second) if no emission happened
if (timer.tick % 4 == 0) {
final pos = currentPosition;
if (pos.inSeconds == _lastEmittedSeconds) {
_logger.debug('⏱️ PositionTracker: Timer tick ${timer.tick}, pos=${pos.inSeconds}s unchanged');
}
}
_emitPosition();
});
@@ -259,7 +240,6 @@ class PositionTracker {
}
void _stopInterpolationTimer() {
_logger.debug('⏱️ PositionTracker: Stopping interpolation timer (was ${_interpolationTimer != null ? "running" : "null"})');
_interpolationTimer?.cancel();
_interpolationTimer = null;
}
@@ -271,7 +251,6 @@ class PositionTracker {
// Only emit if second changed (reduces unnecessary updates)
if (seconds != _lastEmittedSeconds) {
_lastEmittedSeconds = seconds;
_logger.debug('⏱️ PositionTracker: Emitting position ${seconds}s (anchor=${_anchorPosition.toStringAsFixed(1)}s, playing=$_isPlaying)');
_positionController.add(pos);
}
}
-2
View File
@@ -321,8 +321,6 @@ class SendspinService {
final data = jsonDecode(message) as Map<String, dynamic>;
final type = data['type'] as String?;
_logger.log('Sendspin: Received message type: $type');
switch (type) {
case 'server/hello':
// Server acknowledged our client/hello - we're registered!
+7 -2
View File
@@ -81,6 +81,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
String? _lastImageUrl;
int _colorExtractionId = 0;
// Queue state
PlayerQueue? _queue;
@@ -757,13 +758,17 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
Future<void> _extractColors(String imageUrl) async {
if (_lastImageUrl == imageUrl) return;
_lastImageUrl = imageUrl;
final extractionId = ++_colorExtractionId;
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
if (colorSchemes != null && mounted) {
if (!mounted) return;
if (extractionId != _colorExtractionId) return;
if (colorSchemes != null) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
@@ -2056,7 +2061,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
: colorScheme.primaryContainer;
// Create a darker shade for the "unplayed" portion of progress bar
final collapsedBgUnplayed = Color.lerp(collapsedBg, Colors.black, 0.3)!;
final expandedBg = adaptiveScheme?.surface ?? const Color(0xFF121212);
final expandedBg = adaptiveScheme?.surface ?? colorScheme.surface;
final expandedPrimary = adaptiveScheme?.primary;
// Always update to current value - don't preserve stale adaptive colors
_currentExpandedBgColor = expandedBg;
+2 -2
View File
@@ -403,7 +403,7 @@ class _MediaContextMenuOverlayState extends State<_MediaContextMenuOverlay>
switch (widget.mediaType) {
case ContextMenuMediaType.artist:
final artist = widget.item as Artist;
await maProvider.api?.playArtistRadio(player.playerId, artist);
await maProvider.playArtistRadio(player.playerId, artist);
break;
case ContextMenuMediaType.track:
final track = widget.item as Track;
@@ -437,7 +437,7 @@ class _MediaContextMenuOverlayState extends State<_MediaContextMenuOverlay>
switch (widget.mediaType) {
case ContextMenuMediaType.artist:
final artist = widget.item as Artist;
await maProvider.api?.playArtistRadio(player.playerId, artist);
await maProvider.playArtistRadio(player.playerId, artist);
break;
case ContextMenuMediaType.track:
final track = widget.item as Track;
+1
View File
@@ -238,6 +238,7 @@ class _VolumeControlState extends State<VolumeControl> {
children: [
// The slider with gesture detection for precision mode
GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (details) {
setState(() {
_isDragging = true;
+1 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 3.0.5+57
version: 3.0.4+56
environment:
sdk: '>=3.0.0 <4.0.0'