Compare commits

...
31 Commits
Author SHA1 Message Date
Your NameandClaude Opus 4.5 985b55a814 chore: Bump version to 2.0.0-beta for release
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 19:35:33 +00:00
Your NameandClaude Opus 4.5 222d0a1312 fix(switch): Always include builtin player in notification switch
When playing on SendSpin or another player, the builtin/local player
was filtered out of the switch list because it wasn't in playing/paused
state. Now the builtin player (ensemble_*) is always included so users
can switch back to local device from the notification.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 19:19:38 +00:00
Your NameandClaude Opus 4.5 8f30341d2e refactor(pcm): Improve state machine and reduce buffer latency
Key improvements:
- Add proper state machine with transitional states (pausing, resuming, stopping)
- Reduce feed threshold from 8000 to 5000 frames (~104ms vs ~166ms)
- Reduce max chunks per feed from 12 to 8 (~200ms vs ~300ms)
- Add error callbacks (PcmErrorCallback) for operation failures
- Fix silent stream errors - now pause and emit error on stream failure
- Add release() timeout to prevent hangs
- play()/pause()/stop() now return bool for success/failure

Bug fixes:
- Fix sendspin_service.dart ternary always returning 'synchronized'
- Replace fragile _isPausePending flag with proper state machine

UI enhancements:
- Add public getters for Sendspin status in provider
- Add audio format info section in expanded player (48kHz • Stereo • 16-bit PCM)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 18:26:19 +00:00
Your NameandClaude Opus 4.5 a9376ad807 fix(pcm): Reduce native buffer for instant pause (300ms vs 5-8s)
Key changes:
- Limit feeding to 12 chunks (~300ms) per feed cycle instead of entire buffer
- Re-enable release() on pause with Future.delayed to avoid UI block
- Re-initialize PCM player on resume since we release on pause
- Make next/previous track non-blocking with unawaited pause

This should fix the 5-8 second delay when pressing pause/skip.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 14:04:32 +00:00
Your NameandClaude Opus 4.5 efc94b2ecf Fix resume: Call play() on stream/start to reset paused state
The pause fix (keeping _isPausePending=true) worked but broke resume.
When MA sends stream/start after resume, _onAudioData was ignoring
all audio because _isPausePending was still true.

Fix: Call _pcmAudioPlayer.play() in _handleSendspinStreamStart to:
- Clear _isPausePending flag
- Set state from paused to playing
- Allow _onAudioData to process incoming audio

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 12:42:17 +00:00
Your NameandClaude Opus 4.5 b43e026cfa Fix pause freeze: Yield to event loop in feed loop
Attempt #8: The feed loop while() was starving the event loop.
Even with await, if native calls return quickly, the loop runs
continuously without letting UI events (like pause) be processed.

Added: await Future.delayed(Duration.zero) every 5 chunks to force
the event loop to process pending events like button taps.

Previous attempts focused on pause() but the real issue was the
feed loop monopolizing the isolate during playback.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 12:29:20 +00:00
Your NameandClaude Opus 4.5 4e238e3475 Make pause truly non-blocking with optimistic UI update
Previous fix didn't work - the issue was multiple blocking await calls
in the pause chain, not just the PCM player.

Changes:
- pausePlayer(): Use unawaited() for PCM pause and API calls
- pausePlayer(): Don't pause just_audio when in Sendspin mode
- pausePlayer(): Optimistically update _selectedPlayer state and notify
- playPauseSelectedPlayer(): Don't call refreshPlayers() after pause
- Player model: Add copyWith() method for optimistic updates

The key insight is that we don't need to wait for pause operations
to complete before updating the UI. The UI should update immediately
(optimistic) and the actual operations run in the background.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 12:24:19 +00:00
Your NameandClaude Opus 4.5 c9d44a147a Fix pause freeze: Keep _isPausePending true during pause, wait for feed
Root cause analysis (deep dive with 4 agents) revealed:
1. _isPausePending was cleared immediately in pause(), allowing
   new feeds to start before pause was complete
2. Auto-resume in _onAudioData could trigger right after pause
3. _onFeedRequested could start new feeds during pause transition
4. No waiting for in-flight feed operations

Changes:
- Keep _isPausePending = true until play() is called
- Wait for in-flight feed operations with 100ms timeout
- Remove auto-resume from _onAudioData (provider calls play())
- Add explicit state check in _onFeedRequested callback
- Ignore all audio data when paused (don't even buffer)

This prevents the race condition where the feed loop could be
active during pause, causing FFI calls to block the UI thread.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 12:04:06 +00:00
Your NameandClaude Opus 4.5 58c2d3c8a2 Don't call release() on pause - just stop feeding
The native flutter_pcm_sound release() was causing deadlocks.
New approach:
- pause() only sets Dart flags and clears buffer (no native calls)
- Native buffer is small (~170ms at 8000 frame threshold)
- Audio stops naturally as buffer drains
- Player stays initialized for instant resume
- play() just changes state and resumes feeding

This eliminates all native call race conditions on pause.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 11:22:37 +00:00
Your NameandClaude Opus 4.5 944257cadb Move all native calls to Future.microtask to fully detach from UI
pause() now sets only Dart state and returns immediately.
All native calls (setFeedCallback, release) run in a microtask
that executes after pause() returns, ensuring UI never blocks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 11:21:37 +00:00
Your NameandClaude Opus 4.5 d7153e53ad Make pause non-blocking to prevent UI freeze
Instead of awaiting release() which can deadlock at native level,
now pause() returns immediately and schedules release asynchronously:

- pause() sets flags and clears buffer, returns instantly
- _releasePlayerAsync() handles cleanup in background
- 50ms delay before release to let feed loop exit
- UI thread is never blocked by native code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 11:07:45 +00:00
Your NameandClaude Opus 4.5 94c4724e54 Fix pause freeze with proper Completer-based synchronization
The issue was that pause() used a polling loop that could time out
while feed() was still executing, causing a freeze/deadlock. Now:

- Added Completer to track when feed operations complete
- pause/stop/dispose await the Completer with 500ms timeout
- Clear feed callback before release() to prevent native triggers
- Added more _isPausePending checks in feed loop

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 10:54:22 +00:00
Your NameandClaude Opus 4.5 2dc317f4d4 fix(sendspin): Fix pause crash by preventing race condition
- Added _isPausePending flag to stop feed operations during pause/stop
- Feed loop now checks pause flag and exits early when set
- Pause/stop wait for in-progress feed operations before releasing player
- Prevents FlutterPcmSound.release() being called while feed() is executing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 10:21:36 +00:00
Your NameandClaude Opus 4.5 8be1935eb3 fix(sendspin): Instant pause/skip for builtin player
- PCM pause now releases flutter_pcm_sound to stop audio immediately
  instead of letting buffered audio drain (was causing 5-8 second delays)
- Play/resume re-initializes PCM player after release
- Added optimistic local pause/stop for builtin player - don't wait for
  MA WebSocket round-trip
- Next/Previous also do optimistic local stop for immediate feedback

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 09:47:56 +00:00
Your NameandClaude Opus 4.5 6ee37310c0 fix(sendspin): Resume PCM playback when new audio arrives in paused state
When playing in radio mode, after the first track ends the PCM player
would be in paused state. When the next track started streaming,
audio data was received but never played because:
- _isStarted was true (from previous track)
- _state was paused (not ready or playing)

The fix automatically resumes playback when new audio data arrives
while in paused state, which handles track transitions in radio mode.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 09:21:18 +00:00
Your NameandClaude Opus 4.5 bc118cdd74 fix(playback): Improve handling of unavailable tracks
- Add pre-flight check for tracks with available provider mappings
- Skip tracks that lack available providers instead of sending them to MA
- Log detailed info about which tracks are playable vs unavailable
- Add smart retry logic that doesn't retry non-transient errors
- Improve user-facing error messages for unavailable content
- Reduce unnecessary retry delays for data-related failures

Fixes "No playable items found" errors when albums contain tracks
that don't have available streaming providers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 09:02:20 +00:00
Your NameandClaude Opus 4.5 09fcbceb99 feat(sendspin): Add notification progress sync for PCM playback
- Add elapsed time tracking to PcmAudioPlayer based on bytes played
- Fix notification timer to detect Sendspin PCM players (not just ensemble_ prefix)
- Update stream handlers to manage position correctly:
  - Reset position on stream/start (new track)
  - Preserve position on stream/end (pause)
- Wire up 500ms notification updates using PCM elapsed time

This fixes the issue where notification showed 0:00 for entire track duration
during Sendspin PCM playback.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 08:01:57 +00:00
Your NameandClaude Opus 4.5 168b7d0fbd fix: Use _currentNotificationMetadata instead of non-existent currentTrack
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 07:25:52 +00:00
Your NameandClaude Opus 4.5 f5db2b1e9a fix: Add foreground service for Sendspin PCM streaming
- Add onStreamStart and onStreamEnd callbacks to SendspinService
- Activate audioHandler foreground service when PCM streaming starts
  to prevent Android from throttling background audio playback
- Stop PCM player and update notification when stream ends
- Reinitialize PCM player on stream start if needed for reliability

This fixes the "robotic slowed down" audio when screen turns off
by keeping the app in foreground service mode during PCM playback.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 07:15:33 +00:00
Your NameandClaude Opus 4.5 3f4856a4a6 fix: parse Sendspin binary audio frame header
Sendspin binary frames contain a 9-byte header before the actual PCM data:
- Byte 0: message type (4 = audio data)
- Bytes 1-8: timestamp (int64 microseconds for sync)
- Bytes 9+: actual PCM audio samples

Previously we were feeding the entire frame including the header to the
audio player, causing hissing/static instead of music.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 22:34:36 +00:00
Your Name 4e0f37a901 Fix LogLevel name conflict with pcm prefix 2025-12-15 22:18:17 +00:00
Your NameandClaude Opus 4.5 33921ada43 Switch from raw_sound to flutter_pcm_sound for PCM playback
The raw_sound package has an outdated Android build configuration
missing the namespace declaration required by newer AGP versions.

Switched to flutter_pcm_sound (^3.3.3) which is actively maintained
(last updated Oct 2025) and properly configured for modern Android.

Changes:
- Update pubspec.yaml to use flutter_pcm_sound
- Rewrite PcmAudioPlayer to use flutter_pcm_sound API:
  - FlutterPcmSound.setup() for initialization
  - FlutterPcmSound.feed(PcmArrayInt16) for audio data
  - Callback-based feeding with setFeedCallback
  - Convert Uint8List bytes to Int16 samples

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 22:12:36 +00:00
Your NameandClaude Opus 4.5 a3b024fa6a Add raw PCM audio playback support for Sendspin streaming
Implements raw PCM audio playback using the raw_sound package to
handle binary audio frames sent by Sendspin over WebSocket.

Changes:
- Add raw_sound dependency (^0.3.0+1) to pubspec.yaml
- Create PcmAudioPlayer service wrapper (lib/services/pcm_audio_player.dart)
  - Configurable audio format (defaults to Sendspin: 48kHz, stereo, 16-bit)
  - Stream-based audio feeding with buffering
  - Play/pause/stop controls
  - Periodic logging of playback stats
- Integrate PcmAudioPlayer into MusicAssistantProvider
  - Initialize PCM player when Sendspin connects
  - Connect audioDataStream to PCM player
  - Control PCM player on pause/stop commands
  - Clean up in disconnect/dispose

This completes the Sendspin audio streaming pipeline:
1. SendspinService receives binary WebSocket frames
2. Frames are emitted via audioDataStream
3. PcmAudioPlayer consumes and plays the raw PCM data

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 22:03:32 +00:00
Your NameandClaude Opus 4.5 b4a2d2df3b Fix binary audio frame handling in Sendspin WebSocket
The server sends binary WebSocket frames for PCM audio data, but
_handleMessage was trying to cast all messages to String for JSON
parsing. This caused hundreds of type cast errors:
"type '_Uint8ArrayView' is not a subtype of type 'String' in type cast"

Changes:
- Add dart:typed_data import for Uint8List
- Add SendspinAudioDataCallback typedef and onAudioData callback
- Add _audioDataController stream for raw PCM frames
- Add _isStreamingAudio and _audioFramesReceived state tracking
- Update _handleMessage to detect binary (List<int>) vs text (String)
- Add _handleBinaryAudioData method with periodic logging
- Add stream/start and stream/end message handlers
- Close _audioDataController in dispose()

This fixes the type cast errors and sets up infrastructure for PCM
audio playback (actual audio player integration is a separate task).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 21:52:16 +00:00
Your NameandClaude Opus 4.5 7f8a18929d feat: Refresh player list when new players are added
Added player_added event listener that refreshes the player list when
the Sendspin player gets registered. This fixes the issue where the
local phone player wasn't appearing in the device list.

The problem was timing: the player list was fetched BEFORE the Sendspin
player finished registering in Music Assistant. Now when the player_added
event is received, the list is automatically refreshed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 21:29:17 +00:00
Your NameandClaude Opus 4.5 86dd6dfede fix: Allow initial state messages during handshake phase
The initial client/state and client/time messages were being silently
dropped because they were sent while the connection state was still
'connecting' (before _waitForAck completed).

The _sendMessage method has a guard that blocks messages unless
state == connected OR allowDuringHandshake is true. The initial
messages need allowDuringHandshake since they must be sent immediately
after receiving server/hello, before the handshake completes.

Without this fix, the server times out after 5 seconds waiting for
the required initial state message.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 21:08:53 +00:00
Your NameandClaude Opus 4.5 62c63d691a fix: Use correct Sendspin protocol message types
Root cause: Server logs showed "ClientMessage has no subtype with
attribute 'type' equal to 'state'" - the client was using incorrect
message types that the aiosendspin library couldn't parse.

Changes:
- Fix state reporting: 'state' → 'client/state' with proper payload
- Fix heartbeat: 'ping' → 'client/time' with microsecond timestamp
- Fix disconnect: 'disconnect' → 'client/goodbye' with reason
- Send initial client/state immediately after server/hello (required)
- Handle server/time and group/update message types
- Reduce state report interval from 1s to 5s (was flooding server)

The server was disconnecting after 5 seconds because it couldn't parse
the malformed 'state' messages, triggering a "spec violation" timeout.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 20:55:10 +00:00
Your NameandClaude Opus 4.5 4df99ae8f1 fix: Allow sending hello message during handshake phase
The _sendMessage method was checking for connected state before sending,
but during handshake the state is still 'connecting'. This caused the
hello message to be silently dropped, preventing registration.

Added allowDuringHandshake flag to bypass the state check for the
initial hello message.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 20:07:34 +00:00
Your NameandClaude Opus 4.5 a3cd6911b8 fix: Use correct Sendspin protocol format for client/hello
The Sendspin protocol requires:
- Nested 'payload' object with client_id, name, version
- version as integer (not string)
- supported_roles array with versioned roles like 'player@v1'
- player_support object with supported_formats, buffer_capacity, supported_commands
- Handle 'server/hello' response type

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 19:54:15 +00:00
Your NameandClaude Opus 4.5 848c41f13d fix: Send client/hello message to register with Sendspin server
The Sendspin protocol requires the client to send a client/hello message
immediately after connecting. The server waits for this message before
registering the player. Previously we were waiting for the server to
speak first, causing a deadlock and connection timeout.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 19:33:43 +00:00
Your NameandClaude Opus 4.5 2efc6b5fe8 feat: Add Sendspin support for MA 2.7.0b20+
Implements Sendspin protocol support as a replacement for the deprecated
builtin_player API. This allows local playback on devices running Music
Assistant 2.7.0b20 or newer.

Key features:
- SendspinService for WebSocket connection management
- Smart connection fallback: external wss:// → local ws:// → WebRTC
- API methods for Sendspin connection info and WebRTC signaling
- Automatic detection when builtin_player API is unavailable
- Username-based player naming (same as builtin player)
- Persistent device ID to prevent ghost players

Connection strategy for external access:
1. If server is HTTPS, try wss://{server}/sendspin first
2. Fall back to local_ws_url from API
3. WebRTC fallback noted but not yet implemented

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 19:01:31 +00:00
9 changed files with 1947 additions and 29 deletions
+4 -2
View File
@@ -8,8 +8,10 @@ class Timings {
/// Player state polling interval (for selected player updates)
static const Duration playerPollingInterval = Duration(seconds: 5);
/// Local player state report interval (for seek bar smoothness)
static const Duration localPlayerReportInterval = Duration(seconds: 1);
/// Local player state report interval
/// Note: Sendspin protocol only requires state on changes, not periodic reports.
/// This is mainly for local UI position tracking. Set to 5s to reduce server traffic.
static const Duration localPlayerReportInterval = Duration(seconds: 5);
/// Player list cache duration before refresh
static const Duration playersCacheDuration = Duration(minutes: 5);
+29
View File
@@ -27,6 +27,35 @@ class Player {
this.elapsedTimeLastUpdated,
});
/// Create a copy of this Player with some fields replaced
Player copyWith({
String? playerId,
String? name,
String? provider,
bool? available,
bool? powered,
String? state,
String? currentItemId,
int? volumeLevel,
bool? volumeMuted,
double? elapsedTime,
double? elapsedTimeLastUpdated,
}) {
return Player(
playerId: playerId ?? this.playerId,
name: name ?? this.name,
provider: provider ?? this.provider,
available: available ?? this.available,
powered: powered ?? this.powered,
state: state ?? this.state,
currentItemId: currentItemId ?? this.currentItemId,
volumeLevel: volumeLevel ?? this.volumeLevel,
volumeMuted: volumeMuted ?? this.volumeMuted,
elapsedTime: elapsedTime ?? this.elapsedTime,
elapsedTimeLastUpdated: elapsedTimeLastUpdated ?? this.elapsedTimeLastUpdated,
);
}
// Derived properties
bool get isPlaying => state == 'playing';
bool get isMuted => volumeMuted ?? false;
+455 -24
View File
@@ -16,6 +16,8 @@ import '../services/cache_service.dart';
import '../services/local_player_service.dart';
import '../services/metadata_service.dart';
import '../services/position_tracker.dart';
import '../services/sendspin_service.dart';
import '../services/pcm_audio_player.dart';
import '../constants/timings.dart';
import '../main.dart' show audioHandler;
@@ -59,6 +61,7 @@ class MusicAssistantProvider with ChangeNotifier {
bool _builtinPlayerAvailable = true; // False on MA 2.7.0b20+ (uses Sendspin instead)
StreamSubscription? _localPlayerEventSubscription;
StreamSubscription? _playerUpdatedEventSubscription;
StreamSubscription? _playerAddedEventSubscription;
Timer? _localPlayerStateReportTimer;
TrackMetadata? _pendingTrackMetadata;
TrackMetadata? _currentNotificationMetadata;
@@ -67,6 +70,13 @@ class MusicAssistantProvider with ChangeNotifier {
// Local player service
late final LocalPlayerService _localPlayer;
// Sendspin service (MA 2.7.0b20+ replacement for builtin_player)
SendspinService? _sendspinService;
bool _sendspinConnected = false;
// PCM audio player for raw Sendspin audio streaming
PcmAudioPlayer? _pcmAudioPlayer;
// Search state persistence
String _lastSearchQuery = '';
Map<String, List<MediaItem>> _lastSearchResults = {
@@ -103,6 +113,27 @@ class MusicAssistantProvider with ChangeNotifier {
/// Position tracker for playback progress - single source of truth
PositionTracker get positionTracker => _positionTracker;
/// Whether Sendspin (PCM streaming) is connected for builtin player
bool get isSendspinConnected => _sendspinConnected;
/// Whether PCM audio is currently playing via Sendspin
bool get isPcmPlaying => _sendspinConnected && _pcmAudioPlayer != null && _pcmAudioPlayer!.isPlaying;
/// Get current PCM audio format info (when using Sendspin)
/// Returns null if not using Sendspin PCM streaming
String? get currentAudioFormat {
if (!_sendspinConnected || _pcmAudioPlayer == null) return null;
return '48kHz • Stereo • 16-bit PCM';
}
/// Get the current playback source description
String get playbackSource {
if (_sendspinConnected && _pcmAudioPlayer != null) {
return 'Sendspin (Local PCM)';
}
return 'Music Assistant';
}
/// Get cached track for a player (used for smooth swipe transitions)
Track? getCachedTrackForPlayer(String playerId) => _cacheService.getCachedTrackForPlayer(playerId);
@@ -209,6 +240,12 @@ class MusicAssistantProvider with ChangeNotifier {
onError: (error) => _logger.log('Player updated event stream error: $error'),
);
_playerAddedEventSubscription?.cancel();
_playerAddedEventSubscription = _api!.playerAddedEvents.listen(
_handlePlayerAddedEvent,
onError: (error) => _logger.log('Player added event stream error: $error'),
);
await _api!.connect();
notifyListeners();
} catch (e) {
@@ -324,7 +361,14 @@ class MusicAssistantProvider with ChangeNotifier {
_localPlayerStateReportTimer?.cancel();
_localPlayerEventSubscription?.cancel();
_playerUpdatedEventSubscription?.cancel();
_playerAddedEventSubscription?.cancel();
_positionTracker.clear();
// Disconnect Sendspin and PCM player if connected
if (_sendspinConnected) {
await _pcmAudioPlayer?.disconnect();
await _sendspinService?.disconnect();
_sendspinConnected = false;
}
await _api?.disconnect();
_connectionState = MAConnectionState.disconnected;
_artists = [];
@@ -477,13 +521,25 @@ class MusicAssistantProvider with ChangeNotifier {
final errorStr = e.toString();
if (errorStr.contains('Invalid command') && errorStr.contains('builtin_player')) {
_logger.log('⚠️ Builtin player API not available (MA 2.7.0b20+ uses Sendspin)');
_logger.log('ℹ️ Local player registration skipped - use other players (Chromecast, etc)');
_builtinPlayerAvailable = false;
// Try to connect via Sendspin instead
_logger.log('🔄 Attempting Sendspin connection...');
final sendspinSuccess = await _connectViaSendspin();
if (sendspinSuccess) {
_logger.log('✅ Connected via Sendspin - local player available');
_startReportingLocalPlayerState();
} else {
_logger.log('⚠️ Sendspin connection failed - local player unavailable');
_logger.log('ℹ️ Use other players (Chromecast, etc) or ensure /sendspin route is configured');
}
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.complete();
}
_registrationInProgress = null;
return; // Non-fatal, continue without local player
return; // Non-fatal, continue
}
_logger.log('❌ Player registration failed: $e');
@@ -495,6 +551,277 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Connect to Music Assistant via Sendspin protocol (MA 2.7.0b20+)
/// This is the replacement for builtin_player when that API is not available.
///
/// Connection strategy:
/// 1. If server is HTTPS, try external wss://{server}/sendspin first
/// 2. Get local_ws_url from API and try that
/// 3. WebRTC fallback as last resort (requires TURN servers)
Future<bool> _connectViaSendspin() async {
if (_api == null || _serverUrl == null) return false;
try {
// Initialize Sendspin service
_sendspinService?.dispose();
_sendspinService = SendspinService(_serverUrl!);
// Initialize PCM audio player for raw audio streaming
_pcmAudioPlayer?.dispose();
_pcmAudioPlayer = PcmAudioPlayer();
final pcmInitialized = await _pcmAudioPlayer!.initialize();
if (pcmInitialized) {
_logger.log('✅ PCM audio player initialized for Sendspin');
// Connect PCM player to Sendspin audio stream
await _pcmAudioPlayer!.connectToStream(_sendspinService!.audioDataStream);
} else {
_logger.log('⚠️ PCM audio player initialization failed');
}
// Wire up callbacks
_sendspinService!.onPlay = _handleSendspinPlay;
_sendspinService!.onPause = _handleSendspinPause;
_sendspinService!.onStop = _handleSendspinStop;
_sendspinService!.onSeek = _handleSendspinSeek;
_sendspinService!.onVolume = _handleSendspinVolume;
_sendspinService!.onStreamStart = _handleSendspinStreamStart;
_sendspinService!.onStreamEnd = _handleSendspinStreamEnd;
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
// Strategy 1: If server is HTTPS, try external wss:// first
final isHttps = _serverUrl!.startsWith('https://') ||
(!_serverUrl!.contains('://') && !_serverUrl!.contains(':'));
if (isHttps) {
_logger.log('Sendspin: Server is HTTPS, trying external connection first');
final connected = await _sendspinService!.connect();
if (connected) {
_sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via external URL');
return true;
}
_logger.log('⚠️ Sendspin: External connection failed, trying local URL');
}
// Strategy 2: Get local_ws_url from API and try that
final connectionInfo = await _api!.getSendspinConnectionInfo(playerId);
if (connectionInfo != null) {
final localWsUrl = connectionInfo['local_ws_url'] as String?;
if (localWsUrl != null) {
_logger.log('Sendspin: Trying local WebSocket URL: $localWsUrl');
final connected = await _sendspinService!.connectWithUrl(localWsUrl);
if (connected) {
_sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via local URL');
return true;
}
}
}
// Strategy 3: WebRTC fallback (requires TURN servers from Nabucasa)
// For now, just log that we'd need WebRTC - implementing full WebRTC
// would require additional dependencies (flutter_webrtc)
_logger.log('⚠️ Sendspin: WebRTC fallback not yet implemented');
_logger.log('ℹ️ For external access, configure /sendspin route in your reverse proxy');
return false;
} catch (e) {
_logger.log('❌ Sendspin connection error: $e');
return false;
}
}
/// Handle Sendspin play command
void _handleSendspinPlay(String streamUrl, Map<String, dynamic> trackInfo) async {
_logger.log('🎵 Sendspin: Play command received');
try {
// Build full URL if needed
String fullUrl = streamUrl;
if (!streamUrl.startsWith('http') && _serverUrl != null) {
var baseUrl = _serverUrl!;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
baseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
final path = streamUrl.startsWith('/') ? streamUrl : '/$streamUrl';
fullUrl = '$baseUrl$path';
}
// Extract metadata from track info
final trackName = trackInfo['title'] as String? ?? trackInfo['name'] as String? ?? 'Unknown Track';
final artistName = trackInfo['artist'] as String? ?? 'Unknown Artist';
final albumName = trackInfo['album'] as String?;
var artworkUrl = trackInfo['image_url'] as String? ?? trackInfo['artwork_url'] as String?;
final durationSecs = trackInfo['duration'] as int?;
if (artworkUrl != null && artworkUrl.startsWith('http://')) {
artworkUrl = artworkUrl.replaceFirst('http://', 'https://');
}
final metadata = TrackMetadata(
title: trackName,
artist: artistName,
album: albumName,
artworkUrl: artworkUrl,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
);
_localPlayer.setCurrentTrackMetadata(metadata);
_currentNotificationMetadata = metadata;
await _localPlayer.playUrl(fullUrl);
// Report state back to MA
_sendspinService?.reportState(playing: true, paused: false);
} catch (e) {
_logger.log('❌ Sendspin: Error handling play command: $e');
}
}
/// Handle Sendspin pause command
void _handleSendspinPause() async {
_logger.log('⏸️ Sendspin: Pause command received');
// Pause both players - PCM for raw streaming, local for URL-based
await _pcmAudioPlayer?.pause();
await _localPlayer.pause();
_sendspinService?.reportState(playing: false, paused: true);
}
/// Handle Sendspin stop command
void _handleSendspinStop() async {
_logger.log('⏹️ Sendspin: Stop command received');
// Stop both players - PCM for raw streaming, local for URL-based
await _pcmAudioPlayer?.stop();
await _localPlayer.stop();
_sendspinService?.reportState(playing: false, paused: false);
}
/// Handle Sendspin seek command
void _handleSendspinSeek(int positionSeconds) async {
_logger.log('⏩ Sendspin: Seek to $positionSeconds seconds');
await _localPlayer.seek(Duration(seconds: positionSeconds));
_sendspinService?.reportState(position: positionSeconds);
}
/// Handle Sendspin volume command
void _handleSendspinVolume(int volumeLevel) async {
_logger.log('🔊 Sendspin: Set volume to $volumeLevel');
_localPlayerVolume = volumeLevel;
await FlutterVolumeController.setVolume(volumeLevel / 100.0);
_sendspinService?.reportState(volume: volumeLevel);
}
/// Handle Sendspin stream start - server is about to send PCM audio data
/// This is called when audio streaming begins, before any audio frames arrive.
/// We use this to:
/// 1. Ensure PCM player is ready
/// 2. Start the foreground service to prevent background throttling
/// 3. Reset position for new track and start position timer
void _handleSendspinStreamStart(Map<String, dynamic>? trackInfo) async {
_logger.log('🎵 Sendspin: Stream starting');
// Ensure PCM player is initialized and ready
if (_pcmAudioPlayer == null || _pcmAudioPlayer!.state == PcmPlayerState.idle) {
_logger.log('🎵 Sendspin: Reinitializing PCM player for stream');
_pcmAudioPlayer?.dispose();
_pcmAudioPlayer = PcmAudioPlayer();
final initialized = await _pcmAudioPlayer!.initialize();
if (initialized) {
await _pcmAudioPlayer!.connectToStream(_sendspinService!.audioDataStream);
} else {
_logger.log('⚠️ Sendspin: Failed to initialize PCM player');
return;
}
}
// CRITICAL: Reset paused state when stream starts
// This clears _isPausePending and sets state to playing
// so that _onAudioData will process incoming audio
await _pcmAudioPlayer!.play();
// Reset position for new stream (new track)
_pcmAudioPlayer!.resetPosition();
// Extract track info for notification
String? title = trackInfo?['title'] as String? ?? trackInfo?['name'] as String?;
String? artist = trackInfo?['artist'] as String?;
String? album = trackInfo?['album'] as String?;
String? artworkUrl = trackInfo?['image_url'] as String? ?? trackInfo?['artwork_url'] as String?;
int? durationSecs = trackInfo?['duration'] as int?;
// If no track info in stream/start, try to get from cached notification metadata
if (title == null && _currentNotificationMetadata != null) {
title = _currentNotificationMetadata!.title;
artist = _currentNotificationMetadata!.artist;
album = _currentNotificationMetadata!.album;
artworkUrl = _currentNotificationMetadata!.artworkUrl;
durationSecs = _currentNotificationMetadata!.duration?.inSeconds;
}
// Keep the foreground service active to prevent Android from throttling
// the PCM audio playback when the app goes to background.
// We use setRemotePlaybackState to maintain the notification without
// actually playing audio through just_audio.
final mediaItem = audio_service.MediaItem(
id: 'sendspin_pcm_stream',
title: title ?? 'Playing via Sendspin',
artist: artist ?? 'Music Assistant',
album: album,
artUri: artworkUrl != null ? Uri.parse(artworkUrl) : null,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
);
// Initialize notification with position 0
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: true,
position: Duration.zero,
duration: mediaItem.duration,
);
// Start notification position timer for Sendspin PCM
_manageNotificationPositionTimer();
_logger.log('🎵 Sendspin: Foreground service activated for PCM streaming');
}
/// Handle Sendspin stream end - server stopped sending PCM audio data
/// This is called when audio streaming ends (pause, stop, track end, etc.)
void _handleSendspinStreamEnd() async {
// Capture current position before stopping
final lastPosition = _pcmAudioPlayer?.elapsedTime ?? Duration.zero;
_logger.log('🎵 Sendspin: Stream ended at position ${lastPosition.inSeconds}s');
// Stop notification position timer
_notificationPositionTimer?.cancel();
// Pause PCM playback (preserves position) instead of stop (resets position)
await _pcmAudioPlayer?.pause();
// Update foreground service to show paused/stopped state with last position
// Don't completely clear it - keep showing the notification
// in case user wants to resume
final metadata = _currentNotificationMetadata;
final mediaItem = audio_service.MediaItem(
id: 'sendspin_pcm_stream',
title: metadata?.title ?? 'Music Assistant',
artist: metadata?.artist ?? 'Paused',
album: metadata?.album,
artUri: metadata?.artworkUrl != null ? Uri.parse(metadata!.artworkUrl!) : null,
duration: metadata?.duration,
);
// Show paused state with preserved position
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: false,
position: lastPosition,
duration: mediaItem.duration,
);
}
void _startReportingLocalPlayerState() {
_localPlayerStateReportTimer?.cancel();
_localPlayerStateReportTimer = Timer.periodic(Timings.localPlayerReportInterval, (_) async {
@@ -508,7 +835,6 @@ class MusicAssistantProvider with ChangeNotifier {
Future<void> _reportLocalPlayerState() async {
if (_api == null) return;
if (!_builtinPlayerAvailable) return; // Skip on MA 2.7.0b20+ (no builtin_player API)
// Don't try to report state if not authenticated - avoids spamming errors
if (_api!.currentConnectionState != MAConnectionState.authenticated) return;
@@ -523,6 +849,22 @@ class MusicAssistantProvider with ChangeNotifier {
final volume = _localPlayerVolume;
final isPaused = !isPlaying && position > 0;
// Report via Sendspin if connected (MA 2.7.0b20+)
if (_sendspinConnected && _sendspinService != null) {
_sendspinService!.reportState(
powered: _isLocalPlayerPowered,
playing: isPlaying,
paused: isPaused,
position: position,
volume: volume,
muted: _localPlayerVolume == 0,
);
return;
}
// Otherwise use builtin_player API (older MA versions)
if (!_builtinPlayerAvailable) return;
await _api!.updateBuiltinPlayerState(
playerId,
powered: _isLocalPlayerPowered,
@@ -658,6 +1000,21 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Handle player_added event - refresh player list when new players join
Future<void> _handlePlayerAddedEvent(Map<String, dynamic> event) async {
try {
final playerId = event['player_id'] as String?;
final playerName = event['name'] as String?;
_logger.log('🆕 Player added: $playerName ($playerId)');
// Refresh the player list to include the new player
await _loadAndSelectPlayers(forceRefresh: true);
notifyListeners();
} catch (e) {
_logger.log('Error handling player added event: $e');
}
}
Future<void> _handlePlayerUpdatedEvent(Map<String, dynamic> event) async {
try {
final playerId = event['player_id'] as String?;
@@ -1298,8 +1655,13 @@ class MusicAssistantProvider with ChangeNotifier {
}
// Only include players that are available AND actively playing/paused
// Always include the builtin player so user can switch to local device
final activePlayers = _availablePlayers.where((p) =>
p.available && (p.state == 'playing' || p.state == 'paused')
p.available && (
p.state == 'playing' ||
p.state == 'paused' ||
p.playerId.startsWith('ensemble_') // Always include builtin player
)
).toList();
if (activePlayers.isEmpty) {
@@ -1325,7 +1687,7 @@ class MusicAssistantProvider with ChangeNotifier {
selectPlayer(nextPlayer);
}
/// Manage notification position timer for remote players.
/// Manage notification position timer for remote players and Sendspin PCM.
/// This timer updates the notification position every second using interpolated time,
/// making the progress bar smooth instead of jumping every 5 seconds (polling interval).
void _manageNotificationPositionTimer() {
@@ -1333,24 +1695,30 @@ class MusicAssistantProvider with ChangeNotifier {
if (_selectedPlayer == null || _currentTrack == null) return;
// Check if this is a builtin/local player (doesn't need timer - just_audio handles position)
// Note: We can't use async/await in this method easily, so check using pattern matching
// Check if this is a builtin/local player
final playerId = _selectedPlayer!.playerId;
if (playerId.startsWith('ensemble_')) {
// Local player - no timer needed, just_audio handles position automatically
return;
// This is a local player ID, but we need to check if it's using Sendspin PCM
// Sendspin PCM needs the timer because flutter_pcm_sound doesn't broadcast position
// just_audio (non-Sendspin) handles position automatically via native events
if (!_sendspinConnected || _pcmAudioPlayer == null) {
// True local player using just_audio - no timer needed
return;
}
// Sendspin PCM player - continue to start timer
_logger.log('🔔 Starting notification timer for Sendspin PCM player');
}
// Only run timer if remote player is playing
// Only run timer if player is playing
if (_selectedPlayer!.state != 'playing') return;
_notificationPositionTimer = Timer.periodic(
const Duration(seconds: 1),
const Duration(milliseconds: 500), // 500ms for smoother progress
(_) => _updateNotificationPosition(),
);
}
/// Update just the notification position (called every second for remote players)
/// Update just the notification position (called every 500ms for remote/Sendspin players)
void _updateNotificationPosition() {
if (_selectedPlayer == null || _currentTrack == null) {
_notificationPositionTimer?.cancel();
@@ -1363,15 +1731,28 @@ class MusicAssistantProvider with ChangeNotifier {
return;
}
// Use position tracker for consistent position (single source of truth)
final position = _positionTracker.currentPosition;
final track = _currentTrack!;
Duration position;
// Check if track has ended (position reached duration)
if (_positionTracker.hasReachedEnd) {
_logger.log('PositionTracker: Track appears to have ended (position >= duration)');
// Don't update notification, let the next poll handle the state change
return;
// For Sendspin PCM, use the PCM player's elapsed time (based on bytes played)
// For remote players, use the position tracker (server-based interpolation)
if (_sendspinConnected && _pcmAudioPlayer != null && _pcmAudioPlayer!.isPlaying) {
position = _pcmAudioPlayer!.elapsedTime;
// Check if track has ended based on PCM elapsed time
if (track.duration != null && position >= track.duration!) {
_logger.log('🔔 Sendspin: Track appears to have ended (position >= duration)');
return;
}
} else {
// Use position tracker for remote players
position = _positionTracker.currentPosition;
// Check if track has ended (position reached duration)
if (_positionTracker.hasReachedEnd) {
_logger.log('PositionTracker: Track appears to have ended (position >= duration)');
return;
}
}
final artworkUrl = _api?.getImageUrl(track, size: 512);
@@ -1688,13 +2069,17 @@ class MusicAssistantProvider with ChangeNotifier {
Future<void> playPauseSelectedPlayer() async {
if (_selectedPlayer == null) return;
if (_selectedPlayer!.isPlaying) {
final wasPlaying = _selectedPlayer!.isPlaying;
if (wasPlaying) {
await pausePlayer(_selectedPlayer!.playerId);
// For pause: Don't refresh - we already did optimistic UI update
// The player_updated event from MA will handle final state sync
} else {
await resumePlayer(_selectedPlayer!.playerId);
// For resume: Refresh in background to get updated track info
unawaited(refreshPlayers());
}
await refreshPlayers();
}
Future<void> nextTrackSelectedPlayer() async {
@@ -1943,15 +2328,42 @@ class MusicAssistantProvider with ChangeNotifier {
Future<void> pausePlayer(String playerId) async {
try {
await _api?.pausePlayer(playerId);
// Get builtin player ID - this is cached so should be fast
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) {
_logger.log('⏸️ Non-blocking local pause for builtin player');
// CRITICAL: Don't await these - they can block the UI thread
// Use unawaited to make them fire-and-forget
unawaited(_pcmAudioPlayer?.pause() ?? Future.value());
// Don't pause just_audio for Sendspin mode - it's not being used for audio output
// and calling pause() on it can cause blocking issues
// unawaited(_localPlayer.pause());
// Report state to MA immediately (fire and forget)
_sendspinService?.reportState(playing: false, paused: true);
// Update local player state optimistically for UI responsiveness
if (_selectedPlayer != null) {
_selectedPlayer = _selectedPlayer!.copyWith(state: 'paused');
notifyListeners();
}
}
// Send command to MA for proper state sync - don't await
unawaited(_api?.pausePlayer(playerId) ?? Future.value());
} catch (e) {
ErrorHandler.logError('Pause player', e);
rethrow;
// Don't rethrow - we want pause to be resilient
}
}
Future<void> resumePlayer(String playerId) async {
try {
// For resume, we let MA handle it since it needs to restart the stream
// The stream_start event will trigger local playback
await _api?.resumePlayer(playerId);
} catch (e) {
ErrorHandler.logError('Resume player', e);
@@ -1961,6 +2373,14 @@ class MusicAssistantProvider with ChangeNotifier {
Future<void> nextTrack(String playerId) async {
try {
// Optimistic local stop for builtin player on skip - non-blocking
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) {
_logger.log('⏭️ Non-blocking local stop for skip on builtin player');
// Stop current audio immediately - fire and forget
unawaited(_pcmAudioPlayer?.pause() ?? Future.value());
// Don't stop just_audio - not used for Sendspin audio output
}
await _api?.nextTrack(playerId);
} catch (e) {
ErrorHandler.logError('Next track', e);
@@ -1970,6 +2390,14 @@ class MusicAssistantProvider with ChangeNotifier {
Future<void> previousTrack(String playerId) async {
try {
// Optimistic local stop for builtin player on previous - non-blocking
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) {
_logger.log('⏮️ Non-blocking local stop for previous on builtin player');
// Stop current audio immediately - fire and forget
unawaited(_pcmAudioPlayer?.pause() ?? Future.value());
// Don't stop just_audio - not used for Sendspin audio output
}
await _api?.previousTrack(playerId);
} catch (e) {
ErrorHandler.logError('Previous track', e);
@@ -2111,7 +2539,10 @@ class MusicAssistantProvider with ChangeNotifier {
_localPlayerStateReportTimer?.cancel();
_localPlayerEventSubscription?.cancel();
_playerUpdatedEventSubscription?.cancel();
_playerAddedEventSubscription?.cancel();
_positionTracker.dispose();
_pcmAudioPlayer?.dispose();
_sendspinService?.dispose();
_api?.dispose();
super.dispose();
}
+12
View File
@@ -71,6 +71,18 @@ class ErrorHandler {
);
}
// Unavailable content errors - these won't resolve with retry
if (errorStr.contains('no playable') ||
errorStr.contains('lack available providers') ||
errorStr.contains('no available providers')) {
return ErrorInfo(
type: ErrorType.playback,
userMessage: 'These tracks are unavailable. The music provider may not have them available for streaming.',
technicalMessage: error.toString(),
canRetry: false,
);
}
// Playback errors
if (errorStr.contains('queue') ||
errorStr.contains('player') ||
+200 -2
View File
@@ -1045,7 +1045,20 @@ class MusicAssistantAPI {
/// If clearQueue is true, replaces the queue (default behavior)
/// If startIndex is provided, only tracks from that index onwards will be queued
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex, bool clearQueue = true}) async {
return await RetryHelper.retryCritical(
return await RetryHelper.retry(
maxAttempts: 3,
initialDelaySeconds: 1,
maxDelaySeconds: 4,
// Don't retry errors about missing/unavailable tracks - those won't resolve with retries
shouldRetry: (error) {
final errorStr = error.toString().toLowerCase();
if (errorStr.contains('no playable') ||
errorStr.contains('no tracks') ||
errorStr.contains('lack available')) {
return false;
}
return true;
},
operation: () async {
// If startIndex is provided, slice the tracks list to start from that index
// This is a workaround since Music Assistant ignores the start_item parameter
@@ -1053,8 +1066,36 @@ class MusicAssistantAPI {
? tracks.sublist(startIndex)
: tracks;
if (tracksToPlay.isEmpty) {
throw Exception('No tracks to play');
}
// Filter to only tracks with available provider mappings
final playableTracks = tracksToPlay.where((track) {
if (track.providerMappings == null || track.providerMappings!.isEmpty) {
_logger.log('⚠️ Track "${track.name}" has no provider mappings');
return false;
}
final hasAvailable = track.providerMappings!.any((m) => m.available);
if (!hasAvailable) {
_logger.log('⚠️ Track "${track.name}" has no available providers');
}
return hasAvailable;
}).toList();
if (playableTracks.isEmpty) {
_logger.log('❌ No playable tracks found (${tracksToPlay.length} tracks had no available providers)');
throw Exception('No playable tracks - all ${tracksToPlay.length} tracks lack available providers');
}
if (playableTracks.length < tracksToPlay.length) {
_logger.log('⚠️ ${tracksToPlay.length - playableTracks.length} tracks skipped (no available providers)');
}
// Build array of URI strings (not objects!)
final mediaUris = tracksToPlay.map((track) => _buildTrackUri(track)).toList();
final mediaUris = playableTracks.map((track) => _buildTrackUri(track)).toList();
_logger.log('🎵 Playing ${mediaUris.length} tracks: ${mediaUris.take(3).join(", ")}${mediaUris.length > 3 ? "..." : ""}');
final option = clearQueue ? 'replace' : 'play';
@@ -1458,6 +1499,14 @@ class MusicAssistantAPI {
return _eventStreams['player_updated']!.stream;
}
/// Stream of player_added events (for refreshing player list when new players join)
Stream<Map<String, dynamic>> get playerAddedEvents {
if (!_eventStreams.containsKey('player_added')) {
_eventStreams['player_added'] = StreamController<Map<String, dynamic>>.broadcast();
}
return _eventStreams['player_added']!.stream;
}
/// Register this device as a player with retry logic
/// CRITICAL: This creates a player config in MA's settings.json
/// The server expects: player_id and player_name
@@ -1703,6 +1752,155 @@ class MusicAssistantAPI {
}
}
// ============================================================================
// SENDSPIN PLAYER MANAGEMENT (MA 2.7.0b20+ replacement for builtin_player)
// ============================================================================
/// Stream of Sendspin player events
Stream<Map<String, dynamic>> get sendspinPlayerEvents {
if (!_eventStreams.containsKey('sendspin_player')) {
_eventStreams['sendspin_player'] = StreamController<Map<String, dynamic>>.broadcast();
}
return _eventStreams['sendspin_player']!.stream;
}
/// Get Sendspin connection info for a player
/// Returns connection details including:
/// - local_ws_url: Direct WebSocket URL (e.g., ws://192.168.4.120:8927/sendspin)
/// - ice_servers: List of ICE/TURN servers for WebRTC fallback
/// - player_id: The player's ID
Future<Map<String, dynamic>?> getSendspinConnectionInfo(String playerId) async {
try {
_logger.log('Sendspin: Getting connection info for player $playerId');
final response = await _sendCommand(
'sendspin/connection_info',
args: {
'player_id': playerId,
},
);
final result = response['result'] as Map<String, dynamic>?;
if (result != null) {
_logger.log('Sendspin: Got connection info - local_ws_url: ${result['local_ws_url']}');
_logger.log('Sendspin: ICE servers count: ${(result['ice_servers'] as List?)?.length ?? 0}');
}
return result;
} catch (e) {
_logger.log('Sendspin: Error getting connection info: $e');
return null;
}
}
/// Send WebRTC offer to Sendspin server
/// Used when establishing WebRTC connection as fallback
Future<Map<String, dynamic>?> sendspinOffer(String playerId, String sdp) async {
try {
_logger.log('Sendspin: Sending WebRTC offer for player $playerId');
final response = await _sendCommand(
'sendspin/webrtc_offer',
args: {
'player_id': playerId,
'sdp': sdp,
},
);
return response['result'] as Map<String, dynamic>?;
} catch (e) {
_logger.log('Sendspin: Error sending WebRTC offer: $e');
return null;
}
}
/// Send WebRTC answer to Sendspin server
Future<void> sendspinAnswer(String playerId, String sdp) async {
try {
_logger.log('Sendspin: Sending WebRTC answer for player $playerId');
await _sendCommand(
'sendspin/webrtc_answer',
args: {
'player_id': playerId,
'sdp': sdp,
},
);
} catch (e) {
_logger.log('Sendspin: Error sending WebRTC answer: $e');
rethrow;
}
}
/// Send ICE candidate to Sendspin server
Future<void> sendspinIceCandidate(String playerId, Map<String, dynamic> candidate) async {
try {
await _sendCommand(
'sendspin/ice_candidate',
args: {
'player_id': playerId,
'candidate': candidate,
},
);
} catch (e) {
_logger.log('Sendspin: Error sending ICE candidate: $e');
// Don't rethrow - ICE candidate failures are common and non-fatal
}
}
/// Update Sendspin player state
/// Similar to updateBuiltinPlayerState but for Sendspin protocol
Future<void> updateSendspinPlayerState(
String playerId, {
required bool powered,
required bool playing,
required bool paused,
required int position,
required int volume,
required bool muted,
}) async {
try {
_logger.log('Sendspin: Updating player state: powered=$powered, playing=$playing, paused=$paused');
await _sendCommand(
'sendspin/update_state',
args: {
'player_id': playerId,
'state': {
'powered': powered,
'playing': playing,
'paused': paused,
'position': position,
'volume': volume,
'muted': muted,
},
},
);
} catch (e) {
_logger.log('Sendspin: Error updating player state: $e');
// Non-fatal, don't rethrow
}
}
/// Disconnect Sendspin player from server
Future<void> disconnectSendspinPlayer(String playerId) async {
try {
_logger.log('Sendspin: Disconnecting player $playerId');
await _sendCommand(
'sendspin/disconnect',
args: {
'player_id': playerId,
},
);
} catch (e) {
_logger.log('Sendspin: Error disconnecting player: $e');
// Non-fatal during cleanup
}
}
// ============================================================================
// END SENDSPIN PLAYER MANAGEMENT
// ============================================================================
Future<void> _sendQueueCommand(String queueId, String command) async {
try {
final response = await _sendCommand(
+591
View File
@@ -0,0 +1,591 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show unawaited;
import 'package:flutter_pcm_sound/flutter_pcm_sound.dart' as pcm;
import 'debug_logger.dart';
/// Callback type for elapsed time updates
typedef ElapsedTimeCallback = void Function(Duration elapsed);
/// Audio format configuration matching Sendspin protocol
class PcmAudioFormat {
final int sampleRate;
final int channels;
final int bitDepth;
const PcmAudioFormat({
this.sampleRate = 48000,
this.channels = 2,
this.bitDepth = 16,
});
/// Default Sendspin format: 48kHz, stereo, 16-bit PCM
static const sendspin = PcmAudioFormat();
}
/// Player state for PCM audio
enum PcmPlayerState {
idle,
initializing,
ready,
playing,
pausing, // Transitional state: pause requested, waiting for completion
paused,
resuming, // Transitional state: resume requested, re-initializing
stopping, // Transitional state: stop requested, cleaning up
error,
}
/// Error types for PCM player operations
enum PcmPlayerError {
initializationFailed,
feedFailed,
pauseFailed,
resumeFailed,
streamError,
releaseTimeout,
}
/// Callback type for error events
typedef PcmErrorCallback = void Function(PcmPlayerError error, String message);
/// Service to play raw PCM audio data from Sendspin WebSocket stream
/// Uses flutter_pcm_sound plugin for low-level PCM playback
///
/// State Machine:
/// - idle → initializing → ready → playing ↔ paused
/// - Any state can transition to error
/// - Transitional states (pausing, resuming, stopping) block operations
class PcmAudioPlayer {
final _logger = DebugLogger();
PcmPlayerState _state = PcmPlayerState.idle;
PcmAudioFormat _format = PcmAudioFormat.sendspin;
StreamSubscription<Uint8List>? _audioSubscription;
// Audio buffer for smooth playback
final List<Uint8List> _audioBuffer = [];
bool _isFeeding = false;
bool _isStarted = false;
Completer<void>? _feedCompleter; // Tracks when current feed operation completes
// Error callback for operation failures
PcmErrorCallback? onError;
// Maximum chunks to feed at once to keep native buffer small (~200ms)
// Each chunk is ~25ms of audio, so 8 chunks = ~200ms
// Reduced from 12 (300ms) to 8 for faster pause response
static const int _maxChunksPerFeed = 8;
// Feed threshold - lower = faster pause but more risk of underruns
// Reduced from 8000 to 5000 frames (~104ms vs ~166ms) for faster response
static const int _feedThreshold = 5000;
// Stats
int _framesPlayed = 0;
int _bytesPlayed = 0;
// Elapsed time tracking for notification sync
Timer? _elapsedTimeTimer;
final _elapsedTimeController = StreamController<Duration>.broadcast();
ElapsedTimeCallback? onElapsedTimeUpdate;
// Track offset for pause/resume (preserves position across pause cycles)
int _bytesPlayedAtLastPause = 0;
DateTime? _playbackStartTime;
PcmPlayerState get state => _state;
bool get isPlaying => _state == PcmPlayerState.playing;
bool get isPaused => _state == PcmPlayerState.paused;
bool get isReady => _state == PcmPlayerState.ready || _state == PcmPlayerState.playing || _state == PcmPlayerState.paused;
int get framesPlayed => _framesPlayed;
int get bytesPlayed => _bytesPlayed;
/// Check if player is in a transitional state (operation in progress)
bool get isTransitioning => _state == PcmPlayerState.pausing ||
_state == PcmPlayerState.resuming ||
_state == PcmPlayerState.stopping;
/// Check if feeding should be blocked
bool get _shouldBlockFeeding => isTransitioning || _state == PcmPlayerState.paused;
/// Stream of elapsed time updates (emits every 500ms when playing)
Stream<Duration> get elapsedTimeStream => _elapsedTimeController.stream;
/// Calculate elapsed playback time from bytes played
/// For 48kHz stereo 16-bit: 4 bytes per frame, 48000 frames per second
Duration get elapsedTime {
// Bytes per frame = channels * (bitDepth / 8) = 2 * 2 = 4
final bytesPerFrame = _format.channels * (_format.bitDepth ~/ 8);
final framesFromBytes = _bytesPlayed / bytesPerFrame;
final elapsedSeconds = framesFromBytes / _format.sampleRate;
return Duration(milliseconds: (elapsedSeconds * 1000).round());
}
/// Get elapsed time in seconds (for convenience)
double get elapsedTimeSeconds {
final bytesPerFrame = _format.channels * (_format.bitDepth ~/ 8);
final framesFromBytes = _bytesPlayed / bytesPerFrame;
return framesFromBytes / _format.sampleRate;
}
/// Initialize the PCM player with the given format
Future<bool> initialize({PcmAudioFormat? format}) async {
if (_state == PcmPlayerState.initializing) return false;
_format = format ?? PcmAudioFormat.sendspin;
_state = PcmPlayerState.initializing;
try {
_logger.log('PcmAudioPlayer: Initializing (${_format.sampleRate}Hz, ${_format.channels}ch, ${_format.bitDepth}bit)');
// Setup flutter_pcm_sound with Sendspin audio format
await pcm.FlutterPcmSound.setup(
sampleRate: _format.sampleRate,
channelCount: _format.channels,
);
// Set feed threshold - request more data when buffer has fewer frames
// Lower threshold = lower latency but more risk of underruns
// Using _feedThreshold (5000 frames = ~104ms) for faster pause response
await pcm.FlutterPcmSound.setFeedThreshold(_feedThreshold);
// Set up feed callback for when buffer needs more data
pcm.FlutterPcmSound.setFeedCallback(_onFeedRequested);
// Set log level for debugging
await pcm.FlutterPcmSound.setLogLevel(pcm.LogLevel.standard);
_state = PcmPlayerState.ready;
_logger.log('PcmAudioPlayer: Initialized successfully');
return true;
} catch (e) {
_logger.log('PcmAudioPlayer: Initialization failed: $e');
_state = PcmPlayerState.error;
return false;
}
}
/// Callback when flutter_pcm_sound needs more audio data
void _onFeedRequested(int remainingFrames) {
// This is called from native when buffer is getting low
// Block feeding during pause, transitional states, or when not playing
if (_state != PcmPlayerState.playing) return;
if (_shouldBlockFeeding) return;
if (_audioBuffer.isEmpty || _isFeeding) return;
_feedNextChunk();
}
/// Connect to a Sendspin audio data stream and start playback
Future<bool> connectToStream(Stream<Uint8List> audioStream) async {
if (_state == PcmPlayerState.error || _state == PcmPlayerState.idle) {
_logger.log('PcmAudioPlayer: Cannot connect - player not initialized');
return false;
}
// Cancel any existing subscription
await _audioSubscription?.cancel();
_logger.log('PcmAudioPlayer: Connecting to audio stream');
// Subscribe to the audio stream
_audioSubscription = audioStream.listen(
_onAudioData,
onError: _onStreamError,
onDone: _onStreamDone,
);
return true;
}
/// Handle incoming audio data from the stream
void _onAudioData(Uint8List audioData) {
// Ignore data if in error state or if in transitional/paused state
if (_state == PcmPlayerState.error) return;
if (_shouldBlockFeeding) {
// Silently ignore audio data when paused or transitioning
// This prevents buffer accumulation during pause and any race conditions
return;
}
// Add to buffer
_audioBuffer.add(audioData);
// Start playback if not already started
if (!_isStarted && _state == PcmPlayerState.ready) {
_startPlayback();
}
// NOTE: Auto-resume from paused state has been removed to prevent race conditions.
// The provider should explicitly call play() when ready to resume.
// Feed data if not currently feeding
if (!_isFeeding && _isStarted) {
_feedNextChunk();
}
}
/// Start audio playback
Future<void> _startPlayback() async {
if (_isStarted) return;
try {
await pcm.FlutterPcmSound.start();
_isStarted = true;
_state = PcmPlayerState.playing;
_playbackStartTime = DateTime.now();
_startElapsedTimeTimer();
_logger.log('PcmAudioPlayer: Started playback');
} catch (e) {
_logger.log('PcmAudioPlayer: Error starting playback: $e');
_state = PcmPlayerState.error;
}
}
/// Start the elapsed time update timer
void _startElapsedTimeTimer() {
_elapsedTimeTimer?.cancel();
_elapsedTimeTimer = Timer.periodic(
const Duration(milliseconds: 500),
(_) => _emitElapsedTime(),
);
// Emit immediately
_emitElapsedTime();
}
/// Stop the elapsed time update timer
void _stopElapsedTimeTimer() {
_elapsedTimeTimer?.cancel();
_elapsedTimeTimer = null;
}
/// Emit the current elapsed time to listeners
void _emitElapsedTime() {
if (_state != PcmPlayerState.playing) return;
final elapsed = elapsedTime;
if (!_elapsedTimeController.isClosed) {
_elapsedTimeController.add(elapsed);
}
onElapsedTimeUpdate?.call(elapsed);
}
/// Feed the next chunk of audio data to the player
/// CRITICAL: Only feeds limited chunks to keep native buffer small (~200ms)
/// This enables faster pause response by minimizing buffered audio
Future<void> _feedNextChunk() async {
if (_isFeeding || _audioBuffer.isEmpty || _shouldBlockFeeding) return;
_isFeeding = true;
_feedCompleter = Completer<void>();
try {
int chunksProcessed = 0;
// CRITICAL: Only feed limited chunks to keep native buffer small
// This is the key to fast pause - we drain ~200ms instead of 5-8 seconds
while (_audioBuffer.isNotEmpty &&
chunksProcessed < _maxChunksPerFeed &&
_state == PcmPlayerState.playing &&
!_shouldBlockFeeding) {
final chunk = _audioBuffer.removeAt(0);
// Convert Uint8List (raw bytes) to Int16 samples
// Sendspin sends 16-bit little-endian PCM
final samples = _bytesToInt16List(chunk);
// Check state again before the async feed call
if (samples.isNotEmpty && !_shouldBlockFeeding) {
await pcm.FlutterPcmSound.feed(pcm.PcmArrayInt16.fromList(samples));
// Don't update stats if we got paused during the feed
if (!_shouldBlockFeeding) {
_framesPlayed++;
_bytesPlayed += chunk.length;
chunksProcessed++;
// Log periodically
if (_framesPlayed % 100 == 0) {
_logger.log('PcmAudioPlayer: Played $_framesPlayed frames (${(_bytesPlayed / 1024).toStringAsFixed(1)} KB)');
}
}
}
// Yield to event loop mid-feed to allow UI to respond
if (chunksProcessed % 4 == 0) {
await Future.delayed(Duration.zero);
}
}
} catch (e) {
// Don't log errors during pause/transition - expected when player is released
if (!_shouldBlockFeeding) {
_logger.log('PcmAudioPlayer: Error feeding audio: $e');
_emitError(PcmPlayerError.feedFailed, e.toString());
}
}
_isFeeding = false;
_feedCompleter?.complete();
_feedCompleter = null;
}
/// Emit an error to the callback
void _emitError(PcmPlayerError error, String message) {
_logger.log('PcmAudioPlayer: Error - $error: $message');
onError?.call(error, message);
}
/// Convert raw bytes (Uint8List) to Int16 samples
/// Assumes little-endian 16-bit PCM
List<int> _bytesToInt16List(Uint8List bytes) {
if (bytes.length < 2) return [];
final byteData = ByteData.sublistView(bytes);
final samples = <int>[];
for (int i = 0; i < bytes.length - 1; i += 2) {
samples.add(byteData.getInt16(i, Endian.little));
}
return samples;
}
/// Handle stream errors - notify listeners and pause playback
void _onStreamError(dynamic error) {
_logger.log('PcmAudioPlayer: Stream error: $error');
_emitError(PcmPlayerError.streamError, error.toString());
// Pause on stream error to prevent playback of corrupted/incomplete audio
if (_state == PcmPlayerState.playing) {
_logger.log('PcmAudioPlayer: Pausing due to stream error');
pause();
}
}
/// Handle stream completion
void _onStreamDone() {
_logger.log('PcmAudioPlayer: Audio stream ended');
// Don't stop immediately - let buffered audio finish
}
/// Start playback (if paused or ready)
/// Returns true if playback started successfully
Future<bool> play() async {
if (_state == PcmPlayerState.error) return false;
if (isTransitioning) {
_logger.log('PcmAudioPlayer: Cannot play - operation in progress (state: $_state)');
return false;
}
if (_state == PcmPlayerState.paused) {
// Resume from pause - need to re-initialize since we released on pause
_logger.log('PcmAudioPlayer: Resuming from pause at ${elapsedTime.inSeconds}s');
_state = PcmPlayerState.resuming;
// Re-initialize the PCM player (it was released on pause)
try {
await pcm.FlutterPcmSound.setup(
sampleRate: _format.sampleRate,
channelCount: _format.channels,
);
await pcm.FlutterPcmSound.setFeedThreshold(_feedThreshold);
pcm.FlutterPcmSound.setFeedCallback(_onFeedRequested);
await pcm.FlutterPcmSound.start();
_isStarted = true;
} catch (e) {
_logger.log('PcmAudioPlayer: Error re-initializing on resume: $e');
_emitError(PcmPlayerError.resumeFailed, e.toString());
_state = PcmPlayerState.error;
return false;
}
_state = PcmPlayerState.playing;
_startElapsedTimeTimer();
// Resume feeding - new audio will come from stream
if (!_isFeeding && _audioBuffer.isNotEmpty) {
_feedNextChunk();
}
_logger.log('PcmAudioPlayer: Resumed playback');
return true;
} else if (_state == PcmPlayerState.ready) {
await _startPlayback();
_logger.log('PcmAudioPlayer: Started playback');
// Start feeding
if (!_isFeeding && _audioBuffer.isNotEmpty) {
_feedNextChunk();
}
return true;
}
return false;
}
/// Pause playback - stops feeding and releases player for instant stop
/// Position is preserved via _bytesPlayed tracking
/// Uses release() to clear native buffer for instant audio stop
/// Returns true if pause was successful
Future<bool> pause() async {
if (_state != PcmPlayerState.playing) {
_logger.log('PcmAudioPlayer: Cannot pause - not playing (state: $_state)');
return false;
}
_logger.log('PcmAudioPlayer: Pause requested');
// Set pausing state FIRST to stop feed loop from starting new feeds
_state = PcmPlayerState.pausing;
// Clear our buffer - no more data will be fed
_audioBuffer.clear();
// Clear feed callback to prevent native from triggering more feeds
pcm.FlutterPcmSound.setFeedCallback(null);
// Mark as not feeding
_isFeeding = false;
// Save position and stop timer
_bytesPlayedAtLastPause = _bytesPlayed;
_stopElapsedTimeTimer();
// Schedule release() to clear native buffer - use Future.delayed to yield control
// This allows UI to update before the potentially blocking release() call
unawaited(Future.delayed(Duration.zero, () async {
try {
await pcm.FlutterPcmSound.release().timeout(
const Duration(milliseconds: 500),
onTimeout: () {
_logger.log('PcmAudioPlayer: Release timed out');
_emitError(PcmPlayerError.releaseTimeout, 'release() timed out');
},
);
_isStarted = false;
_logger.log('PcmAudioPlayer: Player released for instant stop');
} catch (e) {
_logger.log('PcmAudioPlayer: Release error (expected if already released): $e');
}
}));
// Transition to paused state
_state = PcmPlayerState.paused;
_logger.log('PcmAudioPlayer: Paused playback at ${elapsedTime.inSeconds}s');
return true;
}
/// Stop playback (clears buffer and resets position)
/// Returns true if stop was successful
Future<bool> stop() async {
if (_state == PcmPlayerState.idle) return true;
_logger.log('PcmAudioPlayer: Stop requested');
// Set stopping state to block any in-flight feed operations
_state = PcmPlayerState.stopping;
_isStarted = false;
_stopElapsedTimeTimer();
// Clear the feed callback to prevent native code from triggering new feeds
pcm.FlutterPcmSound.setFeedCallback(null);
// Wait for any in-progress feed operation to complete (with timeout)
final completer = _feedCompleter;
if (completer != null && !completer.isCompleted) {
await completer.future.timeout(
const Duration(milliseconds: 500),
onTimeout: () {
_logger.log('PcmAudioPlayer: Feed wait timed out on stop');
},
);
}
_audioBuffer.clear();
_isFeeding = false;
try {
await pcm.FlutterPcmSound.release();
} catch (e) {
_logger.log('PcmAudioPlayer: Error releasing: $e');
}
_state = PcmPlayerState.ready;
_framesPlayed = 0;
_bytesPlayed = 0;
_bytesPlayedAtLastPause = 0;
_playbackStartTime = null;
_logger.log('PcmAudioPlayer: Stopped playback');
// Re-initialize for next playback
await initialize(format: _format);
return true;
}
/// Reset position to zero (for new track) without stopping playback
void resetPosition() {
_framesPlayed = 0;
_bytesPlayed = 0;
_bytesPlayedAtLastPause = 0;
_playbackStartTime = DateTime.now();
_logger.log('PcmAudioPlayer: Position reset to 0');
_emitElapsedTime();
}
/// Disconnect from audio stream
Future<void> disconnect() async {
await _audioSubscription?.cancel();
_audioSubscription = null;
await stop();
_logger.log('PcmAudioPlayer: Disconnected from stream');
}
/// Release all resources
Future<void> dispose() async {
_logger.log('PcmAudioPlayer: Disposing...');
// Set stopping state to block any feed operations
_state = PcmPlayerState.stopping;
_isStarted = false;
_stopElapsedTimeTimer();
// Clear the feed callback first
pcm.FlutterPcmSound.setFeedCallback(null);
// Wait for any in-progress feed operation
final completer = _feedCompleter;
if (completer != null && !completer.isCompleted) {
await completer.future.timeout(
const Duration(milliseconds: 500),
onTimeout: () {
_logger.log('PcmAudioPlayer: Feed wait timed out on dispose');
},
);
}
_isFeeding = false;
await _audioSubscription?.cancel();
_audioSubscription = null;
_audioBuffer.clear();
try {
await pcm.FlutterPcmSound.release().timeout(
const Duration(milliseconds: 500),
onTimeout: () {
_logger.log('PcmAudioPlayer: Release timed out on dispose');
},
);
} catch (e) {
_logger.log('PcmAudioPlayer: Error releasing: $e');
}
if (!_elapsedTimeController.isClosed) {
await _elapsedTimeController.close();
}
_state = PcmPlayerState.idle;
_logger.log('PcmAudioPlayer: Disposed');
}
}
+616
View File
@@ -0,0 +1,616 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/io.dart';
import 'debug_logger.dart';
import 'settings_service.dart';
import 'device_id_service.dart';
/// Connection state for Sendspin player
enum SendspinConnectionState {
disconnected,
connecting,
connected,
error,
}
/// Callback types for Sendspin events
typedef SendspinPlayCallback = void Function(String streamUrl, Map<String, dynamic> trackInfo);
typedef SendspinPauseCallback = void Function();
typedef SendspinStopCallback = void Function();
typedef SendspinSeekCallback = void Function(int positionSeconds);
typedef SendspinVolumeCallback = void Function(int volumeLevel);
typedef SendspinAudioDataCallback = void Function(Uint8List audioData);
typedef SendspinStreamStartCallback = void Function(Map<String, dynamic>? trackInfo);
typedef SendspinStreamEndCallback = void Function();
/// Service to manage Sendspin WebSocket connection for local playback
/// Sendspin is the replacement for builtin_player in MA 2.7.0b20+
///
/// Connection strategy (smart fallback for external access):
/// 1. If server is HTTPS, try external wss://{server}/sendspin first
/// 2. Fall back to local_ws_url from API (ws://local-ip:8927/sendspin)
/// 3. WebRTC fallback as last resort (requires TURN servers)
class SendspinService {
final String serverUrl;
final _logger = DebugLogger();
WebSocketChannel? _channel;
Timer? _heartbeatTimer;
Timer? _reconnectTimer;
// Connection state
SendspinConnectionState _state = SendspinConnectionState.disconnected;
SendspinConnectionState get state => _state;
final _stateController = StreamController<SendspinConnectionState>.broadcast();
Stream<SendspinConnectionState> get stateStream => _stateController.stream;
// Player info
String? _playerId;
String? _playerName;
String? _connectedUrl;
String? get playerId => _playerId;
String? get playerName => _playerName;
// Event callbacks
SendspinPlayCallback? onPlay;
SendspinPauseCallback? onPause;
SendspinStopCallback? onStop;
SendspinSeekCallback? onSeek;
SendspinVolumeCallback? onVolume;
SendspinAudioDataCallback? onAudioData;
SendspinStreamStartCallback? onStreamStart;
SendspinStreamEndCallback? onStreamEnd;
// Audio data stream for raw PCM frames
final _audioDataController = StreamController<Uint8List>.broadcast();
Stream<Uint8List> get audioDataStream => _audioDataController.stream;
// Track audio streaming state
bool _isStreamingAudio = false;
int _audioFramesReceived = 0;
// Player state for reporting to server
bool _isPowered = true;
bool _isPlaying = false;
bool _isPaused = false;
int _position = 0;
int _volume = 100;
bool _isMuted = false;
bool _isDisposed = false;
SendspinService(this.serverUrl);
/// Initialize and connect to Sendspin server
/// Uses persistent player ID and username-based naming
Future<bool> connect() async {
if (_isDisposed) return false;
if (_state == SendspinConnectionState.connected) return true;
_updateState(SendspinConnectionState.connecting);
try {
// Get persistent player ID and name
_playerId = await DeviceIdService.getOrCreateDevicePlayerId();
_playerName = await SettingsService.getLocalPlayerName();
_logger.log('Sendspin: Connecting as "$_playerName" (ID: $_playerId)');
// Try connection strategies in order
bool connected = false;
// Strategy 1: If server is HTTPS, try external wss:// first
if (_isHttpsServer()) {
final externalUrl = _buildExternalSendspinUrl();
_logger.log('Sendspin: Trying external URL: $externalUrl');
connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 3));
}
// Strategy 2: If external failed or server is HTTP, try local connection
// Note: We would need local_ws_url from getSendspinConnectionInfo API
// For now, skip this step and fall back to WebRTC (handled by provider)
if (!connected) {
_logger.log('Sendspin: External connection failed, WebRTC fallback needed');
_updateState(SendspinConnectionState.error);
return false;
}
return true;
} catch (e) {
_logger.log('Sendspin: Connection error: $e');
_updateState(SendspinConnectionState.error);
return false;
}
}
/// Connect with a specific WebSocket URL (called by provider with local_ws_url)
Future<bool> connectWithUrl(String wsUrl) async {
if (_isDisposed) return false;
if (_state == SendspinConnectionState.connected) return true;
_updateState(SendspinConnectionState.connecting);
try {
_playerId = await DeviceIdService.getOrCreateDevicePlayerId();
_playerName = await SettingsService.getLocalPlayerName();
_logger.log('Sendspin: Connecting with URL: $wsUrl');
final connected = await _tryConnect(wsUrl, timeout: const Duration(seconds: 5));
if (!connected) {
_updateState(SendspinConnectionState.error);
return false;
}
return true;
} catch (e) {
_logger.log('Sendspin: Connection error: $e');
_updateState(SendspinConnectionState.error);
return false;
}
}
/// Attempt to connect to a specific WebSocket URL
Future<bool> _tryConnect(String url, {Duration timeout = const Duration(seconds: 5)}) async {
try {
// Connect to the base URL without query params - we send player info in hello message
_logger.log('Sendspin: Connecting to $url');
// Create WebSocket connection
final webSocket = await WebSocket.connect(url).timeout(timeout);
_channel = IOWebSocketChannel(webSocket);
_connectedUrl = url;
// Set up message listener BEFORE sending hello
_channel!.stream.listen(
_handleMessage,
onError: _handleError,
onDone: _handleDone,
);
// Send client/hello message immediately after connecting
// This is required by the Sendspin protocol - server waits for this
// Message format follows aiosendspin protocol spec
_logger.log('Sendspin: Sending client/hello with player_id=$_playerId');
_sendMessage({
'type': 'client/hello',
'payload': {
'client_id': _playerId,
'name': _playerName,
'version': 1, // Protocol version (integer)
'supported_roles': ['player@v1'],
'player_support': {
'supported_formats': [
{
'codec': 'pcm',
'channels': 2,
'sample_rate': 48000,
'bit_depth': 16,
},
],
'buffer_capacity': 1048576, // 1MB buffer
'supported_commands': ['volume', 'mute'],
},
},
}, allowDuringHandshake: true);
// Wait for server acknowledgment (server sends welcome/registered after hello)
final ackReceived = await _waitForAck().timeout(
const Duration(seconds: 5),
onTimeout: () => false,
);
if (!ackReceived) {
_logger.log('Sendspin: No acknowledgment from server after hello');
await _channel?.sink.close();
_channel = null;
return false;
}
_logger.log('Sendspin: Connected and registered successfully');
_updateState(SendspinConnectionState.connected);
_startHeartbeat();
return true;
} catch (e) {
_logger.log('Sendspin: Connection attempt failed: $e');
return false;
}
}
/// Wait for server acknowledgment after connection
Completer<bool>? _ackCompleter;
Future<bool> _waitForAck() async {
_ackCompleter = Completer<bool>();
return _ackCompleter!.future;
}
/// Handle incoming WebSocket messages
/// WebSocket can send text frames (JSON control messages) or binary frames (PCM audio data)
void _handleMessage(dynamic message) {
// Check if this is binary audio data (Uint8List) or text (String)
if (message is List<int>) {
_handleBinaryAudioData(Uint8List.fromList(message));
return;
}
// Handle text messages (JSON)
if (message is! String) {
_logger.log('Sendspin: Unexpected message type: ${message.runtimeType}');
return;
}
try {
final data = jsonDecode(message) as Map<String, dynamic>;
final type = data['type'] as String?;
_logger.log('Sendspin: Received message type: $type');
switch (type) {
case 'server/hello':
// Server acknowledged our client/hello - we're registered!
_logger.log('Sendspin: Received server/hello - connection successful');
final payload = data['payload'] as Map<String, dynamic>?;
if (payload != null) {
_logger.log('Sendspin: Server name: ${payload['name']}, version: ${payload['version']}');
_logger.log('Sendspin: Active roles: ${payload['active_roles']}');
}
if (_ackCompleter != null && !_ackCompleter!.isCompleted) {
_ackCompleter!.complete(true);
}
// Send initial client/state immediately after handshake (required by spec)
// Note: These must use allowDuringHandshake since state is still 'connecting'
_sendInitialState();
// Also send initial time sync
_sendClientTime(allowDuringHandshake: true);
break;
case 'server/time':
// Server responded to our time sync - can be used to calculate clock offset
// For now, just log it (proper sync would use Kalman filter)
_logger.log('Sendspin: Received server/time response');
break;
case 'group/update':
// Group state update - store for reference but no response needed
_logger.log('Sendspin: Received group/update');
final groupPayload = data['payload'] as Map<String, dynamic>?;
if (groupPayload != null) {
_logger.log('Sendspin: Group state: ${groupPayload['playback_state']}');
}
break;
case 'stream/start':
// Server is starting to send audio data
_logger.log('Sendspin: Audio stream starting');
_isStreamingAudio = true;
_audioFramesReceived = 0;
_isPlaying = true;
_isPaused = false;
// Notify provider to start PCM player and foreground service
final streamPayload = data['payload'] as Map<String, dynamic>?;
onStreamStart?.call(streamPayload);
break;
case 'stream/end':
// Server finished sending audio data
_logger.log('Sendspin: Audio stream ended (received $_audioFramesReceived frames)');
_isStreamingAudio = false;
_isPlaying = false;
// Notify provider to stop PCM player
onStreamEnd?.call();
break;
case 'ack':
case 'connected':
case 'registered':
// Legacy/fallback acknowledgment types
if (_ackCompleter != null && !_ackCompleter!.isCompleted) {
_ackCompleter!.complete(true);
}
break;
case 'play':
// Server wants us to play audio (URL-based, not raw PCM)
final streamUrl = data['url'] as String?;
final trackInfo = data['track'] as Map<String, dynamic>? ?? {};
if (streamUrl != null && onPlay != null) {
_isPlaying = true;
_isPaused = false;
onPlay!(streamUrl, trackInfo);
}
break;
case 'pause':
_isPaused = true;
_isPlaying = false;
onPause?.call();
break;
case 'stop':
_isPlaying = false;
_isPaused = false;
_isStreamingAudio = false;
onStop?.call();
break;
case 'seek':
final position = data['position'] as int?;
if (position != null) {
_position = position;
onSeek?.call(position);
}
break;
case 'volume':
final level = data['level'] as int?;
if (level != null) {
_volume = level;
onVolume?.call(level);
}
break;
case 'ping':
// Respond to server ping
_sendMessage({'type': 'pong'});
break;
case 'error':
final errorMsg = data['message'] as String?;
_logger.log('Sendspin: Server error: $errorMsg');
break;
default:
_logger.log('Sendspin: Unknown message type: $type');
}
} catch (e) {
_logger.log('Sendspin: Error handling JSON message: $e');
}
}
/// Handle binary audio data (PCM frames from server)
/// Sendspin binary frame format:
/// - Byte 0: message type (uint8) - Type 4 = audio data
/// - Bytes 1-8: timestamp (int64 microseconds, little-endian) for synchronization
/// - Bytes 9+: actual PCM audio data (16-bit stereo 48kHz)
void _handleBinaryAudioData(Uint8List frame) {
// Minimum frame size: 1 (type) + 8 (timestamp) + 2 (at least one sample)
if (frame.length < 11) {
_logger.log('Sendspin: Binary frame too short: ${frame.length} bytes');
return;
}
// Parse header
final messageType = frame[0];
// Type 4 = audio data, other types may be used for other purposes
if (messageType != 4) {
_logger.log('Sendspin: Unexpected binary message type: $messageType');
return;
}
// Extract timestamp (bytes 1-8, little-endian int64)
final timestampBytes = ByteData.sublistView(frame, 1, 9);
final timestamp = timestampBytes.getInt64(0, Endian.little);
// Extract actual PCM audio data (everything after the 9-byte header)
final audioData = Uint8List.sublistView(frame, 9);
_audioFramesReceived++;
// Log periodically to avoid spam
if (_audioFramesReceived == 1) {
_logger.log('Sendspin: Receiving audio data (first frame: ${frame.length} bytes, audio: ${audioData.length} bytes, ts: $timestamp)');
} else if (_audioFramesReceived % 100 == 0) {
_logger.log('Sendspin: Received $_audioFramesReceived audio frames');
}
// Emit only the PCM audio data (without header) to stream for consumers
if (!_audioDataController.isClosed) {
_audioDataController.add(audioData);
}
// Call callback if registered
onAudioData?.call(audioData);
}
/// Handle WebSocket errors
void _handleError(dynamic error) {
_logger.log('Sendspin: WebSocket error: $error');
_updateState(SendspinConnectionState.error);
_scheduleReconnect();
}
/// Handle WebSocket close
void _handleDone() {
_logger.log('Sendspin: WebSocket closed');
_updateState(SendspinConnectionState.disconnected);
_scheduleReconnect();
}
/// Send a JSON message to the server
/// allowDuringHandshake: set to true to send messages before connection is established (e.g., hello)
void _sendMessage(Map<String, dynamic> message, {bool allowDuringHandshake = false}) {
if (_channel == null) return;
// During handshake, we need to send hello even though state is still 'connecting'
if (!allowDuringHandshake && _state != SendspinConnectionState.connected) return;
try {
final json = jsonEncode(message);
_logger.log('Sendspin: Sending message: ${message['type']}');
_channel!.sink.add(json);
} catch (e) {
_logger.log('Sendspin: Error sending message: $e');
}
}
/// Report current player state to server
/// Uses Sendspin protocol 'client/state' message format
void reportState({
bool? powered,
bool? playing,
bool? paused,
int? position,
int? volume,
bool? muted,
}) {
if (powered != null) _isPowered = powered;
if (playing != null) _isPlaying = playing;
if (paused != null) _isPaused = paused;
if (position != null) _position = position;
if (volume != null) _volume = volume;
if (muted != null) _isMuted = muted;
// Determine player state for Sendspin protocol
// 'synchronized' = ready/playing, 'idle' = not playing
final playerState = _isPlaying || _isPaused ? 'synchronized' : 'idle';
_sendMessage({
'type': 'client/state',
'payload': {
'player': {
'state': playerState,
'volume': _volume,
'muted': _isMuted,
},
},
});
}
/// Check if server URL is HTTPS
bool _isHttpsServer() {
return serverUrl.startsWith('https://') ||
serverUrl.startsWith('wss://') ||
(!serverUrl.contains('://') && !serverUrl.contains(':'));
}
/// Build external Sendspin WebSocket URL from server URL
String _buildExternalSendspinUrl() {
var url = serverUrl;
// Convert HTTP(S) to WS(S)
if (url.startsWith('https://')) {
url = 'wss://${url.substring(8)}';
} else if (url.startsWith('http://')) {
url = 'ws://${url.substring(7)}';
} else if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
url = 'wss://$url';
}
// Remove trailing slash and add /sendspin path
url = url.replaceAll(RegExp(r'/+$'), '');
// Remove any existing path and add /sendspin
final uri = Uri.parse(url);
return Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
path: '/sendspin',
).toString();
}
/// Start heartbeat timer using Sendspin's client/time for clock synchronization
void _startHeartbeat() {
_stopHeartbeat();
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (_) {
_sendClientTime();
});
}
/// Send client/time message for clock synchronization (Sendspin protocol)
void _sendClientTime({bool allowDuringHandshake = false}) {
final timestampMicroseconds = DateTime.now().microsecondsSinceEpoch;
_sendMessage({
'type': 'client/time',
'payload': {
'timestamp': timestampMicroseconds,
},
}, allowDuringHandshake: allowDuringHandshake);
}
/// Send initial client/state immediately after handshake (required by Sendspin spec)
/// Must use allowDuringHandshake since state is still 'connecting' at this point
void _sendInitialState() {
_logger.log('Sendspin: Sending initial client/state');
_sendMessage({
'type': 'client/state',
'payload': {
'player': {
'state': 'synchronized',
'volume': _volume,
'muted': _isMuted,
},
},
}, allowDuringHandshake: true);
}
/// Stop heartbeat timer
void _stopHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
}
/// Schedule reconnection attempt
void _scheduleReconnect() {
if (_isDisposed) return;
_reconnectTimer?.cancel();
_reconnectTimer = Timer(const Duration(seconds: 5), () {
if (!_isDisposed && _state != SendspinConnectionState.connected) {
_logger.log('Sendspin: Attempting reconnection...');
if (_connectedUrl != null) {
connectWithUrl(_connectedUrl!);
}
}
});
}
/// Update connection state
void _updateState(SendspinConnectionState newState) {
if (_state != newState) {
_state = newState;
_stateController.add(newState);
}
}
/// Disconnect from server using Sendspin's client/goodbye message
Future<void> disconnect() async {
_stopHeartbeat();
_reconnectTimer?.cancel();
if (_channel != null) {
// Send graceful goodbye per Sendspin protocol
_sendMessage({
'type': 'client/goodbye',
'payload': {
'reason': 'user_request',
},
});
await _channel!.sink.close();
_channel = null;
}
_updateState(SendspinConnectionState.disconnected);
}
/// Dispose the service
void dispose() {
_isDisposed = true;
_stopHeartbeat();
_reconnectTimer?.cancel();
_channel?.sink.close();
_stateController.close();
_audioDataController.close();
}
}
+36
View File
@@ -1347,6 +1347,42 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
),
),
// Audio source/format info (expanded only, when Sendspin active)
// Shows "Playing from Sendspin" and audio format
if (t > 0.5 && maProvider.isSendspinConnected)
Positioned(
left: contentPadding,
right: contentPadding,
top: expandedAlbumTop + (currentTrack.album != null ? 24 : 0),
child: FadeTransition(
opacity: _expandAnimation.drive(
Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: const Interval(0.5, 1.0, curve: Curves.easeIn)),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.graphic_eq_rounded,
size: 14,
color: primaryColor.withOpacity(0.7),
),
const SizedBox(width: 6),
Text(
maProvider.currentAudioFormat ?? 'PCM Audio',
style: TextStyle(
color: primaryColor.withOpacity(0.7),
fontSize: 12,
fontWeight: FontWeight.w500,
letterSpacing: 0.3,
),
),
],
),
),
),
// Progress bar (expanded only)
// GPU PERF: Use FadeTransition instead of Opacity
if (t > 0.5 && currentTrack.duration != null)
+4 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 1.9.3+14
version: 2.0.0-beta+15
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -38,6 +38,9 @@ dependencies:
audio_session: ^0.1.18
flutter_volume_controller: ^1.3.2
# Raw PCM Audio Playback (for Sendspin streaming)
flutter_pcm_sound: ^3.3.3
dev_dependencies:
flutter_test:
sdk: flutter