Compare commits
31
Commits
@@ -8,8 +8,10 @@ class Timings {
|
|||||||
/// Player state polling interval (for selected player updates)
|
/// Player state polling interval (for selected player updates)
|
||||||
static const Duration playerPollingInterval = Duration(seconds: 5);
|
static const Duration playerPollingInterval = Duration(seconds: 5);
|
||||||
|
|
||||||
/// Local player state report interval (for seek bar smoothness)
|
/// Local player state report interval
|
||||||
static const Duration localPlayerReportInterval = Duration(seconds: 1);
|
/// 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
|
/// Player list cache duration before refresh
|
||||||
static const Duration playersCacheDuration = Duration(minutes: 5);
|
static const Duration playersCacheDuration = Duration(minutes: 5);
|
||||||
|
|||||||
@@ -27,6 +27,35 @@ class Player {
|
|||||||
this.elapsedTimeLastUpdated,
|
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
|
// Derived properties
|
||||||
bool get isPlaying => state == 'playing';
|
bool get isPlaying => state == 'playing';
|
||||||
bool get isMuted => volumeMuted ?? false;
|
bool get isMuted => volumeMuted ?? false;
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import '../services/cache_service.dart';
|
|||||||
import '../services/local_player_service.dart';
|
import '../services/local_player_service.dart';
|
||||||
import '../services/metadata_service.dart';
|
import '../services/metadata_service.dart';
|
||||||
import '../services/position_tracker.dart';
|
import '../services/position_tracker.dart';
|
||||||
|
import '../services/sendspin_service.dart';
|
||||||
|
import '../services/pcm_audio_player.dart';
|
||||||
import '../constants/timings.dart';
|
import '../constants/timings.dart';
|
||||||
import '../main.dart' show audioHandler;
|
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)
|
bool _builtinPlayerAvailable = true; // False on MA 2.7.0b20+ (uses Sendspin instead)
|
||||||
StreamSubscription? _localPlayerEventSubscription;
|
StreamSubscription? _localPlayerEventSubscription;
|
||||||
StreamSubscription? _playerUpdatedEventSubscription;
|
StreamSubscription? _playerUpdatedEventSubscription;
|
||||||
|
StreamSubscription? _playerAddedEventSubscription;
|
||||||
Timer? _localPlayerStateReportTimer;
|
Timer? _localPlayerStateReportTimer;
|
||||||
TrackMetadata? _pendingTrackMetadata;
|
TrackMetadata? _pendingTrackMetadata;
|
||||||
TrackMetadata? _currentNotificationMetadata;
|
TrackMetadata? _currentNotificationMetadata;
|
||||||
@@ -67,6 +70,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// Local player service
|
// Local player service
|
||||||
late final LocalPlayerService _localPlayer;
|
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
|
// Search state persistence
|
||||||
String _lastSearchQuery = '';
|
String _lastSearchQuery = '';
|
||||||
Map<String, List<MediaItem>> _lastSearchResults = {
|
Map<String, List<MediaItem>> _lastSearchResults = {
|
||||||
@@ -103,6 +113,27 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
/// Position tracker for playback progress - single source of truth
|
/// Position tracker for playback progress - single source of truth
|
||||||
PositionTracker get positionTracker => _positionTracker;
|
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)
|
/// Get cached track for a player (used for smooth swipe transitions)
|
||||||
Track? getCachedTrackForPlayer(String playerId) => _cacheService.getCachedTrackForPlayer(playerId);
|
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'),
|
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();
|
await _api!.connect();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -324,7 +361,14 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_localPlayerStateReportTimer?.cancel();
|
_localPlayerStateReportTimer?.cancel();
|
||||||
_localPlayerEventSubscription?.cancel();
|
_localPlayerEventSubscription?.cancel();
|
||||||
_playerUpdatedEventSubscription?.cancel();
|
_playerUpdatedEventSubscription?.cancel();
|
||||||
|
_playerAddedEventSubscription?.cancel();
|
||||||
_positionTracker.clear();
|
_positionTracker.clear();
|
||||||
|
// Disconnect Sendspin and PCM player if connected
|
||||||
|
if (_sendspinConnected) {
|
||||||
|
await _pcmAudioPlayer?.disconnect();
|
||||||
|
await _sendspinService?.disconnect();
|
||||||
|
_sendspinConnected = false;
|
||||||
|
}
|
||||||
await _api?.disconnect();
|
await _api?.disconnect();
|
||||||
_connectionState = MAConnectionState.disconnected;
|
_connectionState = MAConnectionState.disconnected;
|
||||||
_artists = [];
|
_artists = [];
|
||||||
@@ -477,13 +521,25 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
final errorStr = e.toString();
|
final errorStr = e.toString();
|
||||||
if (errorStr.contains('Invalid command') && errorStr.contains('builtin_player')) {
|
if (errorStr.contains('Invalid command') && errorStr.contains('builtin_player')) {
|
||||||
_logger.log('⚠️ Builtin player API not available (MA 2.7.0b20+ uses Sendspin)');
|
_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;
|
_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) {
|
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
|
||||||
_registrationInProgress!.complete();
|
_registrationInProgress!.complete();
|
||||||
}
|
}
|
||||||
_registrationInProgress = null;
|
_registrationInProgress = null;
|
||||||
return; // Non-fatal, continue without local player
|
return; // Non-fatal, continue
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('❌ Player registration failed: $e');
|
_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() {
|
void _startReportingLocalPlayerState() {
|
||||||
_localPlayerStateReportTimer?.cancel();
|
_localPlayerStateReportTimer?.cancel();
|
||||||
_localPlayerStateReportTimer = Timer.periodic(Timings.localPlayerReportInterval, (_) async {
|
_localPlayerStateReportTimer = Timer.periodic(Timings.localPlayerReportInterval, (_) async {
|
||||||
@@ -508,7 +835,6 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> _reportLocalPlayerState() async {
|
Future<void> _reportLocalPlayerState() async {
|
||||||
if (_api == null) return;
|
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
|
// Don't try to report state if not authenticated - avoids spamming errors
|
||||||
if (_api!.currentConnectionState != MAConnectionState.authenticated) return;
|
if (_api!.currentConnectionState != MAConnectionState.authenticated) return;
|
||||||
@@ -523,6 +849,22 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
final volume = _localPlayerVolume;
|
final volume = _localPlayerVolume;
|
||||||
final isPaused = !isPlaying && position > 0;
|
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(
|
await _api!.updateBuiltinPlayerState(
|
||||||
playerId,
|
playerId,
|
||||||
powered: _isLocalPlayerPowered,
|
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 {
|
Future<void> _handlePlayerUpdatedEvent(Map<String, dynamic> event) async {
|
||||||
try {
|
try {
|
||||||
final playerId = event['player_id'] as String?;
|
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
|
// 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) =>
|
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();
|
).toList();
|
||||||
|
|
||||||
if (activePlayers.isEmpty) {
|
if (activePlayers.isEmpty) {
|
||||||
@@ -1325,7 +1687,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
selectPlayer(nextPlayer);
|
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,
|
/// This timer updates the notification position every second using interpolated time,
|
||||||
/// making the progress bar smooth instead of jumping every 5 seconds (polling interval).
|
/// making the progress bar smooth instead of jumping every 5 seconds (polling interval).
|
||||||
void _manageNotificationPositionTimer() {
|
void _manageNotificationPositionTimer() {
|
||||||
@@ -1333,24 +1695,30 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
if (_selectedPlayer == null || _currentTrack == null) return;
|
if (_selectedPlayer == null || _currentTrack == null) return;
|
||||||
|
|
||||||
// Check if this is a builtin/local player (doesn't need timer - just_audio handles position)
|
// Check if this is a builtin/local player
|
||||||
// Note: We can't use async/await in this method easily, so check using pattern matching
|
|
||||||
final playerId = _selectedPlayer!.playerId;
|
final playerId = _selectedPlayer!.playerId;
|
||||||
if (playerId.startsWith('ensemble_')) {
|
if (playerId.startsWith('ensemble_')) {
|
||||||
// Local player - no timer needed, just_audio handles position automatically
|
// This is a local player ID, but we need to check if it's using Sendspin PCM
|
||||||
return;
|
// 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;
|
if (_selectedPlayer!.state != 'playing') return;
|
||||||
|
|
||||||
_notificationPositionTimer = Timer.periodic(
|
_notificationPositionTimer = Timer.periodic(
|
||||||
const Duration(seconds: 1),
|
const Duration(milliseconds: 500), // 500ms for smoother progress
|
||||||
(_) => _updateNotificationPosition(),
|
(_) => _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() {
|
void _updateNotificationPosition() {
|
||||||
if (_selectedPlayer == null || _currentTrack == null) {
|
if (_selectedPlayer == null || _currentTrack == null) {
|
||||||
_notificationPositionTimer?.cancel();
|
_notificationPositionTimer?.cancel();
|
||||||
@@ -1363,15 +1731,28 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use position tracker for consistent position (single source of truth)
|
|
||||||
final position = _positionTracker.currentPosition;
|
|
||||||
final track = _currentTrack!;
|
final track = _currentTrack!;
|
||||||
|
Duration position;
|
||||||
|
|
||||||
// Check if track has ended (position reached duration)
|
// For Sendspin PCM, use the PCM player's elapsed time (based on bytes played)
|
||||||
if (_positionTracker.hasReachedEnd) {
|
// For remote players, use the position tracker (server-based interpolation)
|
||||||
_logger.log('PositionTracker: Track appears to have ended (position >= duration)');
|
if (_sendspinConnected && _pcmAudioPlayer != null && _pcmAudioPlayer!.isPlaying) {
|
||||||
// Don't update notification, let the next poll handle the state change
|
position = _pcmAudioPlayer!.elapsedTime;
|
||||||
return;
|
|
||||||
|
// 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);
|
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
||||||
@@ -1688,13 +2069,17 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
Future<void> playPauseSelectedPlayer() async {
|
Future<void> playPauseSelectedPlayer() async {
|
||||||
if (_selectedPlayer == null) return;
|
if (_selectedPlayer == null) return;
|
||||||
|
|
||||||
if (_selectedPlayer!.isPlaying) {
|
final wasPlaying = _selectedPlayer!.isPlaying;
|
||||||
|
|
||||||
|
if (wasPlaying) {
|
||||||
await pausePlayer(_selectedPlayer!.playerId);
|
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 {
|
} else {
|
||||||
await resumePlayer(_selectedPlayer!.playerId);
|
await resumePlayer(_selectedPlayer!.playerId);
|
||||||
|
// For resume: Refresh in background to get updated track info
|
||||||
|
unawaited(refreshPlayers());
|
||||||
}
|
}
|
||||||
|
|
||||||
await refreshPlayers();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> nextTrackSelectedPlayer() async {
|
Future<void> nextTrackSelectedPlayer() async {
|
||||||
@@ -1943,15 +2328,42 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> pausePlayer(String playerId) async {
|
Future<void> pausePlayer(String playerId) async {
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
ErrorHandler.logError('Pause player', e);
|
ErrorHandler.logError('Pause player', e);
|
||||||
rethrow;
|
// Don't rethrow - we want pause to be resilient
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> resumePlayer(String playerId) async {
|
Future<void> resumePlayer(String playerId) async {
|
||||||
try {
|
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);
|
await _api?.resumePlayer(playerId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ErrorHandler.logError('Resume player', e);
|
ErrorHandler.logError('Resume player', e);
|
||||||
@@ -1961,6 +2373,14 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> nextTrack(String playerId) async {
|
Future<void> nextTrack(String playerId) async {
|
||||||
try {
|
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);
|
await _api?.nextTrack(playerId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ErrorHandler.logError('Next track', e);
|
ErrorHandler.logError('Next track', e);
|
||||||
@@ -1970,6 +2390,14 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> previousTrack(String playerId) async {
|
Future<void> previousTrack(String playerId) async {
|
||||||
try {
|
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);
|
await _api?.previousTrack(playerId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ErrorHandler.logError('Previous track', e);
|
ErrorHandler.logError('Previous track', e);
|
||||||
@@ -2111,7 +2539,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_localPlayerStateReportTimer?.cancel();
|
_localPlayerStateReportTimer?.cancel();
|
||||||
_localPlayerEventSubscription?.cancel();
|
_localPlayerEventSubscription?.cancel();
|
||||||
_playerUpdatedEventSubscription?.cancel();
|
_playerUpdatedEventSubscription?.cancel();
|
||||||
|
_playerAddedEventSubscription?.cancel();
|
||||||
_positionTracker.dispose();
|
_positionTracker.dispose();
|
||||||
|
_pcmAudioPlayer?.dispose();
|
||||||
|
_sendspinService?.dispose();
|
||||||
_api?.dispose();
|
_api?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// Playback errors
|
||||||
if (errorStr.contains('queue') ||
|
if (errorStr.contains('queue') ||
|
||||||
errorStr.contains('player') ||
|
errorStr.contains('player') ||
|
||||||
|
|||||||
@@ -1045,7 +1045,20 @@ class MusicAssistantAPI {
|
|||||||
/// If clearQueue is true, replaces the queue (default behavior)
|
/// If clearQueue is true, replaces the queue (default behavior)
|
||||||
/// If startIndex is provided, only tracks from that index onwards will be queued
|
/// 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 {
|
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 {
|
operation: () async {
|
||||||
// If startIndex is provided, slice the tracks list to start from that index
|
// 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
|
// This is a workaround since Music Assistant ignores the start_item parameter
|
||||||
@@ -1053,8 +1066,36 @@ class MusicAssistantAPI {
|
|||||||
? tracks.sublist(startIndex)
|
? tracks.sublist(startIndex)
|
||||||
: tracks;
|
: 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!)
|
// 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';
|
final option = clearQueue ? 'replace' : 'play';
|
||||||
|
|
||||||
@@ -1458,6 +1499,14 @@ class MusicAssistantAPI {
|
|||||||
return _eventStreams['player_updated']!.stream;
|
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
|
/// Register this device as a player with retry logic
|
||||||
/// CRITICAL: This creates a player config in MA's settings.json
|
/// CRITICAL: This creates a player config in MA's settings.json
|
||||||
/// The server expects: player_id and player_name
|
/// 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 {
|
Future<void> _sendQueueCommand(String queueId, String command) async {
|
||||||
try {
|
try {
|
||||||
final response = await _sendCommand(
|
final response = await _sendCommand(
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
// Progress bar (expanded only)
|
||||||
// GPU PERF: Use FadeTransition instead of Opacity
|
// GPU PERF: Use FadeTransition instead of Opacity
|
||||||
if (t > 0.5 && currentTrack.duration != null)
|
if (t > 0.5 && currentTrack.duration != null)
|
||||||
|
|||||||
+4
-1
@@ -1,7 +1,7 @@
|
|||||||
name: ensemble
|
name: ensemble
|
||||||
description: A minimalistic Music Assistant client built with Flutter
|
description: A minimalistic Music Assistant client built with Flutter
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.9.3+14
|
version: 2.0.0-beta+15
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
@@ -38,6 +38,9 @@ dependencies:
|
|||||||
audio_session: ^0.1.18
|
audio_session: ^0.1.18
|
||||||
flutter_volume_controller: ^1.3.2
|
flutter_volume_controller: ^1.3.2
|
||||||
|
|
||||||
|
# Raw PCM Audio Playback (for Sendspin streaming)
|
||||||
|
flutter_pcm_sound: ^3.3.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
Reference in New Issue
Block a user