Compare commits

..
5 Commits
Author SHA1 Message Date
Chris f7fe6dfbbe Add MA_BETA14_IMPACT.md 2026-08-25 21:41:35 +01:00
Chris cb07e55d69 Merge: per-ABI versionCode override for F-Droid 2026-05-05 06:49:22 +01:00
Chris cf0336e99d Bump version to 3.0.5+57 2026-05-05 06:45:09 +01:00
ChrisandClaude Opus 4.7 73fd1dbdcd Add per-ABI versionCode override for F-Droid split builds
Required by F-Droid for Flutter apps using --split-per-abi: each
per-ABI APK gets a unique versionCode = base * 10 + abiCode
(armeabi-v7a=1, arm64-v8a=2, x86_64=3).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 06:08:46 +01:00
Chris 2eb22cf524 Add new sponsor to README
Added a sponsor section with a new sponsor link.
2026-02-24 15:28:20 +00:00
27 changed files with 533 additions and 1531 deletions
+167
View File
@@ -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
+1
View File
@@ -175,6 +175,7 @@ For access outside your home network, you'll need to expose Music Assistant thro
A huge thank you to my sponsors! 💖 A 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
+10
View File
@@ -77,6 +77,16 @@ android {
} }
} }
def abiCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86_64': 3]
android.applicationVariants.configureEach { variant ->
variant.outputs.each { output ->
def abiVersionCode = abiCodes[output.getFilter('ABI')]
if (abiVersionCode != null) {
output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode
}
}
}
flutter { flutter {
source '../..' source '../..'
} }
-6
View File
@@ -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>
-3
View File
@@ -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');
+90 -361
View File
@@ -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,29 +885,13 @@ 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,
);
if (_selectedPlayer != null) {
_logger.log('📦 Restored selected player from database: ${_selectedPlayer?.name}');
}
}
// 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); _cacheService.setCachedSelectedPlayer(_selectedPlayer);
_logger.log('📦 Restored selected player from database: ${_selectedPlayer?.name}');
} catch (_) {
// Player not in cached list
}
} }
_logger.log('📦 Loaded ${players.length} players from database (instant)'); _logger.log('📦 Loaded ${players.length} players from database (instant)');
@@ -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,9 +1953,6 @@ class MusicAssistantProvider with ChangeNotifier {
return; return;
} }
_reconnectInProgress = Completer<void>();
try {
// IMMEDIATELY load cached players for instant UI display // IMMEDIATELY load cached players for instant UI display
// This makes mini player and device button appear instantly on app resume // This makes mini player and device button appear instantly on app resume
if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) { if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) {
@@ -2056,27 +1968,15 @@ class MusicAssistantProvider with ChangeNotifier {
if (_selectedPlayer == null && _availablePlayers.isNotEmpty) { if (_selectedPlayer == null && _availablePlayers.isNotEmpty) {
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId(); final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
if (lastSelectedPlayerId != null) { if (lastSelectedPlayerId != null) {
_selectedPlayer = _availablePlayers.cast<Player?>().firstWhere( try {
(p) => p!.playerId == lastSelectedPlayerId, _selectedPlayer = _availablePlayers.firstWhere(
orElse: () => null, (p) => p.playerId == lastSelectedPlayerId,
); );
} } catch (e) {
// 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; _selectedPlayer = _availablePlayers.first;
} }
} else {
_selectedPlayer = _availablePlayers.first;
} }
} }
_logger.log('⚡ Loaded ${_availablePlayers.length} cached players instantly (sorted)'); _logger.log('⚡ Loaded ${_availablePlayers.length} cached players instantly (sorted)');
@@ -2108,13 +2008,6 @@ class MusicAssistantProvider with ChangeNotifier {
} }
} }
} }
_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.
_cancelIdleServiceTimer();
if (_currentTrack != null) {
final track = _currentTrack!;
final trackArtworkUrl = _api?.getImageUrl(track, size: 512);
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '${displayArtist} • ${_selectedPlayer?.name ?? ''}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(trackArtworkUrl),
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: true,
duration: track.duration,
position: _positionTracker.currentPosition,
);
} else {
// No track data yet — use stream metadata as fallback
final mediaItem = audio_service.MediaItem( final mediaItem = audio_service.MediaItem(
id: 'sendspin_pcm_stream', id: 'sendspin_pcm_stream',
title: title ?? 'Playing via Sendspin', title: title ?? 'Playing via Sendspin',
artist: artist ?? 'Music Assistant', artist: artist ?? 'Music Assistant',
album: album, album: album,
artUri: _contentArtUri(artworkUrl), artUri: artworkUrl != null ? Uri.parse(artworkUrl) : null,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null, duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
); );
audioHandler.updateLocalModeNotification(
// Initialize notification with position 0
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem, item: mediaItem,
playing: true, playing: true,
position: Duration.zero,
duration: mediaItem.duration, 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,47 +2547,28 @@ 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
_cancelIdleServiceTimer();
if (_currentTrack != null) {
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: 512);
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '${displayArtist} • ${_selectedPlayer?.name ?? ''}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(artworkUrl),
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: false,
duration: track.duration,
position: lastPosition,
);
} else {
final metadata = _currentNotificationMetadata; final metadata = _currentNotificationMetadata;
final mediaItem = audio_service.MediaItem( final mediaItem = audio_service.MediaItem(
id: 'sendspin_pcm_stream', id: 'sendspin_pcm_stream',
title: metadata?.title ?? 'Music Assistant', title: metadata?.title ?? 'Music Assistant',
artist: metadata?.artist ?? 'Paused', artist: metadata?.artist ?? 'Paused',
album: metadata?.album, album: metadata?.album,
artUri: _contentArtUri(metadata?.artworkUrl), artUri: metadata?.artworkUrl != null ? Uri.parse(metadata!.artworkUrl!) : null,
duration: metadata?.duration, duration: metadata?.duration,
); );
audioHandler.updateLocalModeNotification(
// Show paused state with preserved position
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem, item: mediaItem,
playing: false, playing: false,
duration: mediaItem.duration,
position: lastPosition, position: lastPosition,
duration: mediaItem.duration,
); );
} }
}
void _startReportingLocalPlayerState() { void _startReportingLocalPlayerState() {
_localPlayerStateReportTimer?.cancel(); _localPlayerStateReportTimer?.cancel();
@@ -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
// are likely due to SyncService not having finished yet
if (allAlbums.isNotEmpty) {
_cacheService.setCachedArtistAlbums(cacheKey, allAlbums); _cacheService.setCachedArtistAlbums(cacheKey, allAlbums);
}
_logger.log('✅ Cached ${allAlbums.length} albums for artist "$artistName"'); _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}'); _logger.log('📱 Selected local player: ${playerToSelect.name}');
} else { } catch (e) {
// Player exists but not yet available (Sendspin registration in progress) _logger.log('⚠️ Local player not available');
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 (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,30 +4599,19 @@ 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();
// 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( audioHandler.setRemotePlaybackState(
item: mediaItem, item: mediaItem,
playing: isPlaying, playing: isPlaying,
@@ -4862,7 +4619,6 @@ class MusicAssistantProvider with ChangeNotifier {
duration: track.duration, duration: track.duration,
); );
} }
}
Future<void> _preloadAdjacentPlayers({bool preloadAll = false}) async { Future<void> _preloadAdjacentPlayers({bool preloadAll = false}) async {
if (_api == null) return; if (_api == null) return;
@@ -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,40 +5795,25 @@ 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 {
await playTrack(playerId, track);
} catch (fallbackError) {
final errorInfo = ErrorHandler.handleError(fallbackError, context: 'Play radio fallback');
_error = errorInfo.userMessage; _error = errorInfo.userMessage;
ErrorHandler.logError('Play radio fallback', fallbackError); ErrorHandler.logError('Play radio', e);
notifyListeners(); notifyListeners();
rethrow; rethrow;
} }
} }
}
Future<void> playArtistRadio(String playerId, Artist artist) async { Future<void> playArtistRadio(String playerId, Artist artist) async {
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 {
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; _error = errorInfo.userMessage;
ErrorHandler.logError('Play artist radio fallback', fallbackError); ErrorHandler.logError('Play artist radio', e);
notifyListeners(); notifyListeners();
rethrow; rethrow;
} }
} }
}
Future<String?> getCurrentStreamUrl(String playerId) async { Future<String?> getCurrentStreamUrl(String playerId) async {
return await _api?.getCurrentStreamUrl(playerId); return await _api?.getCurrentStreamUrl(playerId);
@@ -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);
} }
+7 -5
View File
@@ -430,13 +430,12 @@ 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, height: BottomSpacing.miniPlayerHeight + 22.0,
child: IgnorePointer( child: IgnorePointer(
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -445,8 +444,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
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).
+28 -26
View File
@@ -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) {
setState(() {
_isLetterScrollbarDragging = isDragging; _isLetterScrollbarDragging = isDragging;
// Show the filter bar when starting to drag // Show the filter bar when starting to drag
if (isDragging) { if (isDragging) {
_filterBarVisibility.value = 1.0; _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),
), ),
), ),
), 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 // 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(
+26 -25
View File
@@ -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,20 +534,13 @@ 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: child!,
),
);
},
child: SizedBox(
height: kToolbarHeight + 16,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row( child: Row(
@@ -646,7 +648,6 @@ class SearchScreenState extends State<SearchScreen> {
), ),
), ),
), ),
),
// Main content // Main content
Expanded( Expanded(
child: !isConnected child: !isConnected
File diff suppressed because it is too large Load Diff
+6 -12
View File
@@ -68,14 +68,6 @@ class MusicAssistantAPI {
MusicAssistantAPI(this.serverUrl, this.authManager); 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}';
+1 -6
View File
@@ -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;
+22 -1
View File
@@ -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);
} }
} }
+2
View File
@@ -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!
+2 -7
View File
@@ -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;
+2 -2
View File
@@ -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;
-1
View File
@@ -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
View File
@@ -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'