Compare commits
31
Commits
@@ -8,8 +8,10 @@ class Timings {
|
||||
/// Player state polling interval (for selected player updates)
|
||||
static const Duration playerPollingInterval = Duration(seconds: 5);
|
||||
|
||||
/// Local player state report interval (for seek bar smoothness)
|
||||
static const Duration localPlayerReportInterval = Duration(seconds: 1);
|
||||
/// Local player state report interval
|
||||
/// Note: Sendspin protocol only requires state on changes, not periodic reports.
|
||||
/// This is mainly for local UI position tracking. Set to 5s to reduce server traffic.
|
||||
static const Duration localPlayerReportInterval = Duration(seconds: 5);
|
||||
|
||||
/// Player list cache duration before refresh
|
||||
static const Duration playersCacheDuration = Duration(minutes: 5);
|
||||
|
||||
@@ -27,6 +27,35 @@ class Player {
|
||||
this.elapsedTimeLastUpdated,
|
||||
});
|
||||
|
||||
/// Create a copy of this Player with some fields replaced
|
||||
Player copyWith({
|
||||
String? playerId,
|
||||
String? name,
|
||||
String? provider,
|
||||
bool? available,
|
||||
bool? powered,
|
||||
String? state,
|
||||
String? currentItemId,
|
||||
int? volumeLevel,
|
||||
bool? volumeMuted,
|
||||
double? elapsedTime,
|
||||
double? elapsedTimeLastUpdated,
|
||||
}) {
|
||||
return Player(
|
||||
playerId: playerId ?? this.playerId,
|
||||
name: name ?? this.name,
|
||||
provider: provider ?? this.provider,
|
||||
available: available ?? this.available,
|
||||
powered: powered ?? this.powered,
|
||||
state: state ?? this.state,
|
||||
currentItemId: currentItemId ?? this.currentItemId,
|
||||
volumeLevel: volumeLevel ?? this.volumeLevel,
|
||||
volumeMuted: volumeMuted ?? this.volumeMuted,
|
||||
elapsedTime: elapsedTime ?? this.elapsedTime,
|
||||
elapsedTimeLastUpdated: elapsedTimeLastUpdated ?? this.elapsedTimeLastUpdated,
|
||||
);
|
||||
}
|
||||
|
||||
// Derived properties
|
||||
bool get isPlaying => state == 'playing';
|
||||
bool get isMuted => volumeMuted ?? false;
|
||||
|
||||
@@ -16,6 +16,8 @@ import '../services/cache_service.dart';
|
||||
import '../services/local_player_service.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
import '../services/position_tracker.dart';
|
||||
import '../services/sendspin_service.dart';
|
||||
import '../services/pcm_audio_player.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../main.dart' show audioHandler;
|
||||
|
||||
@@ -59,6 +61,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
bool _builtinPlayerAvailable = true; // False on MA 2.7.0b20+ (uses Sendspin instead)
|
||||
StreamSubscription? _localPlayerEventSubscription;
|
||||
StreamSubscription? _playerUpdatedEventSubscription;
|
||||
StreamSubscription? _playerAddedEventSubscription;
|
||||
Timer? _localPlayerStateReportTimer;
|
||||
TrackMetadata? _pendingTrackMetadata;
|
||||
TrackMetadata? _currentNotificationMetadata;
|
||||
@@ -67,6 +70,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// Local player service
|
||||
late final LocalPlayerService _localPlayer;
|
||||
|
||||
// Sendspin service (MA 2.7.0b20+ replacement for builtin_player)
|
||||
SendspinService? _sendspinService;
|
||||
bool _sendspinConnected = false;
|
||||
|
||||
// PCM audio player for raw Sendspin audio streaming
|
||||
PcmAudioPlayer? _pcmAudioPlayer;
|
||||
|
||||
// Search state persistence
|
||||
String _lastSearchQuery = '';
|
||||
Map<String, List<MediaItem>> _lastSearchResults = {
|
||||
@@ -103,6 +113,27 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
/// Position tracker for playback progress - single source of truth
|
||||
PositionTracker get positionTracker => _positionTracker;
|
||||
|
||||
/// Whether Sendspin (PCM streaming) is connected for builtin player
|
||||
bool get isSendspinConnected => _sendspinConnected;
|
||||
|
||||
/// Whether PCM audio is currently playing via Sendspin
|
||||
bool get isPcmPlaying => _sendspinConnected && _pcmAudioPlayer != null && _pcmAudioPlayer!.isPlaying;
|
||||
|
||||
/// Get current PCM audio format info (when using Sendspin)
|
||||
/// Returns null if not using Sendspin PCM streaming
|
||||
String? get currentAudioFormat {
|
||||
if (!_sendspinConnected || _pcmAudioPlayer == null) return null;
|
||||
return '48kHz • Stereo • 16-bit PCM';
|
||||
}
|
||||
|
||||
/// Get the current playback source description
|
||||
String get playbackSource {
|
||||
if (_sendspinConnected && _pcmAudioPlayer != null) {
|
||||
return 'Sendspin (Local PCM)';
|
||||
}
|
||||
return 'Music Assistant';
|
||||
}
|
||||
|
||||
/// Get cached track for a player (used for smooth swipe transitions)
|
||||
Track? getCachedTrackForPlayer(String playerId) => _cacheService.getCachedTrackForPlayer(playerId);
|
||||
|
||||
@@ -209,6 +240,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
onError: (error) => _logger.log('Player updated event stream error: $error'),
|
||||
);
|
||||
|
||||
_playerAddedEventSubscription?.cancel();
|
||||
_playerAddedEventSubscription = _api!.playerAddedEvents.listen(
|
||||
_handlePlayerAddedEvent,
|
||||
onError: (error) => _logger.log('Player added event stream error: $error'),
|
||||
);
|
||||
|
||||
await _api!.connect();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
@@ -324,7 +361,14 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_localPlayerStateReportTimer?.cancel();
|
||||
_localPlayerEventSubscription?.cancel();
|
||||
_playerUpdatedEventSubscription?.cancel();
|
||||
_playerAddedEventSubscription?.cancel();
|
||||
_positionTracker.clear();
|
||||
// Disconnect Sendspin and PCM player if connected
|
||||
if (_sendspinConnected) {
|
||||
await _pcmAudioPlayer?.disconnect();
|
||||
await _sendspinService?.disconnect();
|
||||
_sendspinConnected = false;
|
||||
}
|
||||
await _api?.disconnect();
|
||||
_connectionState = MAConnectionState.disconnected;
|
||||
_artists = [];
|
||||
@@ -477,13 +521,25 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
final errorStr = e.toString();
|
||||
if (errorStr.contains('Invalid command') && errorStr.contains('builtin_player')) {
|
||||
_logger.log('⚠️ Builtin player API not available (MA 2.7.0b20+ uses Sendspin)');
|
||||
_logger.log('ℹ️ Local player registration skipped - use other players (Chromecast, etc)');
|
||||
_builtinPlayerAvailable = false;
|
||||
|
||||
// Try to connect via Sendspin instead
|
||||
_logger.log('🔄 Attempting Sendspin connection...');
|
||||
final sendspinSuccess = await _connectViaSendspin();
|
||||
|
||||
if (sendspinSuccess) {
|
||||
_logger.log('✅ Connected via Sendspin - local player available');
|
||||
_startReportingLocalPlayerState();
|
||||
} else {
|
||||
_logger.log('⚠️ Sendspin connection failed - local player unavailable');
|
||||
_logger.log('ℹ️ Use other players (Chromecast, etc) or ensure /sendspin route is configured');
|
||||
}
|
||||
|
||||
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
|
||||
_registrationInProgress!.complete();
|
||||
}
|
||||
_registrationInProgress = null;
|
||||
return; // Non-fatal, continue without local player
|
||||
return; // Non-fatal, continue
|
||||
}
|
||||
|
||||
_logger.log('❌ Player registration failed: $e');
|
||||
@@ -495,6 +551,277 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to Music Assistant via Sendspin protocol (MA 2.7.0b20+)
|
||||
/// This is the replacement for builtin_player when that API is not available.
|
||||
///
|
||||
/// Connection strategy:
|
||||
/// 1. If server is HTTPS, try external wss://{server}/sendspin first
|
||||
/// 2. Get local_ws_url from API and try that
|
||||
/// 3. WebRTC fallback as last resort (requires TURN servers)
|
||||
Future<bool> _connectViaSendspin() async {
|
||||
if (_api == null || _serverUrl == null) return false;
|
||||
|
||||
try {
|
||||
// Initialize Sendspin service
|
||||
_sendspinService?.dispose();
|
||||
_sendspinService = SendspinService(_serverUrl!);
|
||||
|
||||
// Initialize PCM audio player for raw audio streaming
|
||||
_pcmAudioPlayer?.dispose();
|
||||
_pcmAudioPlayer = PcmAudioPlayer();
|
||||
final pcmInitialized = await _pcmAudioPlayer!.initialize();
|
||||
if (pcmInitialized) {
|
||||
_logger.log('✅ PCM audio player initialized for Sendspin');
|
||||
// Connect PCM player to Sendspin audio stream
|
||||
await _pcmAudioPlayer!.connectToStream(_sendspinService!.audioDataStream);
|
||||
} else {
|
||||
_logger.log('⚠️ PCM audio player initialization failed');
|
||||
}
|
||||
|
||||
// Wire up callbacks
|
||||
_sendspinService!.onPlay = _handleSendspinPlay;
|
||||
_sendspinService!.onPause = _handleSendspinPause;
|
||||
_sendspinService!.onStop = _handleSendspinStop;
|
||||
_sendspinService!.onSeek = _handleSendspinSeek;
|
||||
_sendspinService!.onVolume = _handleSendspinVolume;
|
||||
_sendspinService!.onStreamStart = _handleSendspinStreamStart;
|
||||
_sendspinService!.onStreamEnd = _handleSendspinStreamEnd;
|
||||
|
||||
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
|
||||
|
||||
// Strategy 1: If server is HTTPS, try external wss:// first
|
||||
final isHttps = _serverUrl!.startsWith('https://') ||
|
||||
(!_serverUrl!.contains('://') && !_serverUrl!.contains(':'));
|
||||
|
||||
if (isHttps) {
|
||||
_logger.log('Sendspin: Server is HTTPS, trying external connection first');
|
||||
final connected = await _sendspinService!.connect();
|
||||
if (connected) {
|
||||
_sendspinConnected = true;
|
||||
_logger.log('✅ Sendspin: Connected via external URL');
|
||||
return true;
|
||||
}
|
||||
_logger.log('⚠️ Sendspin: External connection failed, trying local URL');
|
||||
}
|
||||
|
||||
// Strategy 2: Get local_ws_url from API and try that
|
||||
final connectionInfo = await _api!.getSendspinConnectionInfo(playerId);
|
||||
if (connectionInfo != null) {
|
||||
final localWsUrl = connectionInfo['local_ws_url'] as String?;
|
||||
if (localWsUrl != null) {
|
||||
_logger.log('Sendspin: Trying local WebSocket URL: $localWsUrl');
|
||||
final connected = await _sendspinService!.connectWithUrl(localWsUrl);
|
||||
if (connected) {
|
||||
_sendspinConnected = true;
|
||||
_logger.log('✅ Sendspin: Connected via local URL');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: WebRTC fallback (requires TURN servers from Nabucasa)
|
||||
// For now, just log that we'd need WebRTC - implementing full WebRTC
|
||||
// would require additional dependencies (flutter_webrtc)
|
||||
_logger.log('⚠️ Sendspin: WebRTC fallback not yet implemented');
|
||||
_logger.log('ℹ️ For external access, configure /sendspin route in your reverse proxy');
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Sendspin connection error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle Sendspin play command
|
||||
void _handleSendspinPlay(String streamUrl, Map<String, dynamic> trackInfo) async {
|
||||
_logger.log('🎵 Sendspin: Play command received');
|
||||
|
||||
try {
|
||||
// Build full URL if needed
|
||||
String fullUrl = streamUrl;
|
||||
if (!streamUrl.startsWith('http') && _serverUrl != null) {
|
||||
var baseUrl = _serverUrl!;
|
||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||
baseUrl = 'https://$baseUrl';
|
||||
}
|
||||
baseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
|
||||
final path = streamUrl.startsWith('/') ? streamUrl : '/$streamUrl';
|
||||
fullUrl = '$baseUrl$path';
|
||||
}
|
||||
|
||||
// Extract metadata from track info
|
||||
final trackName = trackInfo['title'] as String? ?? trackInfo['name'] as String? ?? 'Unknown Track';
|
||||
final artistName = trackInfo['artist'] as String? ?? 'Unknown Artist';
|
||||
final albumName = trackInfo['album'] as String?;
|
||||
var artworkUrl = trackInfo['image_url'] as String? ?? trackInfo['artwork_url'] as String?;
|
||||
final durationSecs = trackInfo['duration'] as int?;
|
||||
|
||||
if (artworkUrl != null && artworkUrl.startsWith('http://')) {
|
||||
artworkUrl = artworkUrl.replaceFirst('http://', 'https://');
|
||||
}
|
||||
|
||||
final metadata = TrackMetadata(
|
||||
title: trackName,
|
||||
artist: artistName,
|
||||
album: albumName,
|
||||
artworkUrl: artworkUrl,
|
||||
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
||||
);
|
||||
|
||||
_localPlayer.setCurrentTrackMetadata(metadata);
|
||||
_currentNotificationMetadata = metadata;
|
||||
|
||||
await _localPlayer.playUrl(fullUrl);
|
||||
|
||||
// Report state back to MA
|
||||
_sendspinService?.reportState(playing: true, paused: false);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Sendspin: Error handling play command: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle Sendspin pause command
|
||||
void _handleSendspinPause() async {
|
||||
_logger.log('⏸️ Sendspin: Pause command received');
|
||||
// Pause both players - PCM for raw streaming, local for URL-based
|
||||
await _pcmAudioPlayer?.pause();
|
||||
await _localPlayer.pause();
|
||||
_sendspinService?.reportState(playing: false, paused: true);
|
||||
}
|
||||
|
||||
/// Handle Sendspin stop command
|
||||
void _handleSendspinStop() async {
|
||||
_logger.log('⏹️ Sendspin: Stop command received');
|
||||
// Stop both players - PCM for raw streaming, local for URL-based
|
||||
await _pcmAudioPlayer?.stop();
|
||||
await _localPlayer.stop();
|
||||
_sendspinService?.reportState(playing: false, paused: false);
|
||||
}
|
||||
|
||||
/// Handle Sendspin seek command
|
||||
void _handleSendspinSeek(int positionSeconds) async {
|
||||
_logger.log('⏩ Sendspin: Seek to $positionSeconds seconds');
|
||||
await _localPlayer.seek(Duration(seconds: positionSeconds));
|
||||
_sendspinService?.reportState(position: positionSeconds);
|
||||
}
|
||||
|
||||
/// Handle Sendspin volume command
|
||||
void _handleSendspinVolume(int volumeLevel) async {
|
||||
_logger.log('🔊 Sendspin: Set volume to $volumeLevel');
|
||||
_localPlayerVolume = volumeLevel;
|
||||
await FlutterVolumeController.setVolume(volumeLevel / 100.0);
|
||||
_sendspinService?.reportState(volume: volumeLevel);
|
||||
}
|
||||
|
||||
/// Handle Sendspin stream start - server is about to send PCM audio data
|
||||
/// This is called when audio streaming begins, before any audio frames arrive.
|
||||
/// We use this to:
|
||||
/// 1. Ensure PCM player is ready
|
||||
/// 2. Start the foreground service to prevent background throttling
|
||||
/// 3. Reset position for new track and start position timer
|
||||
void _handleSendspinStreamStart(Map<String, dynamic>? trackInfo) async {
|
||||
_logger.log('🎵 Sendspin: Stream starting');
|
||||
|
||||
// Ensure PCM player is initialized and ready
|
||||
if (_pcmAudioPlayer == null || _pcmAudioPlayer!.state == PcmPlayerState.idle) {
|
||||
_logger.log('🎵 Sendspin: Reinitializing PCM player for stream');
|
||||
_pcmAudioPlayer?.dispose();
|
||||
_pcmAudioPlayer = PcmAudioPlayer();
|
||||
final initialized = await _pcmAudioPlayer!.initialize();
|
||||
if (initialized) {
|
||||
await _pcmAudioPlayer!.connectToStream(_sendspinService!.audioDataStream);
|
||||
} else {
|
||||
_logger.log('⚠️ Sendspin: Failed to initialize PCM player');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Reset paused state when stream starts
|
||||
// This clears _isPausePending and sets state to playing
|
||||
// so that _onAudioData will process incoming audio
|
||||
await _pcmAudioPlayer!.play();
|
||||
|
||||
// Reset position for new stream (new track)
|
||||
_pcmAudioPlayer!.resetPosition();
|
||||
|
||||
// Extract track info for notification
|
||||
String? title = trackInfo?['title'] as String? ?? trackInfo?['name'] as String?;
|
||||
String? artist = trackInfo?['artist'] as String?;
|
||||
String? album = trackInfo?['album'] as String?;
|
||||
String? artworkUrl = trackInfo?['image_url'] as String? ?? trackInfo?['artwork_url'] as String?;
|
||||
int? durationSecs = trackInfo?['duration'] as int?;
|
||||
|
||||
// If no track info in stream/start, try to get from cached notification metadata
|
||||
if (title == null && _currentNotificationMetadata != null) {
|
||||
title = _currentNotificationMetadata!.title;
|
||||
artist = _currentNotificationMetadata!.artist;
|
||||
album = _currentNotificationMetadata!.album;
|
||||
artworkUrl = _currentNotificationMetadata!.artworkUrl;
|
||||
durationSecs = _currentNotificationMetadata!.duration?.inSeconds;
|
||||
}
|
||||
|
||||
// Keep the foreground service active to prevent Android from throttling
|
||||
// the PCM audio playback when the app goes to background.
|
||||
// We use setRemotePlaybackState to maintain the notification without
|
||||
// actually playing audio through just_audio.
|
||||
final mediaItem = audio_service.MediaItem(
|
||||
id: 'sendspin_pcm_stream',
|
||||
title: title ?? 'Playing via Sendspin',
|
||||
artist: artist ?? 'Music Assistant',
|
||||
album: album,
|
||||
artUri: artworkUrl != null ? Uri.parse(artworkUrl) : null,
|
||||
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
||||
);
|
||||
|
||||
// Initialize notification with position 0
|
||||
audioHandler.setRemotePlaybackState(
|
||||
item: mediaItem,
|
||||
playing: true,
|
||||
position: Duration.zero,
|
||||
duration: mediaItem.duration,
|
||||
);
|
||||
|
||||
// Start notification position timer for Sendspin PCM
|
||||
_manageNotificationPositionTimer();
|
||||
|
||||
_logger.log('🎵 Sendspin: Foreground service activated for PCM streaming');
|
||||
}
|
||||
|
||||
/// Handle Sendspin stream end - server stopped sending PCM audio data
|
||||
/// This is called when audio streaming ends (pause, stop, track end, etc.)
|
||||
void _handleSendspinStreamEnd() async {
|
||||
// Capture current position before stopping
|
||||
final lastPosition = _pcmAudioPlayer?.elapsedTime ?? Duration.zero;
|
||||
_logger.log('🎵 Sendspin: Stream ended at position ${lastPosition.inSeconds}s');
|
||||
|
||||
// Stop notification position timer
|
||||
_notificationPositionTimer?.cancel();
|
||||
|
||||
// Pause PCM playback (preserves position) instead of stop (resets position)
|
||||
await _pcmAudioPlayer?.pause();
|
||||
|
||||
// Update foreground service to show paused/stopped state with last position
|
||||
// Don't completely clear it - keep showing the notification
|
||||
// in case user wants to resume
|
||||
final metadata = _currentNotificationMetadata;
|
||||
final mediaItem = audio_service.MediaItem(
|
||||
id: 'sendspin_pcm_stream',
|
||||
title: metadata?.title ?? 'Music Assistant',
|
||||
artist: metadata?.artist ?? 'Paused',
|
||||
album: metadata?.album,
|
||||
artUri: metadata?.artworkUrl != null ? Uri.parse(metadata!.artworkUrl!) : null,
|
||||
duration: metadata?.duration,
|
||||
);
|
||||
|
||||
// Show paused state with preserved position
|
||||
audioHandler.setRemotePlaybackState(
|
||||
item: mediaItem,
|
||||
playing: false,
|
||||
position: lastPosition,
|
||||
duration: mediaItem.duration,
|
||||
);
|
||||
}
|
||||
|
||||
void _startReportingLocalPlayerState() {
|
||||
_localPlayerStateReportTimer?.cancel();
|
||||
_localPlayerStateReportTimer = Timer.periodic(Timings.localPlayerReportInterval, (_) async {
|
||||
@@ -508,7 +835,6 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
Future<void> _reportLocalPlayerState() async {
|
||||
if (_api == null) return;
|
||||
if (!_builtinPlayerAvailable) return; // Skip on MA 2.7.0b20+ (no builtin_player API)
|
||||
|
||||
// Don't try to report state if not authenticated - avoids spamming errors
|
||||
if (_api!.currentConnectionState != MAConnectionState.authenticated) return;
|
||||
@@ -523,6 +849,22 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
final volume = _localPlayerVolume;
|
||||
final isPaused = !isPlaying && position > 0;
|
||||
|
||||
// Report via Sendspin if connected (MA 2.7.0b20+)
|
||||
if (_sendspinConnected && _sendspinService != null) {
|
||||
_sendspinService!.reportState(
|
||||
powered: _isLocalPlayerPowered,
|
||||
playing: isPlaying,
|
||||
paused: isPaused,
|
||||
position: position,
|
||||
volume: volume,
|
||||
muted: _localPlayerVolume == 0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise use builtin_player API (older MA versions)
|
||||
if (!_builtinPlayerAvailable) return;
|
||||
|
||||
await _api!.updateBuiltinPlayerState(
|
||||
playerId,
|
||||
powered: _isLocalPlayerPowered,
|
||||
@@ -658,6 +1000,21 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle player_added event - refresh player list when new players join
|
||||
Future<void> _handlePlayerAddedEvent(Map<String, dynamic> event) async {
|
||||
try {
|
||||
final playerId = event['player_id'] as String?;
|
||||
final playerName = event['name'] as String?;
|
||||
_logger.log('🆕 Player added: $playerName ($playerId)');
|
||||
|
||||
// Refresh the player list to include the new player
|
||||
await _loadAndSelectPlayers(forceRefresh: true);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('Error handling player added event: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePlayerUpdatedEvent(Map<String, dynamic> event) async {
|
||||
try {
|
||||
final playerId = event['player_id'] as String?;
|
||||
@@ -1298,8 +1655,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
// Only include players that are available AND actively playing/paused
|
||||
// Always include the builtin player so user can switch to local device
|
||||
final activePlayers = _availablePlayers.where((p) =>
|
||||
p.available && (p.state == 'playing' || p.state == 'paused')
|
||||
p.available && (
|
||||
p.state == 'playing' ||
|
||||
p.state == 'paused' ||
|
||||
p.playerId.startsWith('ensemble_') // Always include builtin player
|
||||
)
|
||||
).toList();
|
||||
|
||||
if (activePlayers.isEmpty) {
|
||||
@@ -1325,7 +1687,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
selectPlayer(nextPlayer);
|
||||
}
|
||||
|
||||
/// Manage notification position timer for remote players.
|
||||
/// Manage notification position timer for remote players and Sendspin PCM.
|
||||
/// This timer updates the notification position every second using interpolated time,
|
||||
/// making the progress bar smooth instead of jumping every 5 seconds (polling interval).
|
||||
void _manageNotificationPositionTimer() {
|
||||
@@ -1333,24 +1695,30 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
if (_selectedPlayer == null || _currentTrack == null) return;
|
||||
|
||||
// Check if this is a builtin/local player (doesn't need timer - just_audio handles position)
|
||||
// Note: We can't use async/await in this method easily, so check using pattern matching
|
||||
// Check if this is a builtin/local player
|
||||
final playerId = _selectedPlayer!.playerId;
|
||||
if (playerId.startsWith('ensemble_')) {
|
||||
// Local player - no timer needed, just_audio handles position automatically
|
||||
return;
|
||||
// This is a local player ID, but we need to check if it's using Sendspin PCM
|
||||
// Sendspin PCM needs the timer because flutter_pcm_sound doesn't broadcast position
|
||||
// just_audio (non-Sendspin) handles position automatically via native events
|
||||
if (!_sendspinConnected || _pcmAudioPlayer == null) {
|
||||
// True local player using just_audio - no timer needed
|
||||
return;
|
||||
}
|
||||
// Sendspin PCM player - continue to start timer
|
||||
_logger.log('🔔 Starting notification timer for Sendspin PCM player');
|
||||
}
|
||||
|
||||
// Only run timer if remote player is playing
|
||||
// Only run timer if player is playing
|
||||
if (_selectedPlayer!.state != 'playing') return;
|
||||
|
||||
_notificationPositionTimer = Timer.periodic(
|
||||
const Duration(seconds: 1),
|
||||
const Duration(milliseconds: 500), // 500ms for smoother progress
|
||||
(_) => _updateNotificationPosition(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Update just the notification position (called every second for remote players)
|
||||
/// Update just the notification position (called every 500ms for remote/Sendspin players)
|
||||
void _updateNotificationPosition() {
|
||||
if (_selectedPlayer == null || _currentTrack == null) {
|
||||
_notificationPositionTimer?.cancel();
|
||||
@@ -1363,15 +1731,28 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use position tracker for consistent position (single source of truth)
|
||||
final position = _positionTracker.currentPosition;
|
||||
final track = _currentTrack!;
|
||||
Duration position;
|
||||
|
||||
// Check if track has ended (position reached duration)
|
||||
if (_positionTracker.hasReachedEnd) {
|
||||
_logger.log('PositionTracker: Track appears to have ended (position >= duration)');
|
||||
// Don't update notification, let the next poll handle the state change
|
||||
return;
|
||||
// For Sendspin PCM, use the PCM player's elapsed time (based on bytes played)
|
||||
// For remote players, use the position tracker (server-based interpolation)
|
||||
if (_sendspinConnected && _pcmAudioPlayer != null && _pcmAudioPlayer!.isPlaying) {
|
||||
position = _pcmAudioPlayer!.elapsedTime;
|
||||
|
||||
// Check if track has ended based on PCM elapsed time
|
||||
if (track.duration != null && position >= track.duration!) {
|
||||
_logger.log('🔔 Sendspin: Track appears to have ended (position >= duration)');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Use position tracker for remote players
|
||||
position = _positionTracker.currentPosition;
|
||||
|
||||
// Check if track has ended (position reached duration)
|
||||
if (_positionTracker.hasReachedEnd) {
|
||||
_logger.log('PositionTracker: Track appears to have ended (position >= duration)');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
||||
@@ -1688,13 +2069,17 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
Future<void> playPauseSelectedPlayer() async {
|
||||
if (_selectedPlayer == null) return;
|
||||
|
||||
if (_selectedPlayer!.isPlaying) {
|
||||
final wasPlaying = _selectedPlayer!.isPlaying;
|
||||
|
||||
if (wasPlaying) {
|
||||
await pausePlayer(_selectedPlayer!.playerId);
|
||||
// For pause: Don't refresh - we already did optimistic UI update
|
||||
// The player_updated event from MA will handle final state sync
|
||||
} else {
|
||||
await resumePlayer(_selectedPlayer!.playerId);
|
||||
// For resume: Refresh in background to get updated track info
|
||||
unawaited(refreshPlayers());
|
||||
}
|
||||
|
||||
await refreshPlayers();
|
||||
}
|
||||
|
||||
Future<void> nextTrackSelectedPlayer() async {
|
||||
@@ -1943,15 +2328,42 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
Future<void> pausePlayer(String playerId) async {
|
||||
try {
|
||||
await _api?.pausePlayer(playerId);
|
||||
// Get builtin player ID - this is cached so should be fast
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
|
||||
if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) {
|
||||
_logger.log('⏸️ Non-blocking local pause for builtin player');
|
||||
|
||||
// CRITICAL: Don't await these - they can block the UI thread
|
||||
// Use unawaited to make them fire-and-forget
|
||||
unawaited(_pcmAudioPlayer?.pause() ?? Future.value());
|
||||
|
||||
// Don't pause just_audio for Sendspin mode - it's not being used for audio output
|
||||
// and calling pause() on it can cause blocking issues
|
||||
// unawaited(_localPlayer.pause());
|
||||
|
||||
// Report state to MA immediately (fire and forget)
|
||||
_sendspinService?.reportState(playing: false, paused: true);
|
||||
|
||||
// Update local player state optimistically for UI responsiveness
|
||||
if (_selectedPlayer != null) {
|
||||
_selectedPlayer = _selectedPlayer!.copyWith(state: 'paused');
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Send command to MA for proper state sync - don't await
|
||||
unawaited(_api?.pausePlayer(playerId) ?? Future.value());
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Pause player', e);
|
||||
rethrow;
|
||||
// Don't rethrow - we want pause to be resilient
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> resumePlayer(String playerId) async {
|
||||
try {
|
||||
// For resume, we let MA handle it since it needs to restart the stream
|
||||
// The stream_start event will trigger local playback
|
||||
await _api?.resumePlayer(playerId);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Resume player', e);
|
||||
@@ -1961,6 +2373,14 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
Future<void> nextTrack(String playerId) async {
|
||||
try {
|
||||
// Optimistic local stop for builtin player on skip - non-blocking
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) {
|
||||
_logger.log('⏭️ Non-blocking local stop for skip on builtin player');
|
||||
// Stop current audio immediately - fire and forget
|
||||
unawaited(_pcmAudioPlayer?.pause() ?? Future.value());
|
||||
// Don't stop just_audio - not used for Sendspin audio output
|
||||
}
|
||||
await _api?.nextTrack(playerId);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Next track', e);
|
||||
@@ -1970,6 +2390,14 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
Future<void> previousTrack(String playerId) async {
|
||||
try {
|
||||
// Optimistic local stop for builtin player on previous - non-blocking
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) {
|
||||
_logger.log('⏮️ Non-blocking local stop for previous on builtin player');
|
||||
// Stop current audio immediately - fire and forget
|
||||
unawaited(_pcmAudioPlayer?.pause() ?? Future.value());
|
||||
// Don't stop just_audio - not used for Sendspin audio output
|
||||
}
|
||||
await _api?.previousTrack(playerId);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Previous track', e);
|
||||
@@ -2111,7 +2539,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_localPlayerStateReportTimer?.cancel();
|
||||
_localPlayerEventSubscription?.cancel();
|
||||
_playerUpdatedEventSubscription?.cancel();
|
||||
_playerAddedEventSubscription?.cancel();
|
||||
_positionTracker.dispose();
|
||||
_pcmAudioPlayer?.dispose();
|
||||
_sendspinService?.dispose();
|
||||
_api?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -71,6 +71,18 @@ class ErrorHandler {
|
||||
);
|
||||
}
|
||||
|
||||
// Unavailable content errors - these won't resolve with retry
|
||||
if (errorStr.contains('no playable') ||
|
||||
errorStr.contains('lack available providers') ||
|
||||
errorStr.contains('no available providers')) {
|
||||
return ErrorInfo(
|
||||
type: ErrorType.playback,
|
||||
userMessage: 'These tracks are unavailable. The music provider may not have them available for streaming.',
|
||||
technicalMessage: error.toString(),
|
||||
canRetry: false,
|
||||
);
|
||||
}
|
||||
|
||||
// Playback errors
|
||||
if (errorStr.contains('queue') ||
|
||||
errorStr.contains('player') ||
|
||||
|
||||
@@ -1045,7 +1045,20 @@ class MusicAssistantAPI {
|
||||
/// If clearQueue is true, replaces the queue (default behavior)
|
||||
/// If startIndex is provided, only tracks from that index onwards will be queued
|
||||
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex, bool clearQueue = true}) async {
|
||||
return await RetryHelper.retryCritical(
|
||||
return await RetryHelper.retry(
|
||||
maxAttempts: 3,
|
||||
initialDelaySeconds: 1,
|
||||
maxDelaySeconds: 4,
|
||||
// Don't retry errors about missing/unavailable tracks - those won't resolve with retries
|
||||
shouldRetry: (error) {
|
||||
final errorStr = error.toString().toLowerCase();
|
||||
if (errorStr.contains('no playable') ||
|
||||
errorStr.contains('no tracks') ||
|
||||
errorStr.contains('lack available')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
operation: () async {
|
||||
// If startIndex is provided, slice the tracks list to start from that index
|
||||
// This is a workaround since Music Assistant ignores the start_item parameter
|
||||
@@ -1053,8 +1066,36 @@ class MusicAssistantAPI {
|
||||
? tracks.sublist(startIndex)
|
||||
: tracks;
|
||||
|
||||
if (tracksToPlay.isEmpty) {
|
||||
throw Exception('No tracks to play');
|
||||
}
|
||||
|
||||
// Filter to only tracks with available provider mappings
|
||||
final playableTracks = tracksToPlay.where((track) {
|
||||
if (track.providerMappings == null || track.providerMappings!.isEmpty) {
|
||||
_logger.log('⚠️ Track "${track.name}" has no provider mappings');
|
||||
return false;
|
||||
}
|
||||
final hasAvailable = track.providerMappings!.any((m) => m.available);
|
||||
if (!hasAvailable) {
|
||||
_logger.log('⚠️ Track "${track.name}" has no available providers');
|
||||
}
|
||||
return hasAvailable;
|
||||
}).toList();
|
||||
|
||||
if (playableTracks.isEmpty) {
|
||||
_logger.log('❌ No playable tracks found (${tracksToPlay.length} tracks had no available providers)');
|
||||
throw Exception('No playable tracks - all ${tracksToPlay.length} tracks lack available providers');
|
||||
}
|
||||
|
||||
if (playableTracks.length < tracksToPlay.length) {
|
||||
_logger.log('⚠️ ${tracksToPlay.length - playableTracks.length} tracks skipped (no available providers)');
|
||||
}
|
||||
|
||||
// Build array of URI strings (not objects!)
|
||||
final mediaUris = tracksToPlay.map((track) => _buildTrackUri(track)).toList();
|
||||
final mediaUris = playableTracks.map((track) => _buildTrackUri(track)).toList();
|
||||
|
||||
_logger.log('🎵 Playing ${mediaUris.length} tracks: ${mediaUris.take(3).join(", ")}${mediaUris.length > 3 ? "..." : ""}');
|
||||
|
||||
final option = clearQueue ? 'replace' : 'play';
|
||||
|
||||
@@ -1458,6 +1499,14 @@ class MusicAssistantAPI {
|
||||
return _eventStreams['player_updated']!.stream;
|
||||
}
|
||||
|
||||
/// Stream of player_added events (for refreshing player list when new players join)
|
||||
Stream<Map<String, dynamic>> get playerAddedEvents {
|
||||
if (!_eventStreams.containsKey('player_added')) {
|
||||
_eventStreams['player_added'] = StreamController<Map<String, dynamic>>.broadcast();
|
||||
}
|
||||
return _eventStreams['player_added']!.stream;
|
||||
}
|
||||
|
||||
/// Register this device as a player with retry logic
|
||||
/// CRITICAL: This creates a player config in MA's settings.json
|
||||
/// The server expects: player_id and player_name
|
||||
@@ -1703,6 +1752,155 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SENDSPIN PLAYER MANAGEMENT (MA 2.7.0b20+ replacement for builtin_player)
|
||||
// ============================================================================
|
||||
|
||||
/// Stream of Sendspin player events
|
||||
Stream<Map<String, dynamic>> get sendspinPlayerEvents {
|
||||
if (!_eventStreams.containsKey('sendspin_player')) {
|
||||
_eventStreams['sendspin_player'] = StreamController<Map<String, dynamic>>.broadcast();
|
||||
}
|
||||
return _eventStreams['sendspin_player']!.stream;
|
||||
}
|
||||
|
||||
/// Get Sendspin connection info for a player
|
||||
/// Returns connection details including:
|
||||
/// - local_ws_url: Direct WebSocket URL (e.g., ws://192.168.4.120:8927/sendspin)
|
||||
/// - ice_servers: List of ICE/TURN servers for WebRTC fallback
|
||||
/// - player_id: The player's ID
|
||||
Future<Map<String, dynamic>?> getSendspinConnectionInfo(String playerId) async {
|
||||
try {
|
||||
_logger.log('Sendspin: Getting connection info for player $playerId');
|
||||
|
||||
final response = await _sendCommand(
|
||||
'sendspin/connection_info',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
},
|
||||
);
|
||||
|
||||
final result = response['result'] as Map<String, dynamic>?;
|
||||
if (result != null) {
|
||||
_logger.log('Sendspin: Got connection info - local_ws_url: ${result['local_ws_url']}');
|
||||
_logger.log('Sendspin: ICE servers count: ${(result['ice_servers'] as List?)?.length ?? 0}');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Error getting connection info: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send WebRTC offer to Sendspin server
|
||||
/// Used when establishing WebRTC connection as fallback
|
||||
Future<Map<String, dynamic>?> sendspinOffer(String playerId, String sdp) async {
|
||||
try {
|
||||
_logger.log('Sendspin: Sending WebRTC offer for player $playerId');
|
||||
|
||||
final response = await _sendCommand(
|
||||
'sendspin/webrtc_offer',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
'sdp': sdp,
|
||||
},
|
||||
);
|
||||
|
||||
return response['result'] as Map<String, dynamic>?;
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Error sending WebRTC offer: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send WebRTC answer to Sendspin server
|
||||
Future<void> sendspinAnswer(String playerId, String sdp) async {
|
||||
try {
|
||||
_logger.log('Sendspin: Sending WebRTC answer for player $playerId');
|
||||
|
||||
await _sendCommand(
|
||||
'sendspin/webrtc_answer',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
'sdp': sdp,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Error sending WebRTC answer: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send ICE candidate to Sendspin server
|
||||
Future<void> sendspinIceCandidate(String playerId, Map<String, dynamic> candidate) async {
|
||||
try {
|
||||
await _sendCommand(
|
||||
'sendspin/ice_candidate',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
'candidate': candidate,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Error sending ICE candidate: $e');
|
||||
// Don't rethrow - ICE candidate failures are common and non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
/// Update Sendspin player state
|
||||
/// Similar to updateBuiltinPlayerState but for Sendspin protocol
|
||||
Future<void> updateSendspinPlayerState(
|
||||
String playerId, {
|
||||
required bool powered,
|
||||
required bool playing,
|
||||
required bool paused,
|
||||
required int position,
|
||||
required int volume,
|
||||
required bool muted,
|
||||
}) async {
|
||||
try {
|
||||
_logger.log('Sendspin: Updating player state: powered=$powered, playing=$playing, paused=$paused');
|
||||
await _sendCommand(
|
||||
'sendspin/update_state',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
'state': {
|
||||
'powered': powered,
|
||||
'playing': playing,
|
||||
'paused': paused,
|
||||
'position': position,
|
||||
'volume': volume,
|
||||
'muted': muted,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Error updating player state: $e');
|
||||
// Non-fatal, don't rethrow
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect Sendspin player from server
|
||||
Future<void> disconnectSendspinPlayer(String playerId) async {
|
||||
try {
|
||||
_logger.log('Sendspin: Disconnecting player $playerId');
|
||||
await _sendCommand(
|
||||
'sendspin/disconnect',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Error disconnecting player: $e');
|
||||
// Non-fatal during cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// END SENDSPIN PLAYER MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
Future<void> _sendQueueCommand(String queueId, String command) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
|
||||
@@ -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)
|
||||
// GPU PERF: Use FadeTransition instead of Opacity
|
||||
if (t > 0.5 && currentTrack.duration != null)
|
||||
|
||||
+4
-1
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 1.9.3+14
|
||||
version: 2.0.0-beta+15
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@@ -38,6 +38,9 @@ dependencies:
|
||||
audio_session: ^0.1.18
|
||||
flutter_volume_controller: ^1.3.2
|
||||
|
||||
# Raw PCM Audio Playback (for Sendspin streaming)
|
||||
flutter_pcm_sound: ^3.3.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
Reference in New Issue
Block a user