Files
Ensemble/lib/providers/music_assistant_provider.dart

6858 lines
265 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:audio_service/audio_service.dart' as audio_service;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
import '../models/media_item.dart';
import '../models/player.dart';
import '../models/provider_instance.dart';
import '../models/provider_manifest.dart';
import '../models/recommendation_folder.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../services/error_handler.dart';
import '../services/auth/auth_manager.dart';
import '../services/device_id_service.dart';
import '../services/cache_service.dart';
import '../services/recently_played_service.dart';
import '../services/sync_service.dart';
import '../services/local_player_service.dart';
import '../services/metadata_service.dart';
import '../services/position_tracker.dart';
import '../services/group_volume_manager.dart';
import '../services/sendspin_service.dart';
import '../services/pcm_audio_player.dart';
import '../services/offline_action_queue.dart';
import '../services/image_prefetch_service.dart';
import '../constants/timings.dart';
import '../services/database_service.dart';
import '../services/library_status_service.dart';
import '../main.dart' show audioHandler;
/// Main provider that coordinates connection, player, and library state.
///
/// This is a facade that delegates to internal services while maintaining
/// backward compatibility with existing code that uses MusicAssistantProvider.
///
/// Architecture:
/// - CacheService: Handles all caching (non-notifying)
/// - Connection logic: WebSocket, auth, reconnection
/// - Player logic: Player selection, controls, local player
/// - Library logic: Artists, albums, tracks, search
class MusicAssistantProvider with ChangeNotifier {
MusicAssistantAPI? _api;
final AuthManager _authManager = AuthManager();
final DebugLogger _logger = DebugLogger();
final CacheService _cacheService = CacheService();
final PositionTracker _positionTracker = PositionTracker();
ImagePrefetchService? _imagePrefetchService;
final GroupVolumeManager _groupVolumeManager = GroupVolumeManager();
MAConnectionState _connectionState = MAConnectionState.disconnected;
String? _serverUrl;
String? _error;
// Library state
List<Artist> _artists = [];
List<Album> _albums = [];
List<Track> _tracks = [];
List<Track> _cachedFavoriteTracks = []; // Cached for instant display before full library loads
List<Audiobook> _audiobooks = [];
final Map<String, List<Audiobook>> _seriesAudiobooksCache = {};
List<MediaItem> _radioStations = [];
List<MediaItem> _podcasts = [];
bool _isLoading = false;
bool _isLoadingRadio = false;
bool _isLoadingPodcasts = false;
// Player state
Player? _selectedPlayer;
List<Player> _availablePlayers = [];
bool _selectPlayerInProgress = false; // Reentrancy guard for selectPlayer()
Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping
// Pending volume cache: prevents stale API data from overwriting optimistic
// volume updates in _updatePlayerState() and refreshPlayers().
// Key: playerId, Value: volume level (0-100)
final Map<String, int> _pendingVolumes = {};
final Map<String, int> _pendingVolumeTimestamps = {}; // millisecondsSinceEpoch
static const int _pendingVolumeTimeoutMs = 8000; // 8s for non-group players
static const int _pendingVolumeTimeoutGroupMs = 30000; // 30s for group players (API returns null)
static const int _pendingVolumeTolerancePoints = 3; // Confirm if within ±3 points
Track? _currentTrack;
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
String? _currentPodcastName; // Currently playing podcast's name (set when playing episode)
Timer? _playerStateTimer;
Timer? _notificationPositionTimer; // Updates notification position every second for remote players
// Sleep timer state
Timer? _sleepTimer;
DateTime? _sleepTimerEndTime;
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
Timer? _sleepTimerDisplayTimer; // Updates remaining time display every second
// Idle service timeout - stops foreground service after 30 min of no playback
Timer? _idleServiceTimer;
static const _idleServiceTimeout = Duration(minutes: 30);
// Artwork content:// URI helper for Android Auto compatibility
static const _artworkAuthority = 'com.collotsspot.ensemble.artwork';
static Uri? _contentArtUri(String? httpUrl) {
if (httpUrl == null) return null;
final encoded = base64Url.encode(utf8.encode(httpUrl));
return Uri.parse('content://$_artworkAuthority/$encoded');
}
// Local player state
bool _isLocalPlayerPowered = true;
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
bool _builtinPlayerAvailable = true; // False on MA 2.7.0b20+ (uses Sendspin instead)
// Home refresh counter - increments to signal home screen to refresh all rows
int _homeRefreshCounter = 0;
String? _lastConnectedUrl; // Track last successfully connected server URL
StreamSubscription? _connectionStateSubscription;
StreamSubscription? _localPlayerEventSubscription;
StreamSubscription? _playerUpdatedEventSubscription;
StreamSubscription? _playerAddedEventSubscription;
StreamSubscription? _mediaItemAddedEventSubscription;
StreamSubscription? _mediaItemDeletedEventSubscription;
Timer? _localPlayerStateReportTimer;
TrackMetadata? _pendingTrackMetadata;
TrackMetadata? _currentNotificationMetadata;
Completer<void>? _registrationInProgress;
Completer<void>? _reconnectInProgress;
// 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 = {
'artists': [],
'albums': [],
'tracks': [],
};
// Podcast cover cache: podcastId -> best available cover URL
// This is populated with episode covers when podcast covers are low-res
final Map<String, String> _podcastCoverCache = {};
// Provider filter: list of allowed provider instance IDs from MA user settings
// Empty list means all providers are allowed
List<String> _providerFilter = [];
// Player filter: list of allowed player IDs from MA user settings
// Empty list means all players are allowed
List<String> _playerFilter = [];
// User-controlled music provider filter (local settings in Ensemble)
// Empty list means all providers are enabled (no filtering)
List<String> _enabledProviderIds = [];
// Available music providers discovered from MA
List<ProviderInstance> _availableMusicProviders = [];
// ============================================================================
// GETTERS
// ============================================================================
MAConnectionState get connectionState => _connectionState;
String? get serverUrl => _serverUrl;
String? get error => _error;
bool get isConnected => _connectionState == MAConnectionState.connected ||
_connectionState == MAConnectionState.authenticated;
// Library getters with provider filtering applied
List<Artist> get artists => filterByProvider(_artists);
List<Album> get albums => filterByProvider(_albums);
List<Track> get tracks => filterByProvider(_tracks);
List<Audiobook> get audiobooks => filterByProvider(_audiobooks);
List<MediaItem> get radioStations => filterByProvider(_radioStations);
List<MediaItem> get podcasts => filterByProvider(_podcasts);
// Raw unfiltered access (for internal use only)
List<Artist> get artistsUnfiltered => _artists;
List<Album> get albumsUnfiltered => _albums;
List<MediaItem> get podcastsUnfiltered => _podcasts;
List<MediaItem> get radioStationsUnfiltered => _radioStations;
bool get isLoading => _isLoading;
bool get isLoadingRadio => _isLoadingRadio;
bool get isLoadingPodcasts => _isLoadingPodcasts;
/// Provider filter from MA user settings (empty = all providers allowed)
List<String> get providerFilter => _providerFilter;
/// Whether provider filtering is active
bool get hasProviderFilter => _providerFilter.isNotEmpty;
/// Check if a media item should be visible based on provider filter
/// Returns true if:
/// - No filter is active (empty list = all providers allowed)
/// - The item has at least one provider mapping in the allowed list
/// - The item's primary provider is in the allowed list
bool isItemAllowedByProviderFilter(MediaItem item) {
// No filter = show everything
if (_providerFilter.isEmpty) return true;
// Check if item's provider mappings include any allowed provider
final mappings = item.providerMappings;
if (mappings != null && mappings.isNotEmpty) {
for (final mapping in mappings) {
if (_providerFilter.contains(mapping.providerInstance)) {
return true;
}
}
}
// Also check primary provider field (for items without full mappings)
if (_providerFilter.contains(item.provider)) {
return true;
}
return false;
}
/// Filter a list of media items based on provider filter
List<T> filterByProvider<T extends MediaItem>(List<T> items) {
if (_providerFilter.isEmpty) return items;
return items.where(isItemAllowedByProviderFilter).toList();
}
/// Filter search results map based on provider filter
Map<String, List<MediaItem>> filterSearchResults(Map<String, List<MediaItem>> results) {
if (_providerFilter.isEmpty) return results;
return {
for (final entry in results.entries)
entry.key: entry.value.where(isItemAllowedByProviderFilter).toList(),
};
}
/// Check if a server player ID matches our local builtin player ID.
/// MA beta 14+ wraps Sendspin player IDs with 'up' prefix and lowercases
/// them when merging into universal_player entities, so we need flexible matching.
/// e.g. local "ensemble_Chris_CP21.260" matches server "upensemble_chris_cp21.260"
bool _isBuiltinPlayer(String serverPlayerId, String builtinPlayerId) {
if (serverPlayerId == builtinPlayerId) return true;
return serverPlayerId.toLowerCase() == 'up${builtinPlayerId.toLowerCase()}' ||
serverPlayerId.toLowerCase() == builtinPlayerId.toLowerCase();
}
/// Check if a server player ID is an Ensemble player (this or other device)
bool _isEnsemblePlayer(String serverPlayerId) {
final lower = serverPlayerId.toLowerCase();
return lower.startsWith('ensemble_') || lower.startsWith('upensemble_');
}
/// Player filter from MA user settings (empty = all players allowed)
List<String> get playerFilter => _playerFilter;
/// Whether player filtering is active
bool get hasPlayerFilter => _playerFilter.isNotEmpty;
/// Check if a player should be visible based on player filter
bool isPlayerAllowedByFilter(Player player) {
if (_playerFilter.isEmpty) return true;
return _playerFilter.contains(player.playerId);
}
/// Filter a list of players based on player filter
List<Player> filterPlayers(List<Player> players) {
if (_playerFilter.isEmpty) return players;
return players.where(isPlayerAllowedByFilter).toList();
}
// ============================================================================
// USER-CONTROLLED MUSIC PROVIDER FILTER
// ============================================================================
/// List of available music providers from MA
List<ProviderInstance> get availableMusicProviders => _availableMusicProviders;
/// Get all provider manifests (contains icons)
Map<String, ProviderManifest> get providerManifests =>
_api?.providerManifests ?? {};
/// Get SVG icon for a provider domain
/// Returns null if no icon is available
String? getProviderIconSvg(String domain, {bool isDark = false}) {
return _api?.getProviderIconSvg(domain, isDark: isDark);
}
/// Get provider manifest by domain
ProviderManifest? getProviderManifest(String domain) {
return _api?.getProviderManifest(domain);
}
/// List of enabled provider instance IDs (empty = all enabled)
List<String> get enabledProviderIds => _enabledProviderIds;
/// Whether user has enabled specific providers (not all)
bool get hasUserProviderFilter => _enabledProviderIds.isNotEmpty;
/// Get provider IDs to pass to API calls
/// Returns null if all providers are enabled (no filtering needed)
/// Returns the list of enabled provider IDs otherwise
List<String>? get providerIdsForApiCalls {
if (_enabledProviderIds.isEmpty) return null; // All enabled
return _enabledProviderIds;
}
/// Check if a specific provider is enabled by the user
bool isProviderEnabled(String instanceId) {
if (_enabledProviderIds.isEmpty) return true; // All enabled by default
return _enabledProviderIds.contains(instanceId);
}
/// Get providers that have content in artists library (with item counts)
Map<String, int> getProvidersWithArtists() {
return _getProvidersWithCounts(_artists);
}
/// Get providers that have content in albums library (with item counts)
Map<String, int> getProvidersWithAlbums() {
return _getProvidersWithCounts(_albums);
}
/// Get providers that have content in tracks library (with item counts)
Map<String, int> getProvidersWithTracks() {
return _getProvidersWithCounts(_tracks);
}
/// Get providers that have content in playlists (with item counts)
Map<String, int> getProvidersWithPlaylists() {
return _getProvidersWithCounts(SyncService.instance.cachedPlaylists);
}
/// Get providers that have audiobooks (with item counts)
Map<String, int> getProvidersWithAudiobooks() {
return _getProvidersWithCounts(_audiobooks);
}
/// Get providers that have radio stations (with item counts)
Map<String, int> getProvidersWithRadio() {
return _getProvidersWithCounts(_radioStations);
}
/// Get providers that have podcasts (with item counts)
/// Only counts mappings where inLibrary is true - this indicates which provider
/// the user actually added the podcast from (vs providers that can play it)
Map<String, int> getProvidersWithPodcasts() {
final counts = <String, int>{};
for (final item in _podcasts) {
final mappings = item.providerMappings;
if (mappings != null) {
for (final mapping in mappings) {
// Only count if this provider "owns" the item (user added it from this account)
if (mapping.inLibrary) {
final instanceId = mapping.providerInstance;
if (instanceId.isNotEmpty) {
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
}
}
}
}
}
return counts;
}
/// Internal helper to count items per provider from a list of media items
/// Only counts mappings where inLibrary is true - this indicates which provider
/// the user actually added the item from (vs providers that can play it)
Map<String, int> _getProvidersWithCounts<T extends MediaItem>(List<T> items) {
final counts = <String, int>{};
for (final item in items) {
final mappings = item.providerMappings;
if (mappings != null) {
for (final mapping in mappings) {
// Only count if this provider "owns" the item (user added it from this account)
if (mapping.inLibrary) {
final instanceId = mapping.providerInstance;
if (instanceId.isNotEmpty) {
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
}
}
}
}
}
return counts;
}
/// Get ProviderInstance objects for providers that have content in a category
/// Returns list of (ProviderInstance, itemCount) tuples, sorted by name
List<(ProviderInstance, int)> getRelevantProvidersForCategory(String category) {
final Map<String, int> counts;
switch (category) {
case 'artists':
counts = getProvidersWithArtists();
break;
case 'albums':
counts = getProvidersWithAlbums();
break;
case 'tracks':
counts = getProvidersWithTracks();
break;
case 'playlists':
counts = getProvidersWithPlaylists();
break;
case 'audiobooks':
counts = getProvidersWithAudiobooks();
break;
case 'radio':
counts = getProvidersWithRadio();
break;
case 'podcasts':
counts = getProvidersWithPodcasts();
break;
default:
counts = {};
}
// Build list of providers that support this category, with their item counts
// Only include providers that have at least 1 item (hides providers not synced to library)
// Multiple instances of the same domain are allowed (e.g., two Spotify accounts)
// but we dedupe if names match exactly (same account re-added with new instance ID)
final result = <(ProviderInstance, int)>[];
final addedInstanceIds = <String>{};
final addedNames = <String, int>{}; // Track name -> count to dedupe same-name providers
for (final provider in _availableMusicProviders) {
// Only include providers that support this content type AND have items
if (provider.supportsContentType(category)) {
final count = counts[provider.instanceId] ?? 0;
if (count > 0) {
final existingCount = addedNames[provider.name];
if (existingCount == null) {
// First provider with this name
result.add((provider, count));
addedInstanceIds.add(provider.instanceId);
addedNames[provider.name] = count;
} else if (count > existingCount) {
// Same name but more items - likely same account re-added, keep better one
result.removeWhere((r) => r.$1.name == provider.name);
result.add((provider, count));
addedInstanceIds.add(provider.instanceId);
addedNames[provider.name] = count;
}
}
}
}
// Also include providers that have items but aren't in _availableMusicProviders
// (e.g., search-only providers like iTunes, or orphaned items from removed providers)
// For these synthetic providers, dedupe by domain since we don't have account-specific names
final addedRealDomains = result.map((r) => r.$1.domain).toSet();
final addedSyntheticDomains = <String>{};
for (final entry in counts.entries) {
if (!addedInstanceIds.contains(entry.key) && entry.value > 0) {
// Create a synthetic ProviderInstance from the instance ID
// Format is typically "domain--uniqueId" (e.g., "itunes--abc123")
final instanceId = entry.key;
final domain = instanceId.contains('--')
? instanceId.split('--').first
: instanceId;
// Skip if this domain is already represented by a real provider or synthetic
if (addedRealDomains.contains(domain)) continue;
if (addedSyntheticDomains.contains(domain)) continue;
// Only add if this domain supports the category
final capabilities = ProviderInstance.providerCapabilities[domain];
if (capabilities != null && capabilities.contains(category)) {
result.add((
ProviderInstance(
instanceId: instanceId,
domain: domain,
name: _formatProviderName(domain),
available: true,
),
entry.value,
));
addedSyntheticDomains.add(domain);
}
}
}
// Sort by name
result.sort((a, b) => a.$1.name.compareTo(b.$1.name));
return result;
}
/// Format a provider domain into a readable display name
String _formatProviderName(String domain) {
const names = {
'spotify': 'Spotify',
'tidal': 'Tidal',
'qobuz': 'Qobuz',
'deezer': 'Deezer',
'ytmusic': 'YouTube Music',
'soundcloud': 'SoundCloud',
'apple_music': 'Apple Music',
'amazon_music': 'Amazon Music',
'plex': 'Plex',
'jellyfin': 'Jellyfin',
'emby': 'Emby',
'subsonic': 'Subsonic',
'opensubsonic': 'OpenSubsonic',
'navidrome': 'Navidrome',
'audiobookshelf': 'Audiobookshelf',
'filesystem': 'Filesystem',
'itunes_podcasts': 'iTunes Podcasts',
'tunein': 'TuneIn',
'radiobrowser': 'Radio Browser',
};
return names[domain] ?? domain[0].toUpperCase() + domain.substring(1);
}
/// Whether library is syncing in background
bool get isSyncing => SyncService.instance.isSyncing;
/// Current sync status
SyncStatus get syncStatus => SyncService.instance.status;
/// Selected player - loads from cache if not yet set
Player? get selectedPlayer {
if (_selectedPlayer == null && _cacheService.getCachedSelectedPlayer() != null) {
_selectedPlayer = _cacheService.getCachedSelectedPlayer();
}
return _selectedPlayer;
}
/// Available players - loads from cache for instant UI display
/// Filtered by player_filter if active
List<Player> get availablePlayers {
if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) {
_availablePlayers = _cacheService.getCachedPlayers()!;
_logger.log('⚡ Loaded ${_availablePlayers.length} players from cache (lazy)');
}
return filterPlayers(_availablePlayers);
}
/// Raw unfiltered players list (for internal use)
List<Player> get availablePlayersUnfiltered => _availablePlayers;
/// Group volume manager for resolving group player volumes
GroupVolumeManager get groupVolumeManager => _groupVolumeManager;
/// Check if a player should show the "manually synced" indicator (yellow border)
/// Returns true for BOTH the leader AND children of a manually created sync group
/// Excludes pre-configured MA speaker groups (provider = 'player_group')
bool isPlayerManuallySynced(String playerId) {
final player = _availablePlayers.where((p) => p.playerId == playerId).firstOrNull;
if (player == null) return false;
// Group players (like "All Speakers") should NEVER have yellow border
// They are pre-configured containers, not manually synced players
// Check this FIRST before any other logic to prevent edge cases
if (player.provider == 'player_group') return false;
// Case 1: Player is a child synced to another player
if (player.syncedTo != null) {
// Look up sync target - also check translated IDs for Cast+Sendspin players
// The syncedTo might contain a Cast ID but the player list has the Sendspin version
Player? syncTarget = _availablePlayers.where((p) => p.playerId == player.syncedTo).firstOrNull;
// If not found, try looking up by translated Sendspin ID
if (syncTarget == null) {
final translatedId = _castToSendspinIdMap[player.syncedTo];
if (translatedId != null) {
syncTarget = _availablePlayers.where((p) => p.playerId == translatedId).firstOrNull;
}
}
// Also check reverse: syncedTo might be Sendspin ID, look for Cast player
if (syncTarget == null) {
// Build reverse map on demand
for (final entry in _castToSendspinIdMap.entries) {
if (entry.value == player.syncedTo) {
syncTarget = _availablePlayers.where((p) => p.playerId == entry.key).firstOrNull;
if (syncTarget != null) break;
}
}
}
if (syncTarget == null) return false;
// If synced to a group player, it's part of a pre-configured group
if (syncTarget.provider == 'player_group') return false;
// Synced to a regular player - this is a manual sync child
return true;
}
// Case 2: Player is a leader with group members
if (player.groupMembers != null && player.groupMembers!.length > 1) {
// Key distinction: In a MANUAL sync, the leader's own ID is in groupMembers
// In a PRE-CONFIGURED group (UGP), the group player's ID is NOT in groupMembers
// (the members are the child players, not including the group itself)
final isInOwnGroup = player.groupMembers!.contains(player.playerId);
if (!isInOwnGroup) {
// This is a pre-configured group player (like "All Speakers")
return false;
}
// Leader's ID is in groupMembers = manual sync
return true;
}
return false;
}
Track? get currentTrack => _currentTrack;
// Sleep timer getters
bool get sleepTimerActive => _sleepTimerMinutes != null;
int? get sleepTimerMinutes => _sleepTimerMinutes;
Duration? get sleepTimerRemaining {
if (_sleepTimerEndTime == null) return null;
final remaining = _sleepTimerEndTime!.difference(DateTime.now());
return remaining.isNegative ? Duration.zero : remaining;
}
/// Whether we have cached players available (for instant UI display on app resume)
bool get hasCachedPlayers => _cacheService.hasCachedPlayers;
/// Home refresh counter - increments when home rows should refresh their data
int get homeRefreshCounter => _homeRefreshCounter;
/// Current local (Sendspin) player volume as tracked by MA (0-100).
int get localPlayerVolume => _localPlayerVolume;
/// Currently playing audiobook context (with chapters) - set when playing an audiobook
Audiobook? get currentAudiobook => _currentAudiobook;
/// Whether we're currently playing an audiobook
bool get isPlayingAudiobook => _currentAudiobook != null;
/// Whether we're currently playing a podcast episode
/// Detected by checking if the current track's URI contains podcast_episode
bool get isPlayingPodcast {
final uri = _currentTrack?.uri;
if (uri == null) return false;
return uri.contains('podcast_episode') || uri.contains('podcast/');
}
/// Whether we're currently playing a radio station
/// Detected by checking if the current track's URI contains 'radio/' or media_type is 'radio'
bool get isPlayingRadio {
final uri = _currentTrack?.uri;
if (uri == null) return false;
return uri.contains('library://radio/') || uri.contains('/radio/');
}
/// Get the radio station name when playing a radio stream
/// Returns the station name from album (where MA puts it for radio streams)
String? get currentRadioStationName {
if (!isPlayingRadio || _currentTrack == null) return null;
// For radio, the station name is typically in the album field
if (_currentTrack!.album != null && _currentTrack!.album!.name.isNotEmpty) {
return _currentTrack!.album!.name;
}
return null;
}
/// Get the podcast name when playing a podcast episode
/// Returns the podcast name from stored context, metadata, or fallbacks
String? get currentPodcastName {
if (!isPlayingPodcast || _currentTrack == null) return null;
// Primary source: stored podcast name from when episode was played
if (_currentPodcastName != null && _currentPodcastName!.isNotEmpty) {
return _currentPodcastName;
}
// Try metadata (if episode has parent podcast info from API)
final metadata = _currentTrack!.metadata;
if (metadata != null) {
if (metadata['podcast_name'] != null) {
return metadata['podcast_name'] as String;
}
if (metadata['podcast'] is Map) {
final podcast = metadata['podcast'] as Map;
if (podcast['name'] != null) {
return podcast['name'] as String;
}
}
}
// Fallbacks removed - album/artist contain episode name, not podcast name
// Return null to let UI show generic "Podcasts" label
return null;
}
/// Set the current podcast context (call when playing a podcast episode)
void setCurrentPodcastName(String? podcastName) {
_currentPodcastName = podcastName;
_logger.log('🎙️ Set current podcast name: $podcastName');
}
/// Clear the podcast context
void clearCurrentPodcastName() {
if (_currentPodcastName != null) {
_logger.log('🎙️ Cleared podcast context');
_currentPodcastName = null;
}
}
/// Get the display artist string for a track, using podcast/radio/audiobook context when applicable
String _trackDisplayArtist(Track track) {
if (isPlayingAudiobook) {
return _currentAudiobook!.authorsString;
}
if (isPlayingPodcast) {
return currentPodcastName ?? 'Podcasts';
}
if (isPlayingRadio) {
return currentRadioStationName ?? track.artistsString;
}
// Fallback: check if track URI looks like an audiobook chapter
final uri = track.uri ?? '';
if (uri.contains('audiobook') || uri.contains('/chapter/')) {
// Try to find audiobook from cached data
final book = _findAudiobookForTrack(track);
if (book != null) {
_currentAudiobook = book;
_logger.log('📚 Auto-restored audiobook context: ${book.name}');
return book.authorsString;
}
}
return track.artistsString;
}
/// Try to find the audiobook that owns this track/chapter from cached data
Audiobook? _findAudiobookForTrack(Track track) {
final uri = track.uri ?? '';
for (final book in SyncService.instance.cachedAudiobooks) {
if (uri.contains(book.itemId)) return book;
}
return null;
}
String get lastSearchQuery => _lastSearchQuery;
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
MusicAssistantAPI? get api => _api;
AuthManager get authManager => _authManager;
/// 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)
/// For grouped child players, returns the leader's track
/// Also checks translated Cast<->Sendspin IDs (both from map and computed dynamically)
Track? getCachedTrackForPlayer(String playerId) {
// If player is a group child, get the leader's track instead
final player = _availablePlayers.firstWhere(
(p) => p.playerId == playerId,
orElse: () => Player(
playerId: playerId,
name: '',
available: false,
powered: false,
state: 'idle',
),
);
final effectivePlayerId = (player.isGroupChild && player.syncedTo != null)
? player.syncedTo!
: playerId;
// Try direct lookup first
var track = _cacheService.getCachedTrackForPlayer(effectivePlayerId);
// If not found, try translated Cast<->Sendspin ID
if (track == null) {
// Check Cast -> Sendspin (from map)
final sendspinId = _castToSendspinIdMap[effectivePlayerId];
if (sendspinId != null) {
track = _cacheService.getCachedTrackForPlayer(sendspinId);
}
// Check Sendspin -> Cast (reverse lookup from map)
if (track == null) {
for (final entry in _castToSendspinIdMap.entries) {
if (entry.value == effectivePlayerId) {
track = _cacheService.getCachedTrackForPlayer(entry.key);
if (track != null) break;
}
}
}
// Dynamic ID computation for chromecast players
// This handles cases where the map doesn't have the entry yet
if (track == null) {
// If effectivePlayerId looks like a Sendspin ID (cast-{8chars}), compute Cast ID
if (effectivePlayerId.startsWith('cast-') && effectivePlayerId.length >= 13) {
// Sendspin ID: cast-7df484e3 -> need to find Cast ID that starts with 7df484e3
final prefix = effectivePlayerId.substring(5); // Remove "cast-"
// Search through available players for a chromecast player with matching UUID prefix
for (final p in _availablePlayers) {
if (p.provider == 'chromecast' && p.playerId.startsWith(prefix)) {
track = _cacheService.getCachedTrackForPlayer(p.playerId);
if (track != null) {
_logger.log('🔍 Found track via computed Cast ID: ${p.playerId}');
break;
}
}
}
// Also try direct cache lookup with the prefix as partial ID
if (track == null) {
// Try common UUID patterns - the cache might have the full Cast UUID
final possibleCastIds = _cacheService.getAllCachedPlayerIds()
.where((id) => id.startsWith(prefix))
.toList();
for (final castId in possibleCastIds) {
track = _cacheService.getCachedTrackForPlayer(castId);
if (track != null) {
_logger.log('🔍 Found track via cache scan for prefix $prefix: $castId');
break;
}
}
}
}
// If effectivePlayerId looks like a Cast UUID, compute Sendspin ID
if (track == null && effectivePlayerId.length >= 8 && effectivePlayerId.contains('-')) {
final computedSendspinId = 'cast-${effectivePlayerId.substring(0, 8)}';
track = _cacheService.getCachedTrackForPlayer(computedSendspinId);
if (track != null) {
_logger.log('🔍 Found track via computed Sendspin ID: $computedSendspinId');
}
}
}
}
return track;
}
/// Get artwork URL for a player from cache
String? getCachedArtworkUrl(String playerId, {int size = 512}) {
final track = getCachedTrackForPlayer(playerId);
if (track == null) return null;
return getImageUrl(track, size: size);
}
/// Clear cached track for a player (e.g., after queue transfer)
void clearPlayerTrackCache(String playerId) {
_cacheService.clearCachedTrackForPlayer(playerId);
notifyListeners();
}
/// Number of pending offline actions
int get pendingOfflineActionsCount => OfflineActionQueue.instance.pendingCount;
/// Whether there are pending offline actions
bool get hasPendingOfflineActions => OfflineActionQueue.instance.hasPendingActions;
// ============================================================================
// INITIALIZATION
// ============================================================================
MusicAssistantProvider() {
_localPlayer = LocalPlayerService(_authManager);
_initialize();
}
Future<void> _initialize() async {
_serverUrl = await SettingsService.getServerUrl();
// Load cached players from database for instant display (before connecting)
await _loadPlayersFromDatabase();
// Load cached library from SyncService for instant favorites display
await _loadLibraryFromCache();
// Load cached favorite tracks from database (tracks aren't in SyncService)
await _loadFavoriteTracksFromDatabase();
// Load cached home rows from database for instant discover/recent display
await _cacheService.loadHomeRowsFromDatabase();
// Load podcast cover cache (iTunes URLs) for instant high-res display
await _loadPodcastCoverCache();
// Initialize offline action queue
await OfflineActionQueue.instance.initialize();
if (_serverUrl != null && _serverUrl!.isNotEmpty) {
await _restoreAuthCredentials();
await connectToServer(_serverUrl!);
await _initializeLocalPlayback();
}
}
/// Load cached players from database for instant UI display
Future<void> _loadPlayersFromDatabase() async {
try {
if (!DatabaseService.instance.isInitialized) {
await DatabaseService.instance.initialize();
}
final cachedPlayers = await DatabaseService.instance.getCachedPlayers();
if (cachedPlayers.isEmpty) {
_logger.log('📦 No cached players in database');
return;
}
final players = <Player>[];
for (final cached in cachedPlayers) {
try {
final playerData = jsonDecode(cached.playerJson) as Map<String, dynamic>;
players.add(Player.fromJson(playerData));
} catch (e) {
_logger.log('⚠️ Error parsing cached player: $e');
}
}
if (players.isNotEmpty) {
_availablePlayers = players;
_cacheService.setCachedPlayers(players);
// Restore selected player from settings
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
if (lastSelectedPlayerId != null) {
_selectedPlayer = players.cast<Player?>().firstWhere(
(p) => p!.playerId == lastSelectedPlayerId,
orElse: () => null,
);
if (_selectedPlayer != null) {
_logger.log('📦 Restored selected player from database: ${_selectedPlayer?.name}');
}
}
// Fallback to local/builtin player
if (_selectedPlayer == null) {
final builtinId = await SettingsService.getBuiltinPlayerId();
if (builtinId != null) {
_selectedPlayer = players.cast<Player?>().firstWhere(
(p) => p!.playerId == builtinId,
orElse: () => null,
);
if (_selectedPlayer != null) {
_logger.log('📦 Defaulted to local player: ${_selectedPlayer?.name}');
}
}
}
if (_selectedPlayer != null) {
_cacheService.setCachedSelectedPlayer(_selectedPlayer);
}
_logger.log('📦 Loaded ${players.length} players from database (instant)');
notifyListeners();
// Also load saved playback state for instant track display
if (_selectedPlayer != null) {
await _loadPlaybackStateFromDatabase();
}
}
} catch (e) {
_logger.log('⚠️ Error loading players from database: $e');
}
}
/// Load saved playback state (currentTrack) from database for instant display
Future<void> _loadPlaybackStateFromDatabase() async {
try {
final playbackState = await DatabaseService.instance.getPlaybackState();
if (playbackState == null) {
_logger.log('📦 No saved playback state in database');
return;
}
// Only restore if it matches the current player
if (playbackState.playerId != _selectedPlayer?.playerId) {
_logger.log('📦 Saved playback state is for different player, skipping');
return;
}
if (playbackState.currentTrackJson != null) {
try {
final trackData = jsonDecode(playbackState.currentTrackJson!) as Map<String, dynamic>;
_currentTrack = Track.fromJson(trackData);
_logger.log('📦 Restored currentTrack from database: ${_currentTrack?.name}');
notifyListeners();
} catch (e) {
_logger.log('⚠️ Error parsing cached track: $e');
}
}
} catch (e) {
_logger.log('⚠️ Error loading playback state: $e');
}
}
/// Load library data from SyncService cache for instant favorites display
Future<void> _loadLibraryFromCache() async {
try {
final syncService = SyncService.instance;
// Ensure SyncService has loaded from database
if (!syncService.hasCache) {
await syncService.loadFromCache();
}
if (syncService.hasCache) {
_albums = syncService.cachedAlbums;
_artists = syncService.cachedArtists;
_logger.log('📦 Pre-loaded library for favorites: ${_albums.length} albums, ${_artists.length} artists');
_syncLibraryStatusToService();
notifyListeners();
audioHandler.invalidateAutoChildren(const [
'cat|artists', 'cat|albums', 'cat|playlists', 'cat|home',
]);
}
} catch (e) {
_logger.log('⚠️ Error loading library from cache: $e');
}
}
/// Load podcast cover cache (iTunes URLs) from storage for instant high-res display
Future<void> _loadPodcastCoverCache() async {
try {
final cache = await SettingsService.getPodcastCoverCache();
if (cache.isNotEmpty) {
_podcastCoverCache.addAll(cache);
_logger.log('📦 Loaded ${cache.length} podcast covers from cache (instant high-res)');
}
} catch (e) {
_logger.log('⚠️ Error loading podcast cover cache: $e');
}
}
/// Persist current playback state to database (fire-and-forget)
void _persistPlaybackState() {
if (_selectedPlayer == null) return;
() async {
try {
if (!DatabaseService.instance.isInitialized) return;
await DatabaseService.instance.savePlaybackState(
playerId: _selectedPlayer!.playerId,
playerName: _selectedPlayer!.name,
currentTrackJson: _currentTrack != null ? jsonEncode(_currentTrack!.toJson()) : null,
isPlaying: _selectedPlayer!.state == 'playing',
);
_logger.log('💾 Persisted playback state to database');
} catch (e) {
_logger.log('⚠️ Error persisting playback state: $e');
}
}();
}
/// Persist favorite tracks to database (fire-and-forget)
void _persistFavoriteTracks(List<Track> tracks) {
() async {
try {
if (!DatabaseService.instance.isInitialized) return;
final tracksJson = jsonEncode(tracks.map((t) => t.toJson()).toList());
await DatabaseService.instance.saveHomeRowCache('favorite_tracks', tracksJson);
_logger.log('💾 Persisted ${tracks.length} favorite tracks to database');
} catch (e) {
_logger.log('⚠️ Error persisting favorite tracks: $e');
}
}();
}
/// Load cached favorite tracks from database
Future<void> _loadFavoriteTracksFromDatabase() async {
try {
if (!DatabaseService.instance.isInitialized) return;
final data = await DatabaseService.instance.getHomeRowCache('favorite_tracks');
if (data == null) return;
final items = (jsonDecode(data.itemsJson) as List)
.map((json) => Track.fromJson(json as Map<String, dynamic>))
.toList();
_cachedFavoriteTracks = items;
_logger.log('📦 Loaded ${items.length} cached favorite tracks from database');
} catch (e) {
_logger.log('⚠️ Error loading favorite tracks: $e');
}
}
/// Persist players to database for app restart persistence
void _persistPlayersToDatabase(List<Player> players) {
// Run async but don't await - this is fire-and-forget persistence
() async {
try {
if (!DatabaseService.instance.isInitialized) return;
final playerMaps = players.map((p) => {
'playerId': p.playerId,
'playerJson': jsonEncode(p.toJson()),
'currentTrackJson': null as String?, // Will be updated separately when track changes
}).toList();
await DatabaseService.instance.cachePlayers(playerMaps);
_logger.log('💾 Persisted ${players.length} players to database');
} catch (e) {
_logger.log('⚠️ Error persisting players to database: $e');
}
}();
}
Future<void> _restoreAuthCredentials() async {
final storedToken = await SettingsService.getMaAuthToken();
if (storedToken != null) {
_logger.log('🔐 Restoring saved MA token...');
_authManager.setToken(storedToken);
_logger.log('🔐 Auth token restored');
}
}
// ============================================================================
// CONNECTION
// ============================================================================
Future<void> connectToServer(String serverUrl) async {
try {
_error = null;
_serverUrl = serverUrl;
await SettingsService.setServerUrl(serverUrl);
// Dispose the old API to stop any pending reconnects
_api?.dispose();
_api = MusicAssistantAPI(serverUrl, _authManager);
_connectionStateSubscription?.cancel();
_connectionStateSubscription = _api!.connectionState.listen(
(state) async {
if (state == MAConnectionState.connected) {
_logger.log('🔗 WebSocket connected to MA server');
if (_api!.authRequired && !_api!.isAuthenticated) {
// Don't broadcast 'connected' yet — AppStartup watches isConnected
// and would transition to HomeScreen before auth completes, causing
// data fetches to fail with "Not authenticated".
_logger.log('🔐 MA auth required, attempting authentication...');
final authenticated = await _handleMaAuthentication();
if (!authenticated) {
_connectionState = MAConnectionState.error;
_error = 'Authentication required. Please log in again.';
notifyListeners();
return;
}
// After authentication succeeds, authenticated state will trigger initialization
// Don't call _initializeAfterConnection() here - wait for authenticated state
return;
}
// No auth required — safe to broadcast connected and initialize
_connectionState = state;
notifyListeners();
await _initializeAfterConnection();
} else if (state == MAConnectionState.authenticated) {
_connectionState = state;
notifyListeners();
_logger.log('✅ MA authentication successful');
// Now safe to initialize since we're authenticated
await _initializeAfterConnection();
} else if (state == MAConnectionState.disconnected) {
_connectionState = state;
notifyListeners();
// DON'T clear players or caches on disconnect!
// Keep showing cached data for instant UI display on reconnect
// Player list and state will be refreshed when connection is restored
_logger.log('📡 Disconnected - keeping cached players and data for instant resume');
} else {
_connectionState = state;
notifyListeners();
}
},
onError: (error) {
_logger.log('Connection state stream error: $error');
_connectionState = MAConnectionState.error;
notifyListeners();
},
);
_localPlayerEventSubscription?.cancel();
_localPlayerEventSubscription = _api!.builtinPlayerEvents.listen(
_handleLocalPlayerEvent,
onError: (error) => _logger.log('Builtin player event stream error: $error'),
);
_playerUpdatedEventSubscription?.cancel();
_playerUpdatedEventSubscription = _api!.playerUpdatedEvents.listen(
_handlePlayerUpdatedEvent,
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'),
);
// Subscribe to library change events for instant UI updates
_mediaItemAddedEventSubscription?.cancel();
_mediaItemAddedEventSubscription = _api!.mediaItemAddedEvents.listen(
_handleMediaItemAddedEvent,
onError: (error) => _logger.log('Media item added event stream error: $error'),
);
_mediaItemDeletedEventSubscription?.cancel();
_mediaItemDeletedEventSubscription = _api!.mediaItemDeletedEvents.listen(
_handleMediaItemDeletedEvent,
onError: (error) => _logger.log('Media item deleted event stream error: $error'),
);
await _api!.connect();
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Connect to server');
_error = errorInfo.userMessage;
_connectionState = MAConnectionState.error;
_logger.log('Connection error: ${errorInfo.technicalMessage}');
notifyListeners();
rethrow;
}
}
Future<bool> _handleMaAuthentication() async {
if (_api == null) return false;
try {
final storedToken = await SettingsService.getMaAuthToken();
if (storedToken != null) {
_logger.log('🔐 Trying stored MA token...');
final success = await _api!.authenticateWithToken(storedToken);
if (success) {
_logger.log('✅ MA authentication with stored token successful');
await _fetchUserSettings();
return true;
}
_logger.log('⚠️ Stored MA token invalid, clearing...');
await SettingsService.clearMaAuthToken();
}
final username = await SettingsService.getUsername();
final password = await SettingsService.getPassword();
if (username != null && password != null && username.isNotEmpty && password.isNotEmpty) {
_logger.log('🔐 Trying stored credentials...');
final accessToken = await _api!.loginWithCredentials(username, password);
if (accessToken != null) {
_logger.log('✅ MA login with stored credentials successful');
final longLivedToken = await _api!.createLongLivedToken();
if (longLivedToken != null) {
await SettingsService.setMaAuthToken(longLivedToken);
_logger.log('✅ Saved new long-lived MA token');
} else {
await SettingsService.setMaAuthToken(accessToken);
}
await _fetchUserSettings();
return true;
}
}
_logger.log('❌ MA authentication failed - no valid token or credentials');
return false;
} catch (e) {
_logger.log('❌ MA authentication error: $e');
return false;
}
}
Future<void> _fetchUserSettings() async {
if (_api == null) return;
try {
final userInfo = await _api!.getCurrentUserInfo();
if (userInfo == null) return;
// Set profile name
final displayName = userInfo['display_name'] as String?;
final username = userInfo['username'] as String?;
final profileName = (displayName != null && displayName.isNotEmpty) ? displayName : username;
if (profileName != null && profileName.isNotEmpty) {
await SettingsService.setOwnerName(profileName);
_logger.log('✅ Set owner name from MA profile: $profileName');
}
// Capture provider filter (empty list = all providers allowed)
final providerFilterRaw = userInfo['provider_filter'];
if (providerFilterRaw is List) {
_providerFilter = providerFilterRaw.cast<String>().toList();
if (_providerFilter.isNotEmpty) {
_logger.log('🔒 Provider filter active: ${_providerFilter.length} providers allowed');
_logger.log(' Allowed: ${_providerFilter.join(", ")}');
} else {
_logger.log('🔓 No provider filter - all providers visible');
}
} else {
_providerFilter = [];
_logger.log('🔓 No provider filter in user settings');
}
// Capture player filter (empty list = all players allowed)
final playerFilterRaw = userInfo['player_filter'];
if (playerFilterRaw is List) {
_playerFilter = playerFilterRaw.cast<String>().toList();
if (_playerFilter.isNotEmpty) {
_logger.log('🔒 Player filter active: ${_playerFilter.length} players allowed');
_logger.log(' Allowed: ${_playerFilter.join(", ")}');
} else {
_logger.log('🔓 No player filter - all players visible');
}
} else {
_playerFilter = [];
_logger.log('🔓 No player filter in user settings');
}
} catch (e) {
_logger.log('⚠️ Could not fetch user settings (non-fatal): $e');
}
}
/// Load available music providers from MA and restore saved filter settings
Future<void> loadMusicProviders() async {
if (_api == null) return;
try {
_logger.log('🎵 Loading music providers...');
// Fetch available providers from MA
_availableMusicProviders = await _api!.getMusicProviders();
// Save discovered providers to settings
final discoveredList = _availableMusicProviders.map((p) => {
'instanceId': p.instanceId,
'domain': p.domain,
'name': p.name,
}).toList();
await SettingsService.setDiscoveredMusicProviders(discoveredList);
// Load saved enabled providers
final savedEnabled = await SettingsService.getEnabledMusicProviders();
if (savedEnabled != null) {
// Validate saved IDs against available providers
final availableIds = _availableMusicProviders.map((p) => p.instanceId).toSet();
_enabledProviderIds = savedEnabled.where((id) => availableIds.contains(id)).toList();
// If all saved providers are gone, reset to all enabled
if (_enabledProviderIds.isEmpty && savedEnabled.isNotEmpty) {
_enabledProviderIds = [];
await SettingsService.clearEnabledMusicProviders();
_logger.log('🔄 Reset provider filter - saved providers no longer available');
} else if (_enabledProviderIds.isNotEmpty) {
_logger.log('🔒 Loaded provider filter: ${_enabledProviderIds.length} providers enabled');
}
} else {
_enabledProviderIds = [];
_logger.log('🔓 No provider filter - all ${_availableMusicProviders.length} providers enabled');
}
notifyListeners();
} catch (e) {
_logger.log('⚠️ Could not load music providers (non-fatal): $e');
}
}
/// Toggle a specific music provider on/off
/// Uses debounce to allow multiple toggles before syncing
Future<bool> toggleProviderEnabled(String instanceId, bool enabled) async {
final allIds = _availableMusicProviders.map((p) => p.instanceId).toList();
// Don't allow disabling the last provider
if (!enabled && _enabledProviderIds.isNotEmpty && _enabledProviderIds.length <= 1) {
_logger.log('⚠️ Cannot disable last provider');
return false;
}
// Update local state directly for immediate UI response
// If currently "all enabled" (empty list) and disabling one, need to switch to explicit mode
if (_enabledProviderIds.isEmpty && !enabled) {
// Initialize with all providers except the one being disabled
_enabledProviderIds = allIds.where((id) => id != instanceId).toList();
} else if (enabled) {
// Add to list if not present
if (!_enabledProviderIds.contains(instanceId)) {
_enabledProviderIds = [..._enabledProviderIds, instanceId];
}
} else {
// Remove from list
_enabledProviderIds = _enabledProviderIds.where((id) => id != instanceId).toList();
}
// Persist to settings (async, but don't wait for UI update)
SettingsService.toggleMusicProvider(instanceId, enabled, allIds);
_logger.log('🔄 Provider filter updated: ${_enabledProviderIds.isEmpty ? "all enabled" : "${_enabledProviderIds.length} enabled"}');
// Clear discover caches so they get refetched with new filter
_cacheService.clearDiscoverCaches();
// Notify listeners IMMEDIATELY so UI rebuilds with client-side filtering
// This enables instant UI updates using cached data with source tracking
notifyListeners();
// NOTE: Debounced sync is handled by the UI layer (new_library_screen.dart)
// to avoid duplicate timers and ensure single point of control
// Return true to indicate UI should update
return true;
}
Future<void> _initializeAfterConnection() async {
if (_api == null) return;
try {
_logger.log('🚀 Initializing after connection...');
await _api!.fetchState();
if (_api!.authRequired) {
await _fetchUserSettings();
}
// Load available music providers and user's filter preferences
await loadMusicProviders();
await _tryAdoptGhostPlayer();
await _registerLocalPlayer();
await _loadAndSelectPlayers(coldStart: true);
loadLibrary();
// Only force home refresh when connecting to a different server (or first connect).
// Reconnecting to the same server after backgrounding preserves cached home data.
if (_serverUrl != _lastConnectedUrl) {
_cacheService.invalidateHomeCache();
_homeRefreshCounter++;
}
_lastConnectedUrl = _serverUrl;
// Notify listeners to trigger UI refresh - home rows will re-fetch with valid authentication
notifyListeners();
// Process any queued offline actions now that we're connected
await _processOfflineQueue();
_logger.log('✅ Post-connection initialization complete');
} catch (e) {
_logger.log('❌ Error during post-connection initialization: $e');
_error = 'Failed to initialize after connection';
notifyListeners();
}
}
/// Process queued offline actions (favorites, playlist modifications, etc.)
Future<void> _processOfflineQueue() async {
if (_api == null || !isConnected) return;
final queue = OfflineActionQueue.instance;
if (!queue.hasPendingActions) return;
_logger.log('📋 Processing ${queue.pendingCount} offline actions...');
await queue.processQueue((action) async {
try {
switch (action.type) {
case OfflineActionTypes.toggleFavorite:
return await _executeToggleFavorite(action.params);
case OfflineActionTypes.addToPlaylist:
return await _executeAddToPlaylist(action.params);
case OfflineActionTypes.removeFromPlaylist:
return await _executeRemoveFromPlaylist(action.params);
default:
_logger.log('⚠️ Unknown offline action type: ${action.type}');
return false;
}
} catch (e) {
_logger.log('❌ Error executing offline action ${action.type}: $e');
return false;
}
});
}
/// Execute a queued toggle favorite action
Future<bool> _executeToggleFavorite(Map<String, dynamic> params) async {
if (_api == null) return false;
final mediaType = params['mediaType'] as String;
final add = params['add'] as bool;
if (add) {
final itemId = params['itemId'] as String;
final provider = params['provider'] as String;
await _api!.addToFavorites(mediaType, itemId, provider);
} else {
final libraryItemId = params['libraryItemId'] as int;
await _api!.removeFromFavorites(mediaType, libraryItemId);
}
return true;
}
/// Execute a queued add to playlist action
Future<bool> _executeAddToPlaylist(Map<String, dynamic> params) async {
// TODO: Implement when playlist modification is added
return false;
}
/// Execute a queued remove from playlist action
Future<bool> _executeRemoveFromPlaylist(Map<String, dynamic> params) async {
// TODO: Implement when playlist modification is added
return false;
}
// ============================================================================
// FAVORITE MANAGEMENT (WITH OFFLINE SUPPORT)
// ============================================================================
/// Add item to favorites with offline queuing support
/// Returns true if action was executed or queued successfully
Future<bool> addToFavorites({
required String mediaType,
required String itemId,
required String provider,
}) async {
if (isConnected && _api != null) {
// Online - execute immediately
try {
await _api!.addToFavorites(mediaType, itemId, provider);
// Update local cache for instant UI feedback
_updateLocalFavoriteStatus(mediaType, itemId, true);
return true;
} catch (e) {
_logger.log('❌ Failed to add to favorites: $e');
return false;
}
} else {
// Offline - queue the action
await OfflineActionQueue.instance.queueAction(
OfflineActionTypes.toggleFavorite,
{
'mediaType': mediaType,
'add': true,
'itemId': itemId,
'provider': provider,
},
);
_logger.log('📋 Queued add to favorites (offline): $mediaType');
// Still update local state for offline support
_updateLocalFavoriteStatus(mediaType, itemId, true);
return true;
}
}
/// Remove item from favorites with offline queuing support
/// Returns true if action was executed or queued successfully
Future<bool> removeFromFavorites({
required String mediaType,
required int libraryItemId,
}) async {
if (isConnected && _api != null) {
// Online - execute immediately
try {
await _api!.removeFromFavorites(mediaType, libraryItemId);
// Update local cache for instant UI feedback
_updateLocalFavoriteStatusByLibraryId(mediaType, libraryItemId, false);
return true;
} catch (e) {
_logger.log('❌ Failed to remove from favorites: $e');
return false;
}
} else {
// Offline - queue the action
await OfflineActionQueue.instance.queueAction(
OfflineActionTypes.toggleFavorite,
{
'mediaType': mediaType,
'add': false,
'libraryItemId': libraryItemId,
},
);
_logger.log('📋 Queued remove from favorites (offline): $mediaType');
// Still update local state for offline support
_updateLocalFavoriteStatusByLibraryId(mediaType, libraryItemId, false);
return true;
}
}
/// Update favorite status in local cache for instant UI feedback
void _updateLocalFavoriteStatus(String mediaType, String itemId, bool isFavorite) {
bool updated = false;
if (mediaType == 'artist') {
final index = _artists.indexWhere((a) => a.itemId == itemId);
if (index != -1) {
final artist = _artists[index];
_artists[index] = Artist(
itemId: artist.itemId,
provider: artist.provider,
name: artist.name,
sortName: artist.sortName,
uri: artist.uri,
providerMappings: artist.providerMappings,
metadata: artist.metadata,
favorite: isFavorite,
);
updated = true;
}
} else if (mediaType == 'album') {
final index = _albums.indexWhere((a) => a.itemId == itemId);
if (index != -1) {
final album = _albums[index];
_albums[index] = Album(
itemId: album.itemId,
provider: album.provider,
name: album.name,
artists: album.artists,
albumType: album.albumType,
year: album.year,
sortName: album.sortName,
uri: album.uri,
providerMappings: album.providerMappings,
metadata: album.metadata,
favorite: isFavorite,
);
updated = true;
}
} else if (mediaType == 'track') {
final index = _tracks.indexWhere((t) => t.itemId == itemId);
if (index != -1) {
final track = _tracks[index];
_tracks[index] = Track(
itemId: track.itemId,
provider: track.provider,
name: track.name,
artists: track.artists,
album: track.album,
duration: track.duration,
sortName: track.sortName,
uri: track.uri,
providerMappings: track.providerMappings,
metadata: track.metadata,
favorite: isFavorite,
);
updated = true;
}
}
if (updated) {
notifyListeners();
}
}
/// Update favorite status by library item ID (for remove operations)
void _updateLocalFavoriteStatusByLibraryId(String mediaType, int libraryItemId, bool isFavorite) {
final libraryIdStr = libraryItemId.toString();
bool updated = false;
if (mediaType == 'artist') {
final index = _artists.indexWhere((a) =>
a.provider == 'library' && a.itemId == libraryIdStr ||
a.providerMappings?.any((m) => m.providerInstance == 'library' && m.itemId == libraryIdStr) == true
);
if (index != -1) {
final artist = _artists[index];
_artists[index] = Artist(
itemId: artist.itemId,
provider: artist.provider,
name: artist.name,
sortName: artist.sortName,
uri: artist.uri,
providerMappings: artist.providerMappings,
metadata: artist.metadata,
favorite: isFavorite,
);
updated = true;
}
} else if (mediaType == 'album') {
final index = _albums.indexWhere((a) =>
a.provider == 'library' && a.itemId == libraryIdStr ||
a.providerMappings?.any((m) => m.providerInstance == 'library' && m.itemId == libraryIdStr) == true
);
if (index != -1) {
final album = _albums[index];
_albums[index] = Album(
itemId: album.itemId,
provider: album.provider,
name: album.name,
artists: album.artists,
albumType: album.albumType,
year: album.year,
sortName: album.sortName,
uri: album.uri,
providerMappings: album.providerMappings,
metadata: album.metadata,
favorite: isFavorite,
);
updated = true;
}
} else if (mediaType == 'track') {
final index = _tracks.indexWhere((t) =>
t.provider == 'library' && t.itemId == libraryIdStr ||
t.providerMappings?.any((m) => m.providerInstance == 'library' && m.itemId == libraryIdStr) == true
);
if (index != -1) {
final track = _tracks[index];
_tracks[index] = Track(
itemId: track.itemId,
provider: track.provider,
name: track.name,
artists: track.artists,
album: track.album,
duration: track.duration,
sortName: track.sortName,
uri: track.uri,
providerMappings: track.providerMappings,
metadata: track.metadata,
favorite: isFavorite,
);
updated = true;
}
}
if (updated) {
notifyListeners();
}
}
// ============================================================================
// LIBRARY MANAGEMENT
// ============================================================================
/// Sync library status to centralized service for reactive UI updates
void _syncLibraryStatusToService() {
final service = LibraryStatusService.instance;
// Sync albums
service.syncFromLibrary(
mediaType: 'album',
items: _albums,
getInLibrary: (a) => (a as Album).inLibrary,
getFavorite: (a) => (a as Album).favorite ?? false,
getProvider: (a) => (a as Album).provider,
getItemId: (a) => (a as Album).itemId,
);
// Sync artists
service.syncFromLibrary(
mediaType: 'artist',
items: _artists,
getInLibrary: (a) => (a as Artist).inLibrary,
getFavorite: (a) => (a as Artist).favorite ?? false,
getProvider: (a) => (a as Artist).provider,
getItemId: (a) => (a as Artist).itemId,
);
// Sync tracks
service.syncFromLibrary(
mediaType: 'track',
items: _tracks,
getInLibrary: (t) => (t as Track).inLibrary,
getFavorite: (t) => (t as Track).favorite ?? false,
getProvider: (t) => (t as Track).provider,
getItemId: (t) => (t as Track).itemId,
);
}
/// Add item to library
/// Returns true if action was executed successfully
Future<bool> addToLibrary({
required String mediaType,
required String itemId,
required String provider,
}) async {
if (isConnected && _api != null) {
try {
await _api!.addItemToLibrary(mediaType, itemId, provider);
// Trigger a background refresh to update library with new item
_scheduleLibraryRefresh(mediaType);
// Invalidate caches that could show stale inLibrary status
_cacheService.invalidateSearchCache();
if (mediaType == 'album') {
_cacheService.invalidateArtistAlbumsCache();
_cacheService.invalidateHomeAlbumCaches();
} else if (mediaType == 'track') {
_cacheService.invalidateAllAlbumTracksCaches();
_cacheService.invalidateAllPlaylistTracksCaches();
} else if (mediaType == 'artist') {
_cacheService.invalidateHomeArtistCaches();
} else if (mediaType == 'audiobook') {
_cacheService.invalidateAudiobookCaches();
_cacheService.invalidateHomeCache();
}
return true;
} catch (e) {
_logger.log('❌ Failed to add to library: $e');
return false;
}
} else {
_logger.log('❌ Cannot add to library while offline');
return false;
}
}
/// Remove item from library
/// Returns true if action was executed successfully
/// Uses optimistic update: local cache is updated immediately before API call
Future<bool> removeFromLibrary({
required String mediaType,
required int libraryItemId,
}) async {
if (isConnected && _api != null) {
// OPTIMISTIC UPDATE: Update local cache immediately for instant UI feedback
// This ensures library screens show the change even before API completes
_removeFromLocalLibrary(mediaType, libraryItemId);
// Also remove from SyncService's in-memory cache
SyncService.instance.removeFromCacheByLibraryId(mediaType, libraryItemId);
// Also mark as deleted in database cache so it doesn't reappear on next load
// Use the library ID aware method that handles cases where itemId differs from libraryId
DatabaseService.instance.markCachedItemDeletedByLibraryId(mediaType, libraryItemId.toString());
// Invalidate caches that could show stale inLibrary status
_cacheService.invalidateSearchCache();
if (mediaType == 'album') {
_cacheService.invalidateArtistAlbumsCache();
_cacheService.invalidateHomeAlbumCaches();
} else if (mediaType == 'track') {
_cacheService.invalidateAllAlbumTracksCaches();
_cacheService.invalidateAllPlaylistTracksCaches();
} else if (mediaType == 'artist') {
_cacheService.invalidateHomeArtistCaches();
} else if (mediaType == 'audiobook') {
_cacheService.invalidateAudiobookCaches();
_cacheService.invalidateHomeCache();
}
try {
await _api!.removeItemFromLibrary(mediaType, libraryItemId);
return true;
} catch (e) {
final errorStr = e.toString().toLowerCase();
// "not found in library" means item is already removed - treat as success
if (errorStr.contains('not found in library')) {
_logger.log('ℹ️ Item already removed from library');
return true;
}
// On actual error, we'd ideally restore the item, but that's complex
// For now, just log the error - the background refresh will fix state
_logger.log('❌ Failed to remove from library (local cache already updated): $e');
_scheduleLibraryRefresh(mediaType);
return false;
}
} else {
_logger.log('❌ Cannot remove from library while offline');
return false;
}
}
/// Remove item from local library cache for instant UI feedback
/// Creates new list instances to ensure Selector widgets detect changes
void _removeFromLocalLibrary(String mediaType, int libraryItemId) {
final libraryIdStr = libraryItemId.toString();
bool updated = false;
// Helper to check if item matches the library ID being removed
// Checks both direct provider=library match AND providerMappings
bool matchesLibraryId(String? provider, String? itemId, List<ProviderMapping>? mappings) {
// Direct match: item's own provider is 'library' and ID matches
if (provider == 'library' && itemId == libraryIdStr) {
return true;
}
// Mapping match: check if any providerMapping has library instance with matching ID
if (mappings != null) {
for (final m in mappings) {
if ((m.providerInstance == 'library' || m.providerDomain == 'library') &&
m.itemId == libraryIdStr) {
return true;
}
}
}
return false;
}
if (mediaType == 'artist') {
final before = _artists.length;
// Create new list to trigger Selector rebuilds (reference equality)
_artists = _artists.where((a) =>
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
).toList();
updated = _artists.length != before;
} else if (mediaType == 'album') {
final before = _albums.length;
_albums = _albums.where((a) =>
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
).toList();
updated = _albums.length != before;
} else if (mediaType == 'track') {
final before = _tracks.length;
_tracks = _tracks.where((t) =>
!matchesLibraryId(t.provider, t.itemId, t.providerMappings)
).toList();
updated = _tracks.length != before;
} else if (mediaType == 'radio') {
final before = _radioStations.length;
_radioStations = _radioStations.where((r) =>
!matchesLibraryId(r.provider, r.itemId, r.providerMappings)
).toList();
updated = _radioStations.length != before;
} else if (mediaType == 'podcast') {
final before = _podcasts.length;
_podcasts = _podcasts.where((p) =>
!matchesLibraryId(p.provider, p.itemId, p.providerMappings)
).toList();
updated = _podcasts.length != before;
} else if (mediaType == 'audiobook') {
final before = _audiobooks.length;
_audiobooks = _audiobooks.where((a) =>
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
).toList();
updated = _audiobooks.length != before;
}
if (updated) {
_logger.log('🗑️ Removed $mediaType with libraryId=$libraryItemId from local cache');
notifyListeners();
}
}
/// Refresh library data for a media type after library change
/// Runs immediately (no delay) to ensure UI stays in sync
void _scheduleLibraryRefresh(String mediaType) {
// Run immediately - no delay to ensure data consistency
() async {
if (!isConnected || _api == null) return;
try {
if (mediaType == 'artist') {
_artists = await _api!.getArtists(
limit: LibraryConstants.maxLibraryItems,
albumArtistsOnly: false,
);
} else if (mediaType == 'album') {
_albums = await _api!.getAlbums(limit: LibraryConstants.maxLibraryItems);
} else if (mediaType == 'track') {
_tracks = await _api!.getTracks(limit: LibraryConstants.maxLibraryItems);
} else if (mediaType == 'radio') {
_radioStations = await _api!.getRadioStations(limit: 100);
} else if (mediaType == 'podcast') {
_podcasts = await _api!.getPodcasts(limit: 100);
} else if (mediaType == 'audiobook') {
_audiobooks = await _api!.getAudiobooks(limit: LibraryConstants.maxLibraryItems);
_cacheService.invalidateAudiobookCaches();
}
_syncLibraryStatusToService();
notifyListeners();
} catch (e) {
_logger.log('⚠️ Background library refresh failed: $e');
}
}();
}
Future<void> disconnect() async {
_playerStateTimer?.cancel();
_playerStateTimer = null;
_notificationPositionTimer?.cancel();
_notificationPositionTimer = null;
_localPlayerStateReportTimer?.cancel();
_localPlayerEventSubscription?.cancel();
_playerUpdatedEventSubscription?.cancel();
_playerAddedEventSubscription?.cancel();
_mediaItemAddedEventSubscription?.cancel();
_mediaItemDeletedEventSubscription?.cancel();
_positionTracker.clear();
_imagePrefetchService?.cancel();
_imagePrefetchService = null;
// Disconnect Sendspin and PCM player if connected
if (_sendspinConnected) {
await _pcmAudioPlayer?.disconnect();
await _sendspinService?.disconnect();
_sendspinConnected = false;
}
await _api?.disconnect();
_connectionState = MAConnectionState.disconnected;
// DON'T clear caches or player state - keep for instant reconnect
_logger.log('📡 Explicit disconnect - keeping cached data for instant resume');
notifyListeners();
}
/// Clear all caches and state (for logout or server change)
void clearAllOnLogout() {
_availablePlayers = [];
_selectedPlayer = null;
_artists = [];
_albums = [];
_tracks = [];
_currentTrack = null;
_cacheService.clearAll();
_groupVolumeManager.clear();
_pendingVolumes.clear();
_pendingVolumeTimestamps.clear();
_logger.log('🗑️ Cleared all data on logout');
notifyListeners();
}
Future<void> checkAndReconnect() async {
// Deduplicate: if a reconnect is already in progress, join it
if (_reconnectInProgress != null) {
_logger.log('🔄 checkAndReconnect called — already in progress, joining existing attempt');
return _reconnectInProgress!.future;
}
_logger.log('🔄 checkAndReconnect called - state: $_connectionState');
if (_serverUrl == null) {
_logger.log('🔄 No server URL saved, skipping reconnect');
return;
}
_reconnectInProgress = Completer<void>();
try {
// IMMEDIATELY load cached players for instant UI display
// This makes mini player and device button appear instantly on app resume
if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) {
_availablePlayers = _cacheService.getCachedPlayers()!;
// Sort cached players immediately so list appears in correct order
final smartSort = await SettingsService.getSmartSortPlayers();
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
_sortPlayersSync(_availablePlayers, smartSort, builtinPlayerId);
_selectedPlayer = _cacheService.getCachedSelectedPlayer();
// Also try to restore from settings if cache doesn't have selected player
if (_selectedPlayer == null && _availablePlayers.isNotEmpty) {
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
if (lastSelectedPlayerId != null) {
_selectedPlayer = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == lastSelectedPlayerId,
orElse: () => null,
);
}
// Fallback to local/builtin player
if (_selectedPlayer == null) {
final builtinId = await SettingsService.getBuiltinPlayerId();
if (builtinId != null) {
_selectedPlayer = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == builtinId,
orElse: () => null,
);
}
}
// Last resort: first available (only if no builtin player configured)
if (_selectedPlayer == null) {
final builtinConfigured = await SettingsService.getBuiltinPlayerId();
if (builtinConfigured == null) {
_selectedPlayer = _availablePlayers.first;
}
}
}
_logger.log('⚡ Loaded ${_availablePlayers.length} cached players instantly (sorted)');
notifyListeners(); // Update UI immediately with cached data
}
if (_connectionState != MAConnectionState.connected &&
_connectionState != MAConnectionState.authenticated) {
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
try {
await connectToServer(_serverUrl!);
_logger.log('🔄 Reconnection successful');
} catch (e) {
_logger.log('🔄 Reconnection failed: $e');
}
} else {
_logger.log('🔄 Already connected, verifying connection...');
try {
await refreshPlayers();
await _updatePlayerState();
// Note: _preloadAdjacentPlayers is already called in refreshPlayers() -> _loadAndSelectPlayers()
_logger.log('🔄 Connection verified, players and state refreshed');
} catch (e) {
_logger.log('🔄 Connection verification failed, reconnecting: $e');
try {
await connectToServer(_serverUrl!);
} catch (reconnectError) {
_logger.log('🔄 Reconnection failed: $reconnectError');
}
}
}
_reconnectInProgress!.complete();
} catch (e) {
_reconnectInProgress!.completeError(e);
} finally {
_reconnectInProgress = null;
}
}
// ============================================================================
// LOCAL PLAYER
// ============================================================================
Future<void> _initializeLocalPlayback() async {
await _localPlayer.initialize();
_isLocalPlayerPowered = true;
// Wire up notification button callbacks
audioHandler.onSkipToNext = () {
_logger.log('🎵 Notification: Skip to next pressed');
nextTrackSelectedPlayer();
};
audioHandler.onSkipToPrevious = () {
_logger.log('🎵 Notification: Skip to previous pressed');
previousTrackSelectedPlayer();
};
audioHandler.onPlay = () {
_logger.log('🎵 Notification: Play pressed');
if (_selectedPlayer != null) {
resumePlayer(_selectedPlayer!.playerId);
unawaited(refreshPlayers());
}
};
audioHandler.onPause = () {
_logger.log('🎵 Notification: Pause pressed');
if (_selectedPlayer != null) {
pausePlayer(_selectedPlayer!.playerId);
}
};
audioHandler.onSwitchPlayer = () {
_logger.log('🎵 Notification: Switch player pressed');
selectNextPlayer();
};
audioHandler.onBrowseActivity = () {
_cancelIdleServiceTimer();
};
audioHandler.onAAConnected = () async {
final playerId = await SettingsService.getBuiltinPlayerId();
if (playerId != null && _selectedPlayer?.playerId != playerId) {
final builtinPlayer = _availablePlayers
.where((p) => p.playerId == playerId)
.firstOrNull;
if (builtinPlayer != null) {
_logger.log('🎵 AA connected: auto-selecting builtin player "${builtinPlayer.name}"');
selectPlayer(builtinPlayer);
}
}
};
audioHandler.onAADisconnected = () async {
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null) {
_logger.log('🎵 AA disconnected: pausing builtin player');
pausePlayer(builtinPlayerId);
}
};
// Player registration is now handled in _initializeAfterConnection()
// which runs after authentication completes (when auth is required)
}
Future<bool> _tryAdoptGhostPlayer() async {
if (_api == null) return false;
try {
final isFresh = await DeviceIdService.isFreshInstallation();
if (!isFresh) return false;
final ownerName = await SettingsService.getOwnerName();
if (ownerName == null || ownerName.isEmpty) return false;
_logger.log('👻 Fresh install detected, searching for adoptable ghost for "$ownerName"...');
final adoptableId = await _api!.findAdoptableGhostPlayer(ownerName);
if (adoptableId == null) return false;
_logger.log('👻 Found adoptable ghost: $adoptableId');
await DeviceIdService.adoptPlayerId(adoptableId);
_logger.log('✅ Successfully adopted ghost player');
return true;
} catch (e) {
_logger.log('⚠️ Ghost adoption failed (non-fatal): $e');
return false;
}
}
/// Parse server version into components for version comparisons
/// Returns null if version cannot be parsed
({int major, int minor, int patch, int? beta})? _parseServerVersion() {
final serverInfo = _api?.serverInfo;
if (serverInfo == null) return null;
final versionStr = serverInfo['server_version'] as String?;
if (versionStr == null) return null;
// Parse version like "2.8.0b2" or "2.7.0b20" or "2.7.1"
// Format: MAJOR.MINOR.PATCH[bBETA]
final versionRegex = RegExp(r'^(\d+)\.(\d+)\.(\d+)(?:b(\d+))?');
final match = versionRegex.firstMatch(versionStr);
if (match == null) return null;
return (
major: int.parse(match.group(1)!),
minor: int.parse(match.group(2)!),
patch: int.parse(match.group(3)!),
beta: match.group(4) != null ? int.parse(match.group(4)!) : null,
);
}
/// Check if server version is >= 2.7.0b20 (uses Sendspin instead of builtin_player)
bool _serverUsesSendspin() {
final version = _parseServerVersion();
if (version == null) return false;
final (:major, :minor, :patch, :beta) = version;
// Compare with 2.7.0b20
if (major > 2) return true;
if (major < 2) return false;
// major == 2
if (minor > 7) return true;
if (minor < 7) return false;
// minor == 7
if (patch > 0) return true;
if (patch < 0) return false;
// patch == 0, so version is 2.7.0 - need beta >= 20
if (beta == null) return true; // 2.7.0 release is newer than 2.7.0b20
return beta >= 20;
}
/// Check if server has built-in Sendspin proxy (added in MA 2.7.1)
bool _serverHasSendspinProxy() {
final version = _parseServerVersion();
if (version == null) return false;
final (:major, :minor, :patch, :beta) = version;
// Compare with 2.7.1
if (major > 2) return true;
if (major < 2) return false;
// major == 2
if (minor > 7) return true;
if (minor < 7) return false;
// minor == 7, need patch >= 1
return patch >= 1;
}
/// Get the server version string for logging
String _getServerVersionString() {
final serverInfo = _api?.serverInfo;
return serverInfo?['server_version'] as String? ?? 'unknown';
}
Future<void> _registerLocalPlayer() async {
if (_api == null) return;
if (_registrationInProgress != null) {
_logger.log('⏳ Registration already in progress, waiting...');
return _registrationInProgress!.future;
}
_registrationInProgress = Completer<void>();
try {
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
_logger.log('🆔 Using player ID: $playerId');
await SettingsService.setBuiltinPlayerId(playerId);
final name = await SettingsService.getLocalPlayerName();
// Check if server uses Sendspin (MA 2.7.0b20+) - skip builtin_player entirely
if (_serverUsesSendspin()) {
_logger.log('📡 Server uses Sendspin (MA 2.7.0b20+), skipping builtin_player');
_builtinPlayerAvailable = false;
final sendspinSuccess = await _connectViaSendspin();
if (sendspinSuccess) {
_logger.log('✅ Connected via Sendspin - local player available');
_startReportingLocalPlayerState();
} else {
_logger.log('⚠️ Sendspin connection failed - local player unavailable');
}
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.complete();
}
_registrationInProgress = null;
return;
}
final existingPlayers = await _api!.getPlayers();
final existingPlayer = existingPlayers.where((p) => p.playerId == playerId).firstOrNull;
if (existingPlayer != null && existingPlayer.available) {
_logger.log('✅ Player already registered and available: $playerId');
_startReportingLocalPlayerState();
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.complete();
}
_registrationInProgress = null;
return;
} else if (existingPlayer != null && !existingPlayer.available) {
_logger.log('⚠️ Player exists but unavailable (stale), re-registering: $playerId');
} else {
_logger.log('🆔 Player not found in MA, registering as new');
}
_logger.log('🎵 Registering player with MA: id=$playerId, name=$name');
await _api!.registerBuiltinPlayer(playerId, name);
_logger.log('✅ Player registration complete');
_startReportingLocalPlayerState();
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.complete();
}
_registrationInProgress = null;
} catch (e) {
// Check if this is because builtin_player API is not available (MA 2.7.0b20+)
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)');
_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
}
_logger.log('❌ Player registration failed: $e');
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.completeError(e);
}
_registrationInProgress = null;
rethrow;
}
}
/// 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 depends on MA version and network:
/// - MA 2.7.1+: Has built-in /sendspin proxy, works with any reverse proxy setup
/// - MA 2.7.x: NO proxy, must either:
/// - Use local IP with port 8927 exposed, OR
/// - Manually configure reverse proxy to route /sendspin to port 8927
Future<bool> _connectViaSendspin() async {
if (_api == null || _serverUrl == null) return false;
try {
final serverVersion = _getServerVersionString();
final hasProxy = _serverHasSendspinProxy();
_logger.log('Sendspin: Server version $serverVersion, has proxy: $hasProxy');
// Initialize Sendspin service
_sendspinService?.dispose();
_sendspinService = SendspinService(_serverUrl!);
// Set auth token for proxy authentication (MA 2.7.1+ or manually configured proxy)
final authToken = await SettingsService.getMaAuthToken();
if (authToken != null) {
_sendspinService!.setAuthToken(authToken);
_logger.log('Sendspin: Auth token set for proxy authentication');
}
// 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();
_logger.log('Sendspin: Player ID: $playerId');
// Parse server URL to determine connection strategy
final serverUri = Uri.parse(_serverUrl!.startsWith('http')
? _serverUrl!
: 'https://$_serverUrl');
final isLocalIp = _isLocalNetworkHost(serverUri.host);
final isHttps = serverUri.scheme == 'https' ||
(!_serverUrl!.contains('://') && !isLocalIp);
// Strategy 1: For local IPs, connect directly to Sendspin port 8927
if (isLocalIp) {
final localSendspinUrl = 'ws://${serverUri.host}:8927/sendspin';
_logger.log('Sendspin: Local network detected, trying direct connection: $localSendspinUrl');
final connected = await _sendspinService!.connectWithUrl(localSendspinUrl);
if (connected) {
_sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via local network (port 8927)');
return true;
}
_logger.log('⚠️ Sendspin: Local connection to port 8927 failed');
// For local IPs, also try the proxy path (MA 2.7.1+ has built-in /sendspin proxy)
_logger.log('Sendspin: Trying local proxy fallback...');
final localProxyUrl = 'ws://${serverUri.host}:${serverUri.hasPort ? serverUri.port : 8095}/sendspin';
final proxyConnected = await _sendspinService!.connectWithUrl(localProxyUrl, useProxyAuth: true);
if (proxyConnected) {
_sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via local proxy');
return true;
}
}
// Strategy 2: For external/HTTPS servers, use the proxy at /sendspin
if (isHttps || !isLocalIp) {
if (hasProxy) {
_logger.log('Sendspin: MA 2.7.1+ detected, using built-in /sendspin proxy');
} else {
_logger.log('Sendspin: MA 2.7.x detected, trying /sendspin (requires manual proxy config)');
}
final connected = await _sendspinService!.connect();
if (connected) {
_sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via external proxy');
return true;
}
_logger.log('⚠️ Sendspin: External proxy connection failed');
}
// All strategies failed - provide version-specific guidance
_logger.log('❌ Sendspin: All connection strategies failed');
_logSendspinTroubleshooting(isLocalIp, hasProxy, serverVersion);
return false;
} catch (e) {
_logger.log('❌ Sendspin connection error: $e');
return false;
}
}
/// Log troubleshooting guidance based on setup
void _logSendspinTroubleshooting(bool isLocalIp, bool hasProxy, String serverVersion) {
if (isLocalIp) {
_logger.log('ℹ️ LOCAL IP SETUP: Add port 8927 to your Docker compose:');
_logger.log(' ports:');
_logger.log(' - "8095:8095"');
_logger.log(' - "8927:8927" # Required for Sendspin');
} else if (!hasProxy) {
// MA 2.7.x without built-in proxy
_logger.log('ℹ️ MA $serverVersion does not have built-in /sendspin proxy');
_logger.log('ℹ️ OPTIONS:');
_logger.log(' 1. Upgrade to Music Assistant 2.8.0+ (recommended)');
_logger.log(' 2. Or add reverse proxy config for /sendspin → port 8927');
_logger.log(' Traefik: PathPrefix(`/sendspin`) → service port 8927');
_logger.log(' Nginx: location /sendspin { proxy_pass http://ma:8927; }');
} else {
// MA 2.7.1+ but still failing
_logger.log('ℹ️ MA $serverVersion should have /sendspin proxy');
_logger.log('ℹ️ Check that your reverse proxy forwards WebSocket connections');
_logger.log(' Ensure Upgrade and Connection headers are passed through');
}
}
/// 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) {
_logger.log('🔊 Sendspin: Set volume to $volumeLevel');
_localPlayerVolume = volumeLevel;
FlutterVolumeController.setVolume(volumeLevel / 100.0);
_sendspinService?.reportState(volume: volumeLevel);
notifyListeners();
}
/// Called when the user changes the local player volume from within Ensemble.
/// Updates [_localPlayerVolume] so the periodic state report reflects the real
/// volume, and applies the same gain to the PCM stream.
void updateLocalPlayerVolume(int volumeLevel) {
_localPlayerVolume = volumeLevel.clamp(0, 100);
_sendspinService?.reportState(volume: _localPlayerVolume);
notifyListeners();
}
/// 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 {
final aaDisc = audioHandler.aaDisconnectedAt;
if (aaDisc != null && DateTime.now().difference(aaDisc).inSeconds < 2) {
_logger.log('🎵 Sendspin: Ignoring stream/start (AA disconnected ${DateTime.now().difference(aaDisc).inMilliseconds}ms ago)');
return;
}
_logger.log('🎵 Sendspin: Stream starting');
// Ensure PCM player is initialized and ready
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.
// Use updateLocalModeNotification (NOT setRemotePlaybackState) to avoid
// conflicting mediaItem.add() calls — _updatePlayerState() also calls
// updateLocalModeNotification with the real track data.
_cancelIdleServiceTimer();
if (_currentTrack != null) {
final track = _currentTrack!;
final trackArtworkUrl = _api?.getImageUrl(track, size: 512);
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '${displayArtist} • ${_selectedPlayer?.name ?? ''}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(trackArtworkUrl),
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: true,
duration: track.duration,
position: _positionTracker.currentPosition,
);
} else {
// No track data yet — use stream metadata as fallback
final mediaItem = audio_service.MediaItem(
id: 'sendspin_pcm_stream',
title: title ?? 'Playing via Sendspin',
artist: artist ?? 'Music Assistant',
album: album,
artUri: _contentArtUri(artworkUrl),
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: true,
duration: mediaItem.duration,
position: _positionTracker.currentPosition,
);
}
// Start notification position timer for Sendspin PCM
_manageNotificationPositionTimer();
_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 {
// Use position tracker (server-synced) instead of PCM elapsed time which resets to 0 each stream
final lastPosition = _positionTracker.currentPosition;
_logger.log('🎵 Sendspin: Stream ended at position ${lastPosition.inSeconds}s');
// Stop notification position timer
_notificationPositionTimer?.cancel();
// Pause PCM playback (preserves position) instead of stop (resets position)
await _pcmAudioPlayer?.pause();
// Update foreground service to show paused state with last position.
// Use updateLocalModeNotification (NOT setRemotePlaybackState) to avoid
// conflicting mediaItem.add() calls with _updatePlayerState().
_cancelIdleServiceTimer();
if (_currentTrack != null) {
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: 512);
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '${displayArtist} • ${_selectedPlayer?.name ?? ''}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(artworkUrl),
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: false,
duration: track.duration,
position: lastPosition,
);
} else {
final metadata = _currentNotificationMetadata;
final mediaItem = audio_service.MediaItem(
id: 'sendspin_pcm_stream',
title: metadata?.title ?? 'Music Assistant',
artist: metadata?.artist ?? 'Paused',
album: metadata?.album,
artUri: _contentArtUri(metadata?.artworkUrl),
duration: metadata?.duration,
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: false,
duration: mediaItem.duration,
position: lastPosition,
);
}
}
void _startReportingLocalPlayerState() {
_localPlayerStateReportTimer?.cancel();
_localPlayerStateReportTimer = Timer.periodic(Timings.localPlayerReportInterval, (_) async {
try {
await _reportLocalPlayerState();
} catch (e) {
_logger.log('Error reporting local player state (will retry): $e');
}
});
}
Future<void> _reportLocalPlayerState() async {
if (_api == null) return;
// Don't try to report state if not authenticated - avoids spamming errors
if (_api!.currentConnectionState != MAConnectionState.authenticated) return;
final playerId = await SettingsService.getBuiltinPlayerId();
if (playerId == null) return;
final isPlaying = _localPlayer.isPlaying;
final position = _localPlayer.position.inSeconds;
// Use tracked MA volume instead of just_audio player volume
// just_audio volume is for local playback, MA volume is for server sync
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,
playing: isPlaying,
paused: isPaused,
position: position,
volume: volume,
muted: _localPlayerVolume == 0,
);
}
Future<void> _handleLocalPlayerEvent(Map<String, dynamic> event) async {
_logger.log('📥 Local player event received: ${event['type'] ?? event['command']}');
try {
final eventPlayerId = event['player_id'] as String?;
final myPlayerId = await SettingsService.getBuiltinPlayerId();
if (eventPlayerId != null && myPlayerId != null && eventPlayerId != myPlayerId) {
_logger.log('🚫 Ignoring event for different player: $eventPlayerId (my player: $myPlayerId)');
return;
}
final command = (event['type'] as String?) ?? (event['command'] as String?);
switch (command) {
case 'play_media':
final urlPath = event['media_url'] as String? ?? event['url'] as String?;
_logger.log('🎵 play_media: urlPath=$urlPath, _serverUrl=$_serverUrl');
if (urlPath != null && _serverUrl != null) {
String fullUrl;
if (urlPath.startsWith('http')) {
fullUrl = urlPath;
} else {
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 = urlPath.startsWith('/') ? urlPath : '/$urlPath';
fullUrl = '$baseUrl$path';
}
TrackMetadata metadata;
if (_pendingTrackMetadata != null) {
metadata = _pendingTrackMetadata!;
_logger.log('🎵 Using metadata from player_updated: ${metadata.title} by ${metadata.artist}');
} else {
final trackName = event['track_name'] as String? ?? event['name'] as String? ?? 'Unknown Track';
final artistName = event['artist_name'] as String? ?? event['artist'] as String? ?? 'Unknown Artist';
final albumName = event['album_name'] as String? ?? event['album'] as String?;
var artworkUrl = event['image_url'] as String? ?? event['artwork_url'] as String?;
final durationSecs = event['duration'] as int?;
if (artworkUrl != null && artworkUrl.startsWith('http://')) {
artworkUrl = artworkUrl.replaceFirst('http://', 'https://');
}
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);
}
break;
case 'stop':
await _localPlayer.stop();
break;
case 'pause':
await _localPlayer.pause();
break;
case 'play':
await _localPlayer.play();
break;
case 'seek':
final position = event['position'] as int?;
if (position != null) {
await _localPlayer.seek(Duration(seconds: position));
}
break;
case 'volume_set':
case 'set_volume':
final volume = event['volume_level'] as int? ?? event['volume'] as int?;
if (volume != null) {
_localPlayerVolume = volume;
FlutterVolumeController.setVolume(volume / 100.0);
notifyListeners();
}
break;
case 'power_on':
case 'power_off':
case 'power':
_logger.log('🔋 POWER COMMAND RECEIVED: $command');
bool? newPowerState;
if (command == 'power_on') {
newPowerState = true;
} else if (command == 'power_off') {
newPowerState = false;
} else {
newPowerState = event['powered'] as bool?;
}
if (newPowerState != null) {
_isLocalPlayerPowered = newPowerState;
_logger.log('🔋 Local player power set to: $_isLocalPlayerPowered');
if (!_isLocalPlayerPowered) {
_logger.log('🔋 Stopping playback because powered off');
await _localPlayer.stop();
}
}
break;
}
await _reportLocalPlayerState();
} catch (e) {
_logger.log('Error handling local player event: $e');
}
}
/// 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');
}
}
/// Handle media_item_added event - refresh library when items are added
/// This handles the case where addToLibrary API call times out but the item was actually added
void _handleMediaItemAddedEvent(Map<String, dynamic> event) {
try {
final mediaType = event['media_type'] as String?;
_cacheService.invalidateSearchCache();
_scheduleLibraryRefresh(mediaType ?? 'artist');
audioHandler.invalidateAutoChildren(const ['cat|favorites', 'cat|home', 'cat|fav_tracks', 'cat|fav_albums', 'cat|fav_artists']);
} catch (e) {
_logger.log('Error handling media item added event: $e');
}
}
/// Handle media_item_deleted event - refresh library when items are removed
/// This ensures UI updates even if removeFromLibrary API call times out
void _handleMediaItemDeletedEvent(Map<String, dynamic> event) {
try {
final mediaType = event['media_type'] as String?;
_cacheService.invalidateSearchCache();
_scheduleLibraryRefresh(mediaType ?? 'artist');
audioHandler.invalidateAutoChildren(const ['cat|favorites', 'cat|home', 'cat|fav_tracks', 'cat|fav_albums', 'cat|fav_artists']);
} catch (e) {
_logger.log('Error handling media item deleted event: $e');
}
}
Future<void> _handlePlayerUpdatedEvent(Map<String, dynamic> event) async {
try {
final playerId = event['player_id'] as String?;
if (playerId == null) return;
// Check if this event is for the selected player - also check translated Cast<->Sendspin IDs
// Events may come with Cast ID but selected player uses Sendspin ID (or vice versa)
if (_selectedPlayer != null) {
final selectedId = _selectedPlayer!.playerId;
final isMatch = playerId == selectedId ||
_castToSendspinIdMap[playerId] == selectedId ||
_castToSendspinIdMap[selectedId] == playerId;
if (isMatch) {
_updatePlayerState();
}
}
// Update member volume in _availablePlayers for accurate group volume
// computation. When MA changes a group member's volume (after setVolume
// on the group player), it fires player_updated for each member.
// Without this, _availablePlayers member volumes stay stale until the
// next _updatePlayerState poll, which could cause the group average
// to be wrong during the convergence check.
final rawVolume = event['volume_level'];
final eventVolume = rawVolume is int
? rawVolume
: (rawVolume is double ? rawVolume.round() : null);
if (eventVolume != null) {
final idx = _availablePlayers.indexWhere((p) => p.playerId == playerId);
if (idx != -1 && _availablePlayers[idx].volumeLevel != eventVolume) {
_availablePlayers[idx] =
_availablePlayers[idx].copyWith(volumeLevel: eventVolume);
}
// If this is the local Sendspin player, sync the tracked volume and slider.
// Do NOT call FlutterVolumeController here — _handleSendspinVolume already
// sets system volume from the server/command message; calling it again from
// player_updated would create a feedback loop via the volume listener.
if (playerId == _sendspinService?.playerId && eventVolume != _localPlayerVolume) {
_localPlayerVolume = eventVolume;
notifyListeners();
}
}
final currentMedia = event['current_media'] as Map<String, dynamic>?;
final playerName = event['name'] as String? ?? playerId;
if (currentMedia != null) {
final mediaType = currentMedia['media_type'] as String?;
final uri = currentMedia['uri'] as String?;
// Debug: Log all currentMedia fields for podcast episodes
if (uri != null && (uri.contains('podcast_episode') || uri.contains('podcast/'))) {
_logger.log('🎙️ Podcast currentMedia keys: ${currentMedia.keys.toList()}');
_logger.log('🎙️ Podcast currentMedia: $currentMedia');
}
// Check for external source (optical, Spotify Connect, etc.) - skip caching stale metadata
// Use same logic as Player.fromJson:
// 1. If app_id == 'music_assistant' -> NOT external
// 2. If active_queue has value -> NOT external (MA controls playback)
// 3. If app_id is set and != 'music_assistant' -> external
// 4. Fallback: check for simple external IDs (optical, etc.)
final appId = event['app_id'] as String?;
final activeQueue = event['active_queue'] as String?;
bool isExternalSource = false;
if (appId == 'music_assistant') {
isExternalSource = false;
} else if (activeQueue != null && activeQueue.isNotEmpty) {
isExternalSource = false;
} else if (appId != null && appId.isNotEmpty) {
isExternalSource = true;
} else if (uri != null) {
final uriLower = uri.toLowerCase();
// Only check for simple external source identifiers (physical inputs)
// Do NOT flag spotify://, etc. - those need app_id/active_queue context
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
uriLower == 'coaxial' || uriLower == 'toslink');
isExternalSource = isSimpleExternalId;
// Also treat unknown media type with non-MA URIs as external
if (!isExternalSource && mediaType == 'unknown' &&
!uri.startsWith('library://') && !uri.contains('://track/')) {
isExternalSource = true;
}
}
if (isExternalSource) {
_logger.log('📡 External source detected for $playerName (app_id: $appId, uri: $uri) - skipping metadata cache');
// Clear cached track for this player to avoid showing stale data
_cacheService.clearCachedTrackForPlayer(playerId);
return; // Skip further processing for external sources
}
// Clear audiobook context when switching to music (track) playback
if (mediaType == 'track' && _currentAudiobook != null) {
_logger.log('📚 Media type changed to track - clearing audiobook context');
clearCurrentAudiobook();
}
// Clear podcast context when switching to non-podcast media
if (mediaType != 'podcast_episode' && _currentPodcastName != null) {
_logger.log('🎙️ Media type changed to $mediaType - clearing podcast context');
clearCurrentPodcastName();
}
if (mediaType != 'flow_stream') {
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
final albumName = currentMedia['album'] as String?;
final imageUrl = currentMedia['image_url'] as String?;
Map<String, dynamic>? metadata;
if (imageUrl != null) {
var finalImageUrl = imageUrl;
if (_serverUrl != null) {
try {
final imgUri = Uri.parse(imageUrl);
final queryString = imgUri.query;
var baseUrl = _serverUrl!;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
}
finalImageUrl = '$baseUrl/imageproxy?$queryString';
} catch (e) {
// Use original URL
}
}
metadata = {
'images': [
{'path': finalImageUrl, 'provider': 'direct'}
]
};
}
// Extract podcast info if available (for podcast episodes)
final podcastData = currentMedia['podcast'];
if (podcastData != null) {
metadata ??= {};
metadata['podcast'] = podcastData;
_logger.log('🎙️ Found podcast in currentMedia: $podcastData');
}
// Parse artist from title if artist is missing but title contains " - "
var trackTitle = currentMedia['title'] as String? ?? 'Unknown Track';
var artistName = currentMedia['artist'] as String?;
// Check if this is a radio stream
final isRadioStream = uri != null && (uri.contains('library://radio/') || uri.contains('/radio/'));
// For radio streams, log the currentMedia to debug metadata structure
if (isRadioStream) {
_logger.log('📻 Radio currentMedia keys: ${currentMedia.keys.toList()}');
_logger.log('📻 Radio currentMedia: $currentMedia');
}
// For radio streams, try additional artist sources if primary is missing
if (isRadioStream && (artistName == null || artistName.isEmpty)) {
// Check for artists array (like in Track.fromJson)
final artistsData = currentMedia['artists'];
if (artistsData is List && artistsData.isNotEmpty) {
final firstArtist = artistsData.first;
if (firstArtist is Map<String, dynamic>) {
artistName = firstArtist['name'] as String?;
} else if (firstArtist is String) {
artistName = firstArtist;
}
_logger.log('📻 Found artist from artists array: $artistName');
}
// Check for stream_title which contains the actual now-playing metadata
// For radio, stream_title has "Artist - Title" format from the stream's ICY metadata
final streamTitle = currentMedia['stream_title'] as String?;
if (streamTitle != null && streamTitle.isNotEmpty) {
if (streamTitle.contains(' - ')) {
// Parse "Artist - Title" format
final parts = streamTitle.split(' - ');
if (parts.length >= 2) {
artistName = parts[0].trim();
trackTitle = parts.sublist(1).join(' - ').trim();
_logger.log('📻 Parsed from stream_title: artist=$artistName, title=$trackTitle');
}
} else {
// No separator, use stream_title as the title
trackTitle = streamTitle;
_logger.log('📻 Using stream_title as title: $trackTitle');
}
}
}
if ((artistName == null || artistName == 'Unknown Artist') && trackTitle.contains(' - ')) {
final parts = trackTitle.split(' - ');
if (parts.length >= 2) {
artistName = parts[0].trim();
trackTitle = parts.sublist(1).join(' - ').trim();
}
}
artistName ??= 'Unknown Artist';
final trackFromEvent = Track(
itemId: currentMedia['queue_item_id'] as String? ?? '',
provider: 'library',
name: trackTitle,
uri: currentMedia['uri'] as String?,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
artists: [Artist(itemId: '', provider: 'library', name: artistName)],
album: albumName != null ? Album(itemId: '', provider: 'library', name: albumName) : null,
metadata: metadata,
);
// Only cache if we don't already have better data from queue
final existingTrack = _cacheService.getCachedTrackForPlayer(playerId);
// Check if existing track has proper artist (not Unknown Artist and not empty)
final existingArtist = existingTrack?.artistsString;
final existingHasProperArtist = existingTrack != null &&
existingArtist != null &&
existingArtist != 'Unknown Artist' &&
existingArtist.trim().isNotEmpty;
final existingHasImage = existingTrack?.metadata?['images'] != null;
final newHasImage = metadata != null;
final newHasAlbum = albumName != null;
final existingHasAlbum = existingTrack?.album != null;
// Check if new data has proper artist AND title (not malformed like "- Something")
final newTitleIsMalformed = trackTitle.startsWith('- ') || trackTitle.trim().isEmpty;
final newHasProperArtist = artistName != 'Unknown Artist' && !newTitleIsMalformed;
// For podcasts, album info is crucial (it's the podcast name)
// If new track has album but existing doesn't, prefer new or merge
final isPodcastUri = uri != null && (uri.contains('podcast_episode') || uri.contains('podcast/'));
// For radio streams, always update when we have new metadata (song changes frequently)
// This ensures radio artist/title changes are reflected immediately
final isRadioUri = uri != null && (uri.contains('library://radio/') || uri.contains('/radio/'));
// Keep existing if it has proper artist OR has image that new one lacks
// BUT for podcasts, if new has album and existing doesn't, we need that album data
// AND for radio, always update when new track has proper artist (song changed)
final keepExisting = (existingHasProperArtist || (existingHasImage && !newHasImage))
&& !(isPodcastUri && newHasAlbum && !existingHasAlbum)
&& !(isRadioUri && newHasProperArtist);
if (!keepExisting) {
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
_logger.log('📋 Cached track for $playerName from player_updated: ${trackFromEvent.name}');
// Dual-cache for Cast<->Sendspin players so track is findable by either ID
final sendspinId = _castToSendspinIdMap[playerId];
if (sendspinId != null) {
_cacheService.setCachedTrackForPlayer(sendspinId, trackFromEvent);
_logger.log('📋 Also cached under Sendspin ID: $sendspinId');
} else if (playerId.length >= 8 && playerId.contains('-')) {
// Compute Sendspin ID for chromecast players not yet in map
final computedSendspinId = 'cast-${playerId.substring(0, 8)}';
_cacheService.setCachedTrackForPlayer(computedSendspinId, trackFromEvent);
_logger.log('📋 Also cached under computed Sendspin ID: $computedSendspinId');
}
} else if (isPodcastUri && newHasAlbum && existingTrack != null && !existingHasAlbum) {
// Merge: keep existing but add album from new track
final mergedTrack = Track(
itemId: existingTrack.itemId,
provider: existingTrack.provider,
name: existingTrack.name,
uri: existingTrack.uri,
duration: existingTrack.duration,
artists: existingTrack.artists,
album: trackFromEvent.album, // Take album from new track
metadata: existingTrack.metadata,
);
_cacheService.setCachedTrackForPlayer(playerId, mergedTrack);
_logger.log('📋 Merged album info into existing track for $playerName: ${albumName}');
} else {
_logger.debug('📋 Skipped caching for $playerName - already have better data (artist: $existingHasProperArtist, image: $existingHasImage)');
}
// For selected player, _updatePlayerState() is already called above which fetches queue data
// Update _currentTrack if:
// - We don't have it yet
// - Podcast with new album data
// - Radio with new stream metadata (artist changed from Unknown)
final currentHasUnknownArtist = _currentTrack?.artistsString == 'Unknown Artist' ||
_currentTrack?.artistsString == null;
final shouldUpdateCurrentTrack = _selectedPlayer != null &&
playerId == _selectedPlayer!.playerId &&
(_currentTrack == null ||
(isPodcastUri && newHasAlbum && _currentTrack?.album == null) ||
(isRadioUri && newHasProperArtist && currentHasUnknownArtist));
if (shouldUpdateCurrentTrack) {
_currentTrack = _cacheService.getCachedTrackForPlayer(playerId) ?? trackFromEvent;
_logger.log('📋 Updated _currentTrack: ${_currentTrack?.name} by ${_currentTrack?.artistsString}');
}
notifyListeners();
}
}
// Handle notification metadata for local player
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId == null) return;
if (playerId != builtinPlayerId) return;
if (currentMedia == null) return;
var title = currentMedia['title'] as String? ?? 'Unknown Track';
var artist = currentMedia['artist'] as String?;
final album = currentMedia['album'] as String?;
var imageUrl = currentMedia['image_url'] as String?;
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
final notificationUri = currentMedia['uri'] as String?;
final isRadioNotification = notificationUri != null &&
(notificationUri.contains('library://radio/') || notificationUri.contains('/radio/'));
// For radio streams, try to extract artist from various sources
if (isRadioNotification && (artist == null || artist.isEmpty)) {
// Check for artists array
final artistsData = currentMedia['artists'];
if (artistsData is List && artistsData.isNotEmpty) {
final firstArtist = artistsData.first;
if (firstArtist is Map<String, dynamic>) {
artist = firstArtist['name'] as String?;
} else if (firstArtist is String) {
artist = firstArtist;
}
}
// Check for stream_title which contains the actual now-playing metadata
final streamTitle = currentMedia['stream_title'] as String?;
if (streamTitle != null && streamTitle.isNotEmpty) {
if (streamTitle.contains(' - ')) {
// Parse "Artist - Title" format
final parts = streamTitle.split(' - ');
if (parts.length >= 2) {
artist = parts[0].trim();
title = parts.sublist(1).join(' - ').trim();
}
} else {
// No separator, use stream_title as the title
title = streamTitle;
}
}
}
// Parse artist from title if still missing
if ((artist == null || artist == 'Unknown Artist') && title.contains(' - ')) {
final parts = title.split(' - ');
if (parts.length >= 2) {
artist = parts[0].trim();
title = parts.sublist(1).join(' - ').trim();
}
}
artist ??= 'Unknown Artist';
if (imageUrl != null && _serverUrl != null) {
try {
final imgUri = Uri.parse(imageUrl);
final queryString = imgUri.query;
var baseUrl = _serverUrl!;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
}
imageUrl = '$baseUrl/imageproxy?$queryString';
} catch (e) {
if (imageUrl != null && imageUrl.startsWith('http://')) {
imageUrl = imageUrl.replaceFirst('http://', 'https://');
}
}
}
final newMetadata = TrackMetadata(
title: title,
artist: artist,
album: album,
artworkUrl: imageUrl,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
);
final mediaType = currentMedia['media_type'] as String?;
if (mediaType == 'flow_stream') return;
_pendingTrackMetadata = newMetadata;
_logger.log('📋 Captured track metadata from player_updated: $title by $artist');
final notificationNeedsUpdate = _currentNotificationMetadata != null &&
(_currentNotificationMetadata!.title != title ||
_currentNotificationMetadata!.artist != artist);
if (_localPlayer.isPlaying && notificationNeedsUpdate) {
_logger.log('📋 Notification has stale metadata - updating to: $title by $artist');
await _localPlayer.updateNotificationWhilePlaying(newMetadata);
_currentNotificationMetadata = newMetadata;
}
} catch (e) {
_logger.log('Error handling player_updated event: $e');
}
}
// ============================================================================
// SEARCH STATE
// ============================================================================
void saveSearchState(String query, Map<String, List<MediaItem>> results) {
_lastSearchQuery = query;
_lastSearchResults = results;
}
void clearSearchState() {
_lastSearchQuery = '';
_lastSearchResults = {'artists': [], 'albums': [], 'tracks': []};
}
// ============================================================================
// HOME SCREEN ROW CACHING
// ============================================================================
Future<List<Album>> getRecentAlbumsWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
// Always fetch from API to ensure we have full album data with images
// Local/cached data is used by getCachedRecentAlbums() for instant display
if (_api == null) {
// Fallback when offline: try memory cache, then local database
final cached = _cacheService.getCachedRecentAlbums();
if (cached != null && cached.isNotEmpty) return cached;
return RecentlyPlayedService.instance.getRecentAlbums(
limit: LibraryConstants.defaultRecentLimit,
);
}
try {
_logger.log('🔄 Fetching fresh recent albums from MA...');
final albums = await _api!.getRecentAlbums(limit: LibraryConstants.defaultRecentLimit);
// Apply provider filtering
final filtered = filterByProvider(albums);
_cacheService.setCachedRecentAlbums(filtered);
return filtered;
} catch (e) {
_logger.log('❌ Failed to fetch recent albums: $e');
// Fallback on error: try memory cache, then local database
final cached = _cacheService.getCachedRecentAlbums();
if (cached != null && cached.isNotEmpty) return filterByProvider(cached);
final local = await RecentlyPlayedService.instance.getRecentAlbums(
limit: LibraryConstants.defaultRecentLimit,
);
return filterByProvider(local);
}
}
Future<List<Artist>> getDiscoverArtistsWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
if (_cacheService.isDiscoverArtistsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discover artists');
return _cacheService.getCachedDiscoverArtists()!;
}
if (_api == null) return _cacheService.getCachedDiscoverArtists() ?? [];
try {
_logger.log('🔄 Fetching fresh discover artists...');
final artists = await _api!.getRandomArtists(
limit: LibraryConstants.defaultRecentLimit,
providerInstanceIds: providerIdsForApiCalls,
);
_cacheService.setCachedDiscoverArtists(artists);
return artists;
} catch (e) {
_logger.log('❌ Failed to fetch discover artists: $e');
return _cacheService.getCachedDiscoverArtists() ?? [];
}
}
Future<List<Album>> getDiscoverAlbumsWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
if (_cacheService.isDiscoverAlbumsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discover albums');
return _cacheService.getCachedDiscoverAlbums()!;
}
if (_api == null) return _cacheService.getCachedDiscoverAlbums() ?? [];
try {
_logger.log('🔄 Fetching fresh discover albums...');
final albums = await _api!.getRandomAlbums(
limit: LibraryConstants.defaultRecentLimit,
providerInstanceIds: providerIdsForApiCalls,
);
_cacheService.setCachedDiscoverAlbums(albums);
return albums;
} catch (e) {
_logger.log('❌ Failed to fetch discover albums: $e');
return _cacheService.getCachedDiscoverAlbums() ?? [];
}
}
Future<List<RecommendationFolder>> getDiscoveryFoldersWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
if (_cacheService.isDiscoveryFoldersCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discovery folders');
return _cacheService.getCachedDiscoveryFolders()!;
}
if (_api == null) return _cacheService.getCachedDiscoveryFolders() ?? [];
try {
_logger.log('🔄 Fetching discovery folders...');
final recommendations = await _api!.getRecommendations();
final filteredFolders = _extractDiscoveryFolders(recommendations);
_cacheService.setCachedDiscoveryFolders(filteredFolders);
return filteredFolders;
} catch (e) {
_logger.log('❌ Failed to fetch discovery folders: $e');
return _cacheService.getCachedDiscoveryFolders() ?? [];
}
}
List<RecommendationFolder>? getCachedDiscoveryFolders() => _cacheService.getCachedDiscoveryFolders();
/// Get items for a specific discovery folder (for individual row rendering)
Future<List<MediaItem>> getDiscoveryFolderItems(String folderId, {bool forceRefresh = false}) async {
final folders = await getDiscoveryFoldersWithCache(forceRefresh: forceRefresh);
final folder = folders.firstWhere(
(f) => f.itemId == folderId,
orElse: () => RecommendationFolder(
itemId: folderId,
provider: 'unknown',
name: '',
items: [],
),
);
return folder.items;
}
/// Get cached items for a specific discovery folder (for instant display)
List<MediaItem>? getCachedDiscoveryFolderItems(String folderId) {
final folders = getCachedDiscoveryFolders();
if (folders == null) return null;
final folder = folders.firstWhere(
(f) => f.itemId == folderId,
orElse: () => RecommendationFolder(
itemId: folderId,
provider: 'unknown',
name: '',
items: [],
),
);
return folder.items.isEmpty ? null : folder.items;
}
/// Refresh discovery folders by clearing cache and refetching from API
Future<void> refreshDiscoveryFolders() async {
_cacheService.invalidateHomeCache();
await getDiscoveryFoldersWithCache(forceRefresh: true);
}
List<RecommendationFolder> _extractDiscoveryFolders(List<RecommendationFolder> folders) {
final result = <RecommendationFolder>[];
for (final folder in folders) {
// Filter out "recently played" (duplicate of MA's recently played)
final name = folder.name.trim().toLowerCase();
final id = folder.itemId.trim().toLowerCase();
final isRecentlyPlayed = name == 'recently played' || id.contains('recently_played');
if (isRecentlyPlayed) continue;
// Filter items by provider and type
final providerFiltered = filterByProvider(folder.items);
final filteredItems = providerFiltered
.where((item) => item.mediaType == MediaType.playlist || item.mediaType == MediaType.radio)
.where(_isExternalMixItem)
.toList();
if (filteredItems.isEmpty) continue;
result.add(RecommendationFolder(
itemId: folder.itemId,
provider: folder.provider,
name: folder.name,
uri: folder.uri,
items: _deduplicateItems(filteredItems),
));
}
return result;
}
bool _isExternalMixItem(MediaItem item) {
final provider = item.provider.toLowerCase();
if (provider != 'library' && provider != 'filesystem') {
return true;
}
final mappings = item.providerMappings;
if (mappings != null) {
return mappings.any((m) {
final domain = m.providerDomain.toLowerCase();
return domain.isNotEmpty && domain != 'library' && domain != 'filesystem';
});
}
return false;
}
List<MediaItem> _deduplicateItems(List<MediaItem> items) {
final Map<String, MediaItem> deduped = {};
for (final item in items) {
final key = '${item.provider}:${item.itemId}';
deduped.putIfAbsent(key, () => item);
}
return deduped.values.toList();
}
void invalidateHomeCache() {
_cacheService.invalidateHomeCache();
}
/// Get cached recent albums synchronously (for instant display)
List<Album>? getCachedRecentAlbums() => _cacheService.getCachedRecentAlbums();
/// Get cached discover artists synchronously (for instant display)
List<Artist>? getCachedDiscoverArtists() => _cacheService.getCachedDiscoverArtists();
/// Get cached discover albums synchronously (for instant display)
List<Album>? getCachedDiscoverAlbums() => _cacheService.getCachedDiscoverAlbums();
/// Force a full library sync (for pull-to-refresh)
Future<void> forceLibrarySync() async {
if (_api == null) {
_logger.log('⚠️ forceLibrarySync: API is null, skipping');
return;
}
final providerIds = providerIdsForApiCalls;
_logger.log('🔄 Forcing full library sync with providers: ${providerIds ?? "ALL"}');
await SyncService.instance.forceSync(_api!, providerInstanceIds: providerIds);
// Update local lists from sync result
_albums = SyncService.instance.cachedAlbums;
_artists = SyncService.instance.cachedArtists;
// Also refresh tracks from API
try {
_tracks = await _api!.getTracks(
limit: LibraryConstants.maxLibraryItems,
providerInstanceIds: providerIdsForApiCalls,
);
} catch (e) {
_logger.log('⚠️ Failed to refresh tracks: $e');
}
_syncLibraryStatusToService();
notifyListeners();
_logger.log('✅ Force sync complete');
}
// ============================================================================
// FAVORITES FOR HOME SCREEN
// ============================================================================
/// Get favorite albums from the library
Future<List<Album>> getFavoriteAlbums() async {
// Filter from loaded library - favorites are already loaded
return _albums.where((a) => a.favorite == true).toList();
}
/// Get favorite artists from the library
Future<List<Artist>> getFavoriteArtists() async {
// Filter from loaded library - favorites are already loaded
return _artists.where((a) => a.favorite == true).toList();
}
/// Get favorite tracks from the library
Future<List<Track>> getFavoriteTracks() async {
// Tier 1: filter from in-memory tracks list
if (_tracks.isNotEmpty) {
final favTracks = _tracks.where((t) => t.favorite == true).toList();
// Update cache if we have new data
if (favTracks.isNotEmpty && favTracks.length != _cachedFavoriteTracks.length) {
_cachedFavoriteTracks = favTracks;
_persistFavoriteTracks(favTracks);
}
return favTracks;
}
// Tier 2: return persisted cache
if (_cachedFavoriteTracks.isNotEmpty) {
return _cachedFavoriteTracks;
}
// Tier 3: fetch from API directly (e.g. Android Auto before library loads)
if (_api != null) {
try {
final apiFavs = await _api!.getTracks(favoriteOnly: true);
if (apiFavs.isNotEmpty) {
_cachedFavoriteTracks = apiFavs;
_persistFavoriteTracks(apiFavs);
}
return apiFavs;
} catch (e) {
_logger.log('❌ Failed to fetch favorite tracks from API: $e');
}
}
return [];
}
/// Get favorite playlists from the library
Future<List<Playlist>> getFavoritePlaylists() async {
if (_api == null) return [];
try {
return await getPlaylists(favoriteOnly: true);
} catch (e) {
_logger.log('❌ Failed to fetch favorite playlists: $e');
return [];
}
}
/// Get favorite radio stations from the library
Future<List<MediaItem>> getFavoriteRadioStations() async {
if (_api == null) return [];
try {
final stations = await _api!.getRadioStations(favoriteOnly: true);
return filterByProvider(stations);
} catch (e) {
_logger.log('❌ Failed to fetch favorite radio stations: $e');
return [];
}
}
/// Get favorite podcasts from the library
Future<List<MediaItem>> getFavoritePodcasts() async {
if (_api == null) return [];
try {
final podcasts = await _api!.getPodcasts(favoriteOnly: true);
return filterByProvider(podcasts);
} catch (e) {
_logger.log('❌ Failed to fetch favorite podcasts: $e');
return [];
}
}
// ============================================================================
// AUDIOBOOK HOME SCREEN ROWS
// ============================================================================
/// Get audiobooks that have progress (continue listening) with caching
Future<List<Audiobook>> getInProgressAudiobooksWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
// Check cache first
if (!forceRefresh && _cacheService.isInProgressAudiobooksCacheValid()) {
_logger.log('📦 Using cached in-progress audiobooks');
return _cacheService.getCachedInProgressAudiobooks()!;
}
if (_api == null) {
// Fallback to cache when offline
final cached = _cacheService.getCachedInProgressAudiobooks();
if (cached != null && cached.isNotEmpty) return cached;
return [];
}
try {
_logger.log('📚 Fetching in-progress audiobooks...');
final allAudiobooks = filterByProvider(await _api!.getAudiobooks());
// Filter to only those with progress, sorted by most recent/highest progress
final inProgress = allAudiobooks
.where((a) => a.progress > 0 && a.progress < 1.0) // Has progress but not complete
.toList()
..sort((a, b) => b.progress.compareTo(a.progress)); // Sort by progress descending
final result = inProgress.take(20).toList(); // Limit to 20 for home row
_logger.log('📚 Found ${result.length} in-progress audiobooks');
_cacheService.setCachedInProgressAudiobooks(result);
return result;
} catch (e) {
_logger.log('❌ Failed to fetch in-progress audiobooks: $e');
// Fallback to cache on error
final cached = _cacheService.getCachedInProgressAudiobooks();
if (cached != null && cached.isNotEmpty) return cached;
return [];
}
}
/// Get cached in-progress audiobooks synchronously (for instant display)
List<Audiobook>? getCachedInProgressAudiobooks() => _cacheService.getCachedInProgressAudiobooks();
/// Get random audiobooks for discovery with caching
Future<List<Audiobook>> getDiscoverAudiobooksWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
// Check cache first
if (!forceRefresh && _cacheService.isDiscoverAudiobooksCacheValid()) {
_logger.log('📦 Using cached discover audiobooks');
return _cacheService.getCachedDiscoverAudiobooks()!;
}
if (_api == null) {
// Fallback to cache when offline
final cached = _cacheService.getCachedDiscoverAudiobooks();
if (cached != null && cached.isNotEmpty) return cached;
return [];
}
try {
_logger.log('📚 Fetching discover audiobooks...');
final allAudiobooks = filterByProvider(await _api!.getAudiobooks());
// Shuffle and take a subset
final shuffled = List<Audiobook>.from(allAudiobooks)..shuffle();
final result = shuffled.take(20).toList();
_logger.log('📚 Found ${allAudiobooks.length} total audiobooks, returning random selection');
_cacheService.setCachedDiscoverAudiobooks(result);
return result;
} catch (e) {
_logger.log('❌ Failed to fetch discover audiobooks: $e');
// Fallback to cache on error
final cached = _cacheService.getCachedDiscoverAudiobooks();
if (cached != null && cached.isNotEmpty) return cached;
return [];
}
}
/// Get cached discover audiobooks synchronously (for instant display)
List<Audiobook>? getCachedDiscoverAudiobooks() => _cacheService.getCachedDiscoverAudiobooks();
/// Get random series for discovery with caching
Future<List<AudiobookSeries>> getDiscoverSeriesWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
// Check cache first
if (!forceRefresh && _cacheService.isDiscoverSeriesCacheValid()) {
_logger.log('📦 Using cached discover series');
return _cacheService.getCachedDiscoverSeries()!;
}
if (_api == null) {
// Fallback to cache when offline
final cached = _cacheService.getCachedDiscoverSeries();
if (cached != null && cached.isNotEmpty) return cached;
return [];
}
try {
_logger.log('📚 Fetching discover series...');
final allSeries = await _api!.getAudiobookSeries();
// Shuffle and take a subset
final shuffled = List<AudiobookSeries>.from(allSeries)..shuffle();
final result = shuffled.take(20).toList();
_logger.log('📚 Found ${allSeries.length} total series, returning random selection');
_cacheService.setCachedDiscoverSeries(result);
return result;
} catch (e) {
_logger.log('❌ Failed to fetch discover series: $e');
// Fallback to cache on error
final cached = _cacheService.getCachedDiscoverSeries();
if (cached != null && cached.isNotEmpty) return cached;
return [];
}
}
/// Get cached discover series synchronously (for instant display)
List<AudiobookSeries>? getCachedDiscoverSeries() => _cacheService.getCachedDiscoverSeries();
// ============================================================================
// DETAIL SCREEN CACHING
// ============================================================================
Future<List<Track>> getAlbumTracksWithCache(String provider, String itemId, {bool forceRefresh = false, Album? album}) async {
final cacheKey = '${provider}_$itemId';
if (_cacheService.isAlbumTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached album tracks for $cacheKey');
return _cacheService.getCachedAlbumTracks(cacheKey)!;
}
// Try database fallback before hitting API
if (!forceRefresh) {
final dbTracks = await _cacheService.getAlbumTracksWithDbFallback(cacheKey);
if (dbTracks != null) return dbTracks;
}
if (_api == null) return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
// Use actual provider mapping instead of "library" provider when available.
// The MA server caches tracks per provider, so using the actual Spotify/etc
// provider ID hits the server-side cache instead of re-resolving every time.
var fetchProvider = provider;
var fetchItemId = itemId;
if (album?.providerMappings != null) {
final nonLibrary = album!.providerMappings!
.where((m) => m.providerInstance != 'library' && m.available)
.toList();
if (nonLibrary.isNotEmpty) {
fetchProvider = nonLibrary.first.providerInstance;
fetchItemId = nonLibrary.first.itemId;
_logger.log('🔀 Using provider mapping: $fetchProvider/$fetchItemId instead of $provider/$itemId');
}
}
try {
_logger.log('🔄 Fetching fresh album tracks for $fetchProvider/$fetchItemId...');
final tracks = await _api!.getAlbumTracks(fetchProvider, fetchItemId);
if (tracks.isNotEmpty) {
_cacheService.setCachedAlbumTracks(cacheKey, tracks);
}
return tracks;
} catch (e) {
_logger.log('❌ Failed to fetch album tracks: $e');
return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
}
}
Future<List<Track>> getPlaylistTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
final cacheKey = '${provider}_$itemId';
if (_cacheService.isPlaylistTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached playlist tracks for $cacheKey');
return _cacheService.getCachedPlaylistTracks(cacheKey)!;
}
// Try database fallback before hitting API
if (!forceRefresh) {
final dbTracks = await _cacheService.getPlaylistTracksWithDbFallback(cacheKey);
if (dbTracks != null) return dbTracks;
}
if (_api == null) return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
try {
_logger.log('🔄 Fetching fresh playlist tracks for $cacheKey...');
final tracks = await _api!.getPlaylistTracks(provider, itemId);
if (tracks.isNotEmpty) {
_cacheService.setCachedPlaylistTracks(cacheKey, tracks);
}
return tracks;
} catch (e) {
_logger.log('❌ Failed to fetch playlist tracks: $e');
return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
}
}
Future<Audiobook?> getAudiobookDetailsWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
final cacheKey = '${provider}_$itemId';
if (_cacheService.isAudiobookDetailCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached audiobook detail for $cacheKey');
final json = _cacheService.getCachedAudiobookDetail(cacheKey)!;
try {
return Audiobook.fromJson(jsonDecode(json) as Map<String, dynamic>);
} catch (_) {}
}
// Try database fallback before hitting API
if (!forceRefresh) {
final dbJson = await _cacheService.getAudiobookDetailWithDbFallback(cacheKey);
if (dbJson != null) {
try {
return Audiobook.fromJson(jsonDecode(dbJson) as Map<String, dynamic>);
} catch (_) {}
}
}
if (_api == null) return null;
try {
_logger.log('🔄 Fetching audiobook details for $cacheKey...');
final audiobook = await _api!.getAudiobookDetails(provider, itemId);
if (audiobook != null) {
_cacheService.setCachedAudiobookDetail(cacheKey, jsonEncode(audiobook.toJson()));
}
return audiobook;
} catch (e) {
_logger.log('❌ Failed to fetch audiobook details: $e');
// Try cached version on error
final cached = _cacheService.getCachedAudiobookDetail(cacheKey);
if (cached != null) {
try {
return Audiobook.fromJson(jsonDecode(cached) as Map<String, dynamic>);
} catch (_) {}
}
return null;
}
}
Future<List<MediaItem>> getPodcastEpisodesWithCache(String podcastId, {String? provider, bool forceRefresh = false}) async {
final cacheKey = '${provider ?? 'default'}_$podcastId';
if (_cacheService.isPodcastEpisodesCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached podcast episodes for $cacheKey');
return _cacheService.getCachedPodcastEpisodes(cacheKey)!;
}
// Try database fallback before hitting API
if (!forceRefresh) {
final dbEpisodes = await _cacheService.getPodcastEpisodesWithDbFallback(cacheKey);
if (dbEpisodes != null) return dbEpisodes;
}
if (_api == null) return _cacheService.getCachedPodcastEpisodes(cacheKey) ?? [];
try {
_logger.log('🔄 Fetching podcast episodes for $cacheKey...');
final episodes = await _api!.getPodcastEpisodes(podcastId, provider: provider);
_cacheService.setCachedPodcastEpisodes(cacheKey, episodes);
return episodes;
} catch (e) {
_logger.log('❌ Failed to fetch podcast episodes: $e');
return _cacheService.getCachedPodcastEpisodes(cacheKey) ?? [];
}
}
Future<List<Album>> getArtistAlbumsWithCache(String artistName, {bool forceRefresh = false}) async {
final cacheKey = artistName.toLowerCase();
if (_cacheService.isArtistAlbumsCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached artist albums for "$artistName"');
return _cacheService.getCachedArtistAlbums(cacheKey)!;
}
if (_api == null) return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
try {
// Ensure library is loaded for artist matching
if (_albums.isEmpty) {
final syncService = SyncService.instance;
if (!syncService.hasCache) {
await syncService.loadFromCache();
}
if (syncService.cachedAlbums.isNotEmpty) {
_albums = syncService.cachedAlbums;
}
}
_logger.log('🔄 Fetching albums for artist "$artistName"...');
// Use already-loaded library albums instead of re-fetching from API
final artistAlbums = _albums.where((album) {
final albumArtists = album.artists;
if (albumArtists == null || albumArtists.isEmpty) return false;
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
}).toList();
final searchResults = await _api!.search(artistName);
final searchAlbums = searchResults['albums'] as List<MediaItem>? ?? [];
final providerAlbums = searchAlbums.whereType<Album>().where((album) {
final albumArtists = album.artists;
if (albumArtists == null || albumArtists.isEmpty) return false;
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
}).toList();
final allAlbums = <Album>[];
final seenNames = <String>{};
for (final album in [...artistAlbums, ...providerAlbums]) {
final key = album.name.toLowerCase();
if (!seenNames.contains(key)) {
seenNames.add(key);
allAlbums.add(album);
}
}
// Only cache if the library is loaded — empty results during cold start
// are likely due to SyncService not having finished yet
if (allAlbums.isNotEmpty) {
_cacheService.setCachedArtistAlbums(cacheKey, allAlbums);
}
_logger.log('✅ Cached ${allAlbums.length} albums for artist "$artistName"');
return allAlbums;
} catch (e) {
_logger.log('❌ Failed to fetch artist albums: $e');
return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
}
}
void invalidateAlbumTracksCache(String albumId) {
_cacheService.invalidateAlbumTracksCache(albumId);
}
void invalidatePlaylistTracksCache(String playlistId) {
_cacheService.invalidatePlaylistTracksCache(playlistId);
}
// ============================================================================
// SEARCH CACHING
// ============================================================================
Future<Map<String, List<MediaItem>>> searchWithCache(String query, {bool forceRefresh = false, bool libraryOnly = false}) async {
final baseKey = query.toLowerCase().trim();
if (baseKey.isEmpty) return {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
// Include libraryOnly in cache key to separate results
final cacheKey = libraryOnly ? '$baseKey:library' : baseKey;
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached search results for "$query" (libraryOnly: $libraryOnly)');
return _cacheService.getCachedSearchResults(cacheKey)!;
}
if (_api == null) {
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
}
try {
_logger.log('🔄 Searching for "$query" (libraryOnly: $libraryOnly)...');
final results = await _api!.search(query, libraryOnly: libraryOnly);
final cachedResults = <String, List<MediaItem>>{
'artists': results['artists'] ?? [],
'albums': results['albums'] ?? [],
'tracks': results['tracks'] ?? [],
'playlists': results['playlists'] ?? [],
'audiobooks': results['audiobooks'] ?? [],
};
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
_logger.log('✅ Cached search results for "$query"');
return cachedResults;
} catch (e) {
_logger.log('❌ Search failed: $e');
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
}
}
void clearAllDetailCaches() {
_cacheService.clearAllDetailCaches();
}
/// Get cached album tracks (for instant display before background refresh)
List<Track>? getCachedAlbumTracks(String cacheKey) {
return _cacheService.getCachedAlbumTracks(cacheKey);
}
/// Get cached playlist tracks (for instant display before background refresh)
List<Track>? getCachedPlaylistTracks(String cacheKey) {
return _cacheService.getCachedPlaylistTracks(cacheKey);
}
/// Get cached artist albums (for instant display before background refresh)
List<Album>? getCachedArtistAlbums(String artistName) {
return _cacheService.getCachedArtistAlbums(artistName.toLowerCase());
}
/// Get artist albums from in-memory library (synchronous, no API call)
List<Album> getArtistAlbumsFromLibrary(String artistName) {
final lowerName = artistName.toLowerCase();
return _albums.where((album) {
final albumArtists = album.artists;
if (albumArtists == null || albumArtists.isEmpty) return false;
return albumArtists.any((a) => a.name.toLowerCase() == lowerName);
}).toList();
}
/// Get cached audiobook detail from memory (synchronous, for instant display)
Audiobook? getCachedAudiobookDetail(String provider, String itemId) {
final cacheKey = '${provider}_$itemId';
final json = _cacheService.getCachedAudiobookDetail(cacheKey);
if (json == null) return null;
try {
return Audiobook.fromJson(jsonDecode(json) as Map<String, dynamic>);
} catch (_) {
return null;
}
}
/// Get cached podcast episodes from memory (synchronous, for instant display)
List<MediaItem>? getCachedPodcastEpisodes(String podcastId, {String? provider}) {
final cacheKey = '${provider ?? 'default'}_$podcastId';
return _cacheService.getCachedPodcastEpisodes(cacheKey);
}
/// Get cached series audiobooks (synchronous, for instant display)
List<Audiobook>? getCachedSeriesAudiobooks(String seriesPath) {
return _seriesAudiobooksCache[seriesPath];
}
/// Get series audiobooks with cache-first pattern
Future<List<Audiobook>> getSeriesAudiobooksWithCache(String seriesPath, {bool forceRefresh = false}) async {
if (!forceRefresh && _seriesAudiobooksCache.containsKey(seriesPath)) {
return _seriesAudiobooksCache[seriesPath]!;
}
if (_api == null) return _seriesAudiobooksCache[seriesPath] ?? [];
final books = await _api!.getSeriesAudiobooks(seriesPath);
_seriesAudiobooksCache[seriesPath] = books;
return books;
}
// ============================================================================
// PLAYER SELECTION
// ============================================================================
Future<List<Player>> getAllPlayersUnfiltered() async {
return await getPlayers();
}
Future<String?> getCurrentPlayerId() async {
return await SettingsService.getBuiltinPlayerId();
}
/// Sort players list based on smart sort setting
/// Can be called synchronously with pre-fetched settings for cached players
void _sortPlayersSync(List<Player> players, bool smartSort, String? builtinPlayerId) {
if (smartSort) {
// Smart sort: local player first, then playing, then on, then off
players.sort((a, b) {
// Local player always first
final aIsLocal = builtinPlayerId != null && _isBuiltinPlayer(a.playerId, builtinPlayerId);
final bIsLocal = builtinPlayerId != null && _isBuiltinPlayer(b.playerId, builtinPlayerId);
if (aIsLocal && !bIsLocal) return -1;
if (bIsLocal && !aIsLocal) return 1;
// Then by status: playing > on > off
int statusPriority(Player p) {
if (p.state == 'playing') return 0;
if (p.powered && p.state != 'off') return 1;
return 2;
}
final aPriority = statusPriority(a);
final bPriority = statusPriority(b);
if (aPriority != bPriority) return aPriority.compareTo(bPriority);
// Within same status, sort alphabetically
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
});
} else {
// Default alphabetical sort
players.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
}
}
Future<void> _loadAndSelectPlayers({bool forceRefresh = false, bool coldStart = false}) async {
try {
// Don't skip on coldStart - we need to apply full auto-selection priority logic
if (!forceRefresh &&
!coldStart &&
_cacheService.isPlayersCacheValid() &&
_availablePlayers.isNotEmpty) {
return;
}
// Load persisted Cast-to-Sendspin mappings from database
// This ensures we remember mappings even when Sendspin players are unavailable
if (_castToSendspinIdMap.isEmpty) {
try {
final persistedMappings = await DatabaseService.instance.getAllCastToSendspinMappings();
_castToSendspinIdMap.addAll(persistedMappings);
if (persistedMappings.isNotEmpty) {
_logger.log('🔗 Loaded ${persistedMappings.length} Cast->Sendspin mappings from database');
}
} catch (e) {
_logger.log('⚠️ Failed to load Cast->Sendspin mappings: $e');
}
}
final allPlayers = await getPlayers();
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
_logger.log('🎛️ getPlayers returned ${allPlayers.length} players:');
int filteredCount = 0;
_availablePlayers = allPlayers.where((player) {
final nameLower = player.name.toLowerCase();
if (nameLower.contains('music assistant mobile')) {
filteredCount++;
return false;
}
// Filter out MA Web UI's built-in player (provider is 'builtin_player' and starts with 'ma_')
// Note: We check BOTH conditions to avoid filtering snapcast/other players that may have 'ma_' prefix
if (player.provider == 'builtin_player' && player.playerId.startsWith('ma_')) {
_logger.log('🚫 Filtering out MA Web UI player: ${player.name} (provider: ${player.provider}, id: ${player.playerId})');
filteredCount++;
return false;
}
// Also filter "This Device" named players without proper provider
if (nameLower == 'this device') {
_logger.log('🚫 Filtering out "This Device" player: ${player.name}');
filteredCount++;
return false;
}
if (_isEnsemblePlayer(player.playerId)) {
if (builtinPlayerId == null || !_isBuiltinPlayer(player.playerId, builtinPlayerId)) {
_logger.log('🚫 Filtering out other device\'s player: ${player.name}');
filteredCount++;
return false;
}
}
if (!player.available) {
if (builtinPlayerId != null && _isBuiltinPlayer(player.playerId, builtinPlayerId)) {
return true;
}
filteredCount++;
return false;
}
return true;
}).toList();
// Smart Sendspin/Cast player switching:
// - When grouped: show Sendspin version (renamed), hide original Cast
// - When ungrouped: show original Cast, hide Sendspin version
// This gives power control and proper queue behavior when not syncing
final sendspinSuffix = ' (Sendspin)';
// Detect Sendspin players by:
// 1. Name ends with " (Sendspin)" (e.g., "Kitchen speaker (Sendspin)")
// 2. ID starts with "cast-" and name equals ID (e.g., ID="cast-7df484e3", name="cast-7df484e3")
// This handles Cast devices where MA registers Sendspin player with raw ID as name
bool isSendspinPlayer(Player p) {
if (p.name.endsWith(sendspinSuffix)) return true;
// Check for Sendspin players named with their raw ID (cast-{uuid-prefix})
if (p.playerId.startsWith('cast-') && p.name == p.playerId) return true;
return false;
}
final sendspinPlayers = _availablePlayers.where(isSendspinPlayer).toList();
// NOTE: Don't clear _castToSendspinIdMap - we want to remember mappings
// even when the Sendspin player is temporarily unavailable (e.g., device off)
// This allows syncing to work when the device powers back on
if (sendspinPlayers.isNotEmpty) {
// Build maps for Sendspin players and their grouped status
final sendspinByBaseName = <String, Player>{};
final groupedSendspinBaseNames = <String>{};
// Track Sendspin players that have raw ID as name (need renaming)
final rawIdSendspinPlayers = <String, Player>{};
for (final player in sendspinPlayers) {
String baseName;
Player? regularCastPlayer;
if (player.name.endsWith(sendspinSuffix)) {
// Standard "(Sendspin)" suffix naming
baseName = player.name.substring(0, player.name.length - sendspinSuffix.length);
regularCastPlayer = _availablePlayers.where(
(p) => p.name == baseName && !isSendspinPlayer(p)
).firstOrNull;
} else if (player.playerId.startsWith('cast-') && player.name == player.playerId) {
// Raw ID naming (e.g., "cast-7df484e3") - find matching Cast player by UUID prefix
// Sendspin ID format: cast-{first 8 chars of Cast UUID}
// Cast ID format: {uuid} e.g., 7df484e3-d2ee-c897-f746-2dffc29595ff
final sendspinPrefix = player.playerId.substring(5); // Remove "cast-" prefix
regularCastPlayer = _availablePlayers.where(
(p) => p.playerId.startsWith(sendspinPrefix) && !isSendspinPlayer(p)
).firstOrNull;
baseName = regularCastPlayer?.name ?? player.name;
if (regularCastPlayer != null) {
rawIdSendspinPlayers[player.playerId] = player;
_logger.log('🔍 Found raw-ID Sendspin player: ${player.playerId} matches Cast player "${regularCastPlayer.name}"');
}
} else {
continue;
}
sendspinByBaseName[baseName] = player;
// Store the ID mapping and persist to database
if (regularCastPlayer != null) {
_castToSendspinIdMap[regularCastPlayer.playerId] = player.playerId;
_logger.log('🔗 Mapped Cast ID ${regularCastPlayer.playerId} -> Sendspin ID ${player.playerId}');
// Persist mapping so it survives when Sendspin player is unavailable
DatabaseService.instance.saveCastToSendspinMapping(
regularCastPlayer.playerId,
player.playerId,
);
}
if (player.isGrouped) {
groupedSendspinBaseNames.add(baseName);
_logger.log('🔊 Sendspin player "$baseName" is grouped - will prefer Sendspin version');
} else {
_logger.log('🔊 Sendspin player "$baseName" is ungrouped - will prefer original Cast');
}
}
// Filter players based on grouped status
_availablePlayers = _availablePlayers.where((player) {
final isSendspin = isSendspinPlayer(player);
if (isSendspin) {
// Get base name for this Sendspin player
String baseName;
if (player.name.endsWith(sendspinSuffix)) {
baseName = player.name.substring(0, player.name.length - sendspinSuffix.length);
} else if (rawIdSendspinPlayers.containsKey(player.playerId)) {
// For raw ID players, find the base name from our earlier mapping
baseName = sendspinByBaseName.entries
.firstWhere((e) => e.value.playerId == player.playerId,
orElse: () => MapEntry(player.name, player))
.key;
} else {
baseName = player.name;
}
// Keep Sendspin only if grouped
if (player.isGrouped) {
return true;
} else {
_logger.log('🚫 Hiding ungrouped Sendspin player: ${player.name}');
filteredCount++;
return false;
}
} else {
// For regular players, hide if Sendspin version exists AND is grouped
if (groupedSendspinBaseNames.contains(player.name)) {
_logger.log('🚫 Preferring grouped Sendspin version over: ${player.name}');
filteredCount++;
return false;
}
return true;
}
}).toList();
// Rename remaining Sendspin players to remove the suffix or give proper name
_availablePlayers = _availablePlayers.map((player) {
if (player.name.endsWith(sendspinSuffix)) {
final cleanName = player.name.substring(0, player.name.length - sendspinSuffix.length);
_logger.log('✨ Renaming "${player.name}" to "$cleanName"');
return player.copyWith(name: cleanName);
}
// Rename raw ID Sendspin players to their proper name
if (rawIdSendspinPlayers.containsKey(player.playerId)) {
final properName = sendspinByBaseName.entries
.firstWhere((e) => e.value.playerId == player.playerId,
orElse: () => MapEntry(player.name, player))
.key;
if (properName != player.name) {
_logger.log('✨ Renaming raw ID Sendspin "${player.name}" to "$properName"');
return player.copyWith(name: properName);
}
}
return player;
}).toList();
}
// Sort players using helper method
final smartSort = await SettingsService.getSmartSortPlayers();
_sortPlayersSync(_availablePlayers, smartSort, builtinPlayerId);
// Cache players for instant display on app resume
_cacheService.setCachedPlayers(_availablePlayers);
// Persist players to database for app restart persistence
_persistPlayersToDatabase(_availablePlayers);
_logger.log('🎛️ After filtering: ${_availablePlayers.length} players available');
if (_availablePlayers.isNotEmpty) {
Player? playerToSelect;
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
_logger.log('⚙️ Current selection: ${_selectedPlayer?.name ?? 'none'}, lastSelected=$lastSelectedPlayerId, coldStart=$coldStart');
// SIMPLIFIED PLAYER SELECTION LOGIC:
// 1. User's manual selection is always respected
// 2. On app reopen: restore last selection
// 3. If selected player unavailable: switch to local player
// 4. First launch (no history): select local player
// 5. Local player unavailable: select first available
// Keep currently selected player if still available
if (_selectedPlayer != null && !coldStart) {
// Check if selected player is still available - also check translated Cast/Sendspin IDs
final selectedId = _selectedPlayer!.playerId;
final translatedId = _castToSendspinIdMap[selectedId];
String? reverseTranslatedId;
for (final entry in _castToSendspinIdMap.entries) {
if (entry.value == selectedId) {
reverseTranslatedId = entry.key;
break;
}
}
final stillAvailable = _availablePlayers.any(
(p) => p.available && (p.playerId == selectedId ||
(translatedId != null && p.playerId == translatedId) ||
(reverseTranslatedId != null && p.playerId == reverseTranslatedId)),
);
if (stillAvailable) {
final currentPlayer = _availablePlayers.firstWhere(
(p) => p.playerId == selectedId ||
(translatedId != null && p.playerId == translatedId) ||
(reverseTranslatedId != null && p.playerId == reverseTranslatedId),
);
playerToSelect = currentPlayer;
if (currentPlayer.playerId != selectedId) {
_logger.log('🔄 Selected player ID changed (Cast<->Sendspin): $selectedId -> ${currentPlayer.playerId}');
} else {
_logger.log('✓ Keeping current selection: ${currentPlayer.name}');
}
} else {
// Selected player went offline - will fall through to local player
_logger.log('⚠️ Selected player ${_selectedPlayer!.name} no longer available');
}
}
// Priority 1: Restore last selected player (on coldStart)
if (playerToSelect == null && coldStart && lastSelectedPlayerId != null) {
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == lastSelectedPlayerId && p.available,
orElse: () => null,
);
if (playerToSelect != null) {
_logger.log('🔄 Restored last selected player: ${playerToSelect.name}');
}
}
// Priority 2: Local player (default fallback)
// Try available first, then accept unavailable (Sendspin may still be registering)
if (playerToSelect == null && builtinPlayerId != null) {
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => _isBuiltinPlayer(p!.playerId, builtinPlayerId) && p.available,
orElse: () => null,
);
if (playerToSelect != null) {
_logger.log('📱 Selected local player: ${playerToSelect.name}');
} else {
// Player exists but not yet available (Sendspin registration in progress)
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => _isBuiltinPlayer(p!.playerId, builtinPlayerId),
orElse: () => null,
);
if (playerToSelect != null) {
_logger.log('📱 Selected local player (pending availability): ${playerToSelect.name}');
} else {
_logger.log('⚠️ Local player not in player list yet — waiting for Sendspin registration');
}
}
}
// Priority 3: First available player (only if no local player configured)
// Don't fall back to another player if builtin player is configured but not yet registered
if (playerToSelect == null && builtinPlayerId == null) {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.available,
orElse: () => _availablePlayers.first,
);
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
}
if (playerToSelect != null) {
selectPlayer(playerToSelect);
} else {
// Builtin player not yet registered — keep current selection if any,
// otherwise leave unselected until Sendspin registration completes
_logger.log('⏳ No player selected — awaiting builtin player registration');
}
}
// Preload track data and images in background for swipe gestures
// Don't await - UI should show immediately with cached data
unawaited(_preloadAdjacentPlayers(preloadAll: true));
} catch (e) {
ErrorHandler.logError('Load and select players', e);
}
}
void selectPlayer(Player player, {bool skipNotify = false}) async {
// Reentrancy guard - prevent concurrent selection which can cause race conditions
if (_selectPlayerInProgress) {
_logger.log('⚠️ selectPlayer already in progress, skipping for ${player.name}');
return;
}
_selectPlayerInProgress = true;
try {
_selectedPlayer = player;
// Cache for instant display on app resume
_cacheService.setCachedSelectedPlayer(player);
SettingsService.setLastSelectedPlayerId(player.playerId);
// Immediately set currentTrack from cache to avoid flash during player switch
// This ensures the UI shows the correct track info immediately, before
// the async _updatePlayerState() completes.
// IMPORTANT: Always set from cache, even if null - this prevents showing
// stale track info when switching to a non-playing player.
// For grouped child players, this returns the leader's track
// NOTE: Always use cache even for external sources - the overlay shows cached
// track info, so we should match that. The async _updatePlayerState() will
// clear it if the player is truly playing external content.
_currentTrack = getCachedTrackForPlayer(player.playerId);
// Initialize position tracker for this player (skip for external sources)
_positionTracker.onPlayerSelected(player.playerId);
if (!player.isExternalSource) {
_positionTracker.updateFromServer(
playerId: player.playerId,
position: player.elapsedTime ?? 0.0,
isPlaying: player.state == 'playing',
duration: _currentTrack?.duration,
serverTimestamp: player.elapsedTimeLastUpdated,
);
}
// Switch audio handler mode based on player type
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
final isBuiltinPlayer = builtinPlayerId != null && _isBuiltinPlayer(player.playerId, builtinPlayerId);
if (isBuiltinPlayer) {
audioHandler.setLocalMode();
// Update notification for builtin player using local mode method (keeps pause working)
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: 512);
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '${displayArtist} • ${player.name}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(artworkUrl),
);
// Position comes from actual player in updateLocalModeNotification
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: player.state == 'playing',
duration: track.duration,
);
} else if (player.state == 'playing' || player.state == 'paused') {
// Builtin player active but no cached track - show player name placeholder
final mediaItem = audio_service.MediaItem(
id: 'player_${player.playerId}',
title: player.name,
artist: 'Loading...',
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: player.state == 'playing',
);
} else if (_currentTrack != null) {
// Builtin player is idle but we have track data — show paused notification
// instead of clearing, to avoid artwork flash when AA toggles play/pause
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: 512);
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '${displayArtist} • ${player.name}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(artworkUrl),
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: false,
duration: track.duration,
);
_startIdleServiceTimer();
} else {
// Builtin player is idle with no track - clear notification
audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
}
} else {
// For remote players, immediately show notification if we have cached track info
// and the player is playing (don't wait for polling to kick in)
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: 512);
// Include player name in artist line: "Artist • Player Name"
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '${displayArtist} • ${player.name}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(artworkUrl),
);
// Use position tracker for consistent position
final position = _positionTracker.currentPosition;
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: player.state == 'playing',
position: position,
duration: track.duration,
);
} else if (player.state == 'playing' || player.state == 'paused') {
// Player is active but no cached track - show player name placeholder
// This prevents stale notification from previous player
final mediaItem = audio_service.MediaItem(
id: 'player_${player.playerId}',
title: player.name,
artist: 'Loading...',
);
final position = _positionTracker.currentPosition;
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: player.state == 'playing',
position: position,
duration: Duration.zero,
);
} else {
// Player is idle - clear the notification to prevent stale metadata
// from previous player showing (fixes issue #42)
audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
}
}
_startPlayerStatePolling();
// Start notification position timer for remote players
_manageNotificationPositionTimer();
_preloadAdjacentPlayers();
// Immediately fetch fresh track data from queue to avoid showing stale cache
// This is important when resuming app after being in background - cached data
// from player_updated events may have incomplete artist info
await _updatePlayerState();
if (!skipNotify) {
notifyListeners();
}
} finally {
_selectPlayerInProgress = false;
}
}
/// Cycle to the next active player (for notification switch button)
/// Only cycles through players that are currently playing or paused
Future<void> selectNextPlayer() async {
// If not connected yet (cold start), wait briefly for connection
if (!isConnected) {
_logger.log('🔄 Not connected yet, waiting for connection...');
// Wait up to 3 seconds for connection to be established
for (int i = 0; i < 30; i++) {
await Future.delayed(const Duration(milliseconds: 100));
if (isConnected) break;
}
if (!isConnected) {
_logger.log('🔄 Still not connected, cannot switch player');
return;
}
}
// If players haven't loaded yet (cold start), try to load them first
if (_availablePlayers.isEmpty && _api != null) {
_logger.log('🔄 Players not loaded yet, loading...');
await _loadAndSelectPlayers();
}
// 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.playerId.startsWith('ensemble_') // Always include builtin player
)
).toList();
if (activePlayers.isEmpty) {
_logger.log('🔄 No active players to switch to');
return;
}
if (_selectedPlayer == null) {
// If no player selected, select the first active one
_logger.log('🔄 No player selected, selecting first active: ${activePlayers.first.name}');
selectPlayer(activePlayers.first);
return;
}
// Find current player in active list
final currentIndex = activePlayers.indexWhere((p) => p.playerId == _selectedPlayer!.playerId);
// Calculate next index - if current isn't in active list, start at 0
final nextIndex = currentIndex == -1 ? 0 : (currentIndex + 1) % activePlayers.length;
final nextPlayer = activePlayers[nextIndex];
_logger.log('🔄 Switching to next active player: ${nextPlayer.name} (${nextIndex + 1}/${activePlayers.length})');
selectPlayer(nextPlayer);
}
/// 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() {
_notificationPositionTimer?.cancel();
if (_selectedPlayer == null || _currentTrack == null) return;
// Check if this is a builtin/local player
final playerId = _selectedPlayer!.playerId;
if (playerId.startsWith('ensemble_')) {
// 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
}
// Only run timer if player is playing
if (_selectedPlayer!.state != 'playing') return;
_notificationPositionTimer = Timer.periodic(
const Duration(milliseconds: 500), // 500ms for smoother progress
(_) => _updateNotificationPosition(),
);
}
/// Update just the notification position (called every 500ms for remote/Sendspin players)
/// Set [forceUpdate] to true to update even when paused (used after seek)
void _updateNotificationPosition({bool forceUpdate = false}) {
if (_selectedPlayer == null || _currentTrack == null) {
_notificationPositionTimer?.cancel();
return;
}
final isPlaying = _selectedPlayer!.state == 'playing';
// Don't update if player is not playing (unless forced for seek)
if (!isPlaying && !forceUpdate) {
_notificationPositionTimer?.cancel();
return;
}
final track = _currentTrack!;
// Always use position tracker for notification position - it's the single source of truth.
// Previously used PCM elapsed time for Sendspin, but this breaks when:
// 1. Stream starts mid-track (resume from pause) - PCM elapsed resets to 0
// 2. Switching between multiple Sendspin players - PCM elapsed doesn't match
final 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 displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '${displayArtist} • ${_selectedPlayer!.name}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(artworkUrl),
);
_cancelIdleServiceTimer();
// Use updateLocalModeNotification for builtin players to avoid
// conflicting mediaItem.add() calls with _updatePlayerState()
final isBuiltinPlayer = _sendspinConnected && _pcmAudioPlayer != null;
if (isBuiltinPlayer) {
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: isPlaying,
duration: track.duration,
position: position,
);
} else {
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: isPlaying,
position: position,
duration: track.duration,
);
}
}
Future<void> _preloadAdjacentPlayers({bool preloadAll = false}) async {
if (_api == null) return;
final players = _availablePlayers.where((p) => p.available).toList();
if (players.isEmpty) return;
if (preloadAll) {
_logger.log('🖼️ Preloading track info for all ${players.length} players...');
await Future.wait(
players.map((player) => _preloadPlayerTrack(player)),
);
_logger.log('🖼️ Preloading complete');
return;
}
if (_selectedPlayer == null) return;
if (players.length <= 1) return;
final currentIndex = players.indexWhere((p) => p.playerId == _selectedPlayer!.playerId);
if (currentIndex == -1) return;
final prevIndex = currentIndex <= 0 ? players.length - 1 : currentIndex - 1;
final nextIndex = currentIndex >= players.length - 1 ? 0 : currentIndex + 1;
final playersToPreload = <Player>{};
if (prevIndex != currentIndex) playersToPreload.add(players[prevIndex]);
if (nextIndex != currentIndex) playersToPreload.add(players[nextIndex]);
// Await all preloads so images are ready for next swipe
await Future.wait(
playersToPreload.map((player) => _preloadPlayerTrack(player)),
);
}
Future<void> _preloadPlayerTrack(Player player) async {
if (_api == null) return;
try {
// Log app_id and active_queue for playing players to help diagnose external source detection
if (player.state == 'playing') {
_logger.log('🔍 Preload ${player.name}: state=${player.state}, app_id=${player.appId}, active_queue=${player.activeQueue != null ? "set" : "null"}, isExternalSource=${player.isExternalSource}');
} else {
_logger.log('🔍 Preload ${player.name}: state=${player.state}, available=${player.available}');
}
// Skip external sources - they're not playing MA content
if (player.isExternalSource) {
_logger.log('🔍 Preload ${player.name}: SKIPPED - external source (app_id=${player.appId}, active_queue=${player.activeQueue != null ? "set" : "null"})');
_cacheService.setCachedTrackForPlayer(player.playerId, null);
return;
}
if (!player.available || !player.powered) {
_logger.log('🔍 Preload ${player.name}: SKIPPED - not available or powered');
_cacheService.setCachedTrackForPlayer(player.playerId, null);
return;
}
final queue = await getQueue(player.playerId);
_logger.log('🔍 Preload ${player.name}: queue=${queue != null}, currentItem=${queue?.currentItem != null}');
if (queue != null && queue.currentItem != null) {
final track = queue.currentItem!.track;
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
final existingHasImage = existingTrack?.metadata?['images'] != null;
final newHasImage = track.metadata?['images'] != null;
if (existingHasImage && !newHasImage) {
_logger.log('🔍 Preload ${player.name}: SKIPPED - keeping cached track with image');
} else {
_cacheService.setCachedTrackForPlayer(player.playerId, track);
_logger.log('🔍 Preload ${player.name}: CACHED track "${track.name}"');
// Dual-cache for Cast<->Sendspin players
final sendspinId = _castToSendspinIdMap[player.playerId];
if (sendspinId != null) {
_cacheService.setCachedTrackForPlayer(sendspinId, track);
} else if (player.provider == 'chromecast' && player.playerId.length >= 8) {
final computedSendspinId = 'cast-${player.playerId.substring(0, 8)}';
_cacheService.setCachedTrackForPlayer(computedSendspinId, track);
}
// Also precache the image so it's ready for swipe preview
final imageUrl = getImageUrl(track, size: 512);
if (imageUrl != null) {
_precacheImage(imageUrl);
}
}
} else {
_logger.log('🔍 Preload ${player.name}: NO TRACK - queue empty');
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
if (existingTrack?.metadata?['images'] == null) {
_cacheService.setCachedTrackForPlayer(player.playerId, null);
}
}
} catch (e) {
_logger.log('Error preloading player track for ${player.name}: $e');
}
}
/// Precache an image URL so it loads instantly when displayed
Future<void> _precacheImage(String url) async {
try {
final imageProvider = CachedNetworkImageProvider(url);
final imageStream = imageProvider.resolve(const ImageConfiguration());
final completer = Completer<void>();
late ImageStreamListener listener;
listener = ImageStreamListener(
(ImageInfo info, bool synchronousCall) {
if (!completer.isCompleted) completer.complete();
imageStream.removeListener(listener);
},
onError: (exception, stackTrace) {
if (!completer.isCompleted) completer.completeError(exception);
imageStream.removeListener(listener);
},
);
imageStream.addListener(listener);
await completer.future.timeout(const Duration(seconds: 5), onTimeout: () {
imageStream.removeListener(listener);
});
} catch (e) {
// Silently ignore precache errors - image will load on demand
}
}
Future<void> preloadAllPlayerTracks() async {
if (_api == null) return;
final players = _availablePlayers.where((p) => p.available).toList();
await Future.wait(
players.map((player) => _preloadPlayerTrack(player)),
);
// After preloading, update _currentTrack from cache if it has better data
// This fixes the issue where mini player shows wrong info but device list is correct
// For grouped child players, this gets the leader's track
if (_selectedPlayer != null && _currentTrack != null) {
final cachedTrack = getCachedTrackForPlayer(_selectedPlayer!.playerId);
if (cachedTrack != null && cachedTrack.uri == _currentTrack!.uri) {
final cachedHasImage = cachedTrack.metadata?['images'] != null;
final currentHasImage = _currentTrack!.metadata?['images'] != null;
final cachedHasArtist = cachedTrack.artistsString.isNotEmpty &&
cachedTrack.artistsString != 'Unknown Artist';
final currentHasArtist = _currentTrack!.artistsString.isNotEmpty &&
_currentTrack!.artistsString != 'Unknown Artist';
if ((cachedHasImage && !currentHasImage) || (cachedHasArtist && !currentHasArtist)) {
_currentTrack = cachedTrack;
_logger.log('🎵 Updated currentTrack from cache with better metadata');
notifyListeners();
}
}
}
}
void _startPlayerStatePolling() {
_playerStateTimer?.cancel();
if (_selectedPlayer == null) return;
_playerStateTimer = Timer.periodic(Timings.playerPollingInterval, (_) async {
try {
await _updatePlayerState();
} catch (e) {
_logger.log('Error updating player state (will retry): $e');
}
});
_updatePlayerState();
}
/// Start the idle service timer - stops foreground service after 30 min of no playback
/// Called when playback stops (player state becomes idle)
void _startIdleServiceTimer() {
_idleServiceTimer?.cancel();
_logger.debug('⏱️ Starting idle service timer (${_idleServiceTimeout.inMinutes} min)');
_idleServiceTimer = Timer(_idleServiceTimeout, () {
_logger.log('⏱️ Idle service timeout reached - stopping foreground service');
audioHandler.stopService();
});
}
/// Cancel the idle service timer - called when playback starts
void _cancelIdleServiceTimer() {
if (_idleServiceTimer?.isActive == true) {
_logger.log('⏱️ Cancelling idle service timer (playback resumed)');
_idleServiceTimer?.cancel();
_idleServiceTimer = null;
}
}
/// Apply volume protection for the selected player.
/// Injects effective volume for group players (whose API volume is null)
/// and protects optimistic volume updates from stale API data.
void _applyVolumeProtection(Player updatedPlayer, List<Player> allPlayers) {
if (_groupVolumeManager.isGroupPlayer(updatedPlayer)) {
final effectiveVolume = _groupVolumeManager.getEffectiveVolumeLevel(
updatedPlayer, allPlayers);
if (effectiveVolume != null) {
_selectedPlayer = _selectedPlayer!.copyWith(volumeLevel: effectiveVolume);
}
}
final pendingVol = _pendingVolumes[updatedPlayer.playerId];
final pendingTs = _pendingVolumeTimestamps[updatedPlayer.playerId];
if (pendingVol != null && pendingTs != null) {
final elapsed = DateTime.now().millisecondsSinceEpoch - pendingTs;
final isGroup = _groupVolumeManager.isGroupPlayer(updatedPlayer);
final timeoutMs = isGroup ? _pendingVolumeTimeoutGroupMs : _pendingVolumeTimeoutMs;
if (elapsed >= timeoutMs) {
_pendingVolumes.remove(updatedPlayer.playerId);
_pendingVolumeTimestamps.remove(updatedPlayer.playerId);
} else {
final currentEffective = _selectedPlayer?.volumeLevel;
if (currentEffective != null &&
(currentEffective - pendingVol).abs() <= _pendingVolumeTolerancePoints) {
_pendingVolumes.remove(updatedPlayer.playerId);
_pendingVolumeTimestamps.remove(updatedPlayer.playerId);
} else {
_selectedPlayer = _selectedPlayer!.copyWith(volumeLevel: pendingVol);
}
}
}
}
Future<void> _updatePlayerState() async {
if (_selectedPlayer == null || _api == null) return;
// Don't poll when not connected - avoids "Not authenticated" spam
if (_api!.currentConnectionState != MAConnectionState.authenticated) return;
// Capture player ID at start to detect stale operations
// If player changes during async operations, we should abort
final operationPlayerId = _selectedPlayer!.playerId;
try {
bool stateChanged = false;
// Get fresh player data - but look up from _availablePlayers which has
// the processed names (Sendspin suffix removed) and correct filtering
// Also check translated Cast<->Sendspin IDs
final selectedId = _selectedPlayer!.playerId;
final translatedSendspinId = _castToSendspinIdMap[selectedId];
String? translatedCastId;
for (final entry in _castToSendspinIdMap.entries) {
if (entry.value == selectedId) {
translatedCastId = entry.key;
break;
}
}
// Try to refresh from API first for latest state
final allPlayers = await getPlayers();
// Check for stale operation - player may have changed during async call
if (_selectedPlayer?.playerId != operationPlayerId) {
_logger.log('⚠️ _updatePlayerState: Player changed during operation, aborting');
return;
}
// Update _availablePlayers with fresh volume/state data from API.
// This is critical for group volume computation: member players don't get
// individual _updatePlayerState() calls, so their volumes in _availablePlayers
// would go stale. Without this, after the pending timeout expires, the
// computed group average uses old member volumes and snaps back to 0.
for (final freshPlayer in allPlayers) {
final idx = _availablePlayers.indexWhere(
(p) => p.playerId == freshPlayer.playerId);
if (idx != -1) {
_availablePlayers[idx] = _availablePlayers[idx].copyWith(
volumeLevel: freshPlayer.volumeLevel,
state: freshPlayer.state,
powered: freshPlayer.powered,
available: freshPlayer.available,
);
}
}
Player? rawPlayer = allPlayers.firstWhere(
(p) => p.playerId == selectedId ||
(translatedSendspinId != null && p.playerId == translatedSendspinId) ||
(translatedCastId != null && p.playerId == translatedCastId),
orElse: () => _selectedPlayer!,
);
// Now get the processed version from _availablePlayers to preserve renamed name
// But update with latest state (volume, playing state, etc.) from rawPlayer
final processedPlayer = _availablePlayers.firstWhere(
(p) => p.playerId == selectedId ||
(translatedSendspinId != null && p.playerId == translatedSendspinId) ||
(translatedCastId != null && p.playerId == translatedCastId),
orElse: () => rawPlayer,
);
// Use the processed player's name but raw player's state
final updatedPlayer = processedPlayer.copyWith(
state: rawPlayer.state,
volumeLevel: rawPlayer.volumeLevel,
volumeMuted: rawPlayer.volumeMuted,
elapsedTime: rawPlayer.elapsedTime,
elapsedTimeLastUpdated: rawPlayer.elapsedTimeLastUpdated,
powered: rawPlayer.powered,
available: rawPlayer.available,
groupMembers: rawPlayer.groupMembers,
syncedTo: rawPlayer.syncedTo,
currentItemId: rawPlayer.currentItemId,
isExternalSource: rawPlayer.isExternalSource,
appId: rawPlayer.appId,
activeQueue: rawPlayer.activeQueue,
);
_selectedPlayer = updatedPlayer;
_applyVolumeProtection(updatedPlayer, allPlayers);
stateChanged = true;
// Handle external sources (Spotify Connect, TV optical, etc.)
// These players are "playing" from MA's perspective but not MA content
if (updatedPlayer.isExternalSource) {
if (_currentTrack != null) {
_currentTrack = null;
_cacheService.setCachedTrackForPlayer(updatedPlayer.playerId, null);
stateChanged = true;
_persistPlaybackState();
}
if (_currentAudiobook != null) {
_logger.log('📚 External source active, clearing audiobook context');
_currentAudiobook = null;
stateChanged = true;
}
if (_currentPodcastName != null) {
_logger.log('🎙️ External source active, clearing podcast context');
_currentPodcastName = null;
stateChanged = true;
}
// Clear notification for external source
audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
if (stateChanged) {
notifyListeners();
}
return;
}
// Feed position tracker with server data
// Log raw values to debug position issues
final rawElapsedTime = updatedPlayer.elapsedTime;
final rawTimestamp = updatedPlayer.elapsedTimeLastUpdated;
if (rawElapsedTime == null) {
_logger.log('⚠️ Player ${updatedPlayer.name} has null elapsedTime');
}
_positionTracker.updateFromServer(
playerId: updatedPlayer.playerId,
position: rawElapsedTime ?? 0.0,
isPlaying: updatedPlayer.state == 'playing',
duration: _currentTrack?.duration,
serverTimestamp: rawTimestamp,
);
final isPlayingOrPaused = _selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused';
final isIdleWithContent = _selectedPlayer!.state == 'idle' && _selectedPlayer!.powered;
final shouldShowTrack = _selectedPlayer!.available && (isPlayingOrPaused || isIdleWithContent || isPcmPlaying);
if (!shouldShowTrack) {
if (_currentTrack != null) {
// Player went idle - try to keep last-known track visible for resume
final lastKnown = _cacheService.getLastKnownTrack(_selectedPlayer!.playerId);
if (lastKnown != null && _selectedPlayer!.state == 'idle') {
_logger.log('🔄 Player idle-out: keeping last-known track "${lastKnown.name}" for resume');
_currentTrack = lastKnown;
} else {
_currentTrack = null;
// Persist cleared track state
_persistPlaybackState();
}
stateChanged = true;
}
// Clear audiobook context when playback stops
if (_currentAudiobook != null) {
_logger.log('📚 Playback stopped, clearing audiobook context');
_currentAudiobook = null;
stateChanged = true;
}
// Clear podcast context when playback stops
if (_currentPodcastName != null) {
_logger.log('🎙️ Playback stopped, clearing podcast context');
_currentPodcastName = null;
stateChanged = true;
}
if (stateChanged) {
notifyListeners();
}
return;
}
final queue = await getQueue(_selectedPlayer!.playerId);
// Check for stale operation - player may have changed during async call
if (_selectedPlayer?.playerId != operationPlayerId) {
_logger.log('⚠️ _updatePlayerState: Player changed during getQueue, aborting');
return;
}
if (queue != null && queue.currentItem != null) {
if (queue.currentIndex != null) {
audioHandler.updateQueueIndex(queue.currentIndex!);
}
final queueTrack = queue.currentItem!.track;
final trackChanged = _currentTrack == null ||
_currentTrack!.uri != queueTrack.uri ||
_currentTrack!.name != queueTrack.name;
if (trackChanged) {
// Check "end of track" sleep timer - previous track just ended
if (_currentTrack != null) {
checkEndOfTrackSleepTimer();
}
// Check if cached track has better metadata (images, proper artist info)
// This prevents losing album art and artist data when resuming the app
// For grouped child players, this gets the leader's cached track
final cachedTrack = getCachedTrackForPlayer(_selectedPlayer!.playerId);
final cachedHasImage = cachedTrack?.metadata?['images'] != null;
final queueHasImage = queueTrack.metadata?['images'] != null;
// Also check for malformed artist names (e.g., "Artist - Title" format in name)
final cachedHasProperArtist = cachedTrack?.artistsString.isNotEmpty == true &&
cachedTrack?.artistsString != 'Unknown Artist' &&
!cachedTrack!.name.contains(' - '); // Proper track doesn't have artist in name
final queueHasProperArtist = queueTrack.artistsString.isNotEmpty &&
queueTrack.artistsString != 'Unknown Artist' &&
!queueTrack.name.contains(' - ');
// Also check if _currentTrack (set from cache in selectPlayer) has good data
final currentHasImage = _currentTrack?.metadata?['images'] != null;
final currentHasProperArtist = _currentTrack?.artistsString.isNotEmpty == true &&
_currentTrack?.artistsString != 'Unknown Artist' &&
!(_currentTrack?.name.contains(' - ') ?? false);
// Match by URI - name might differ if queue has malformed data
final isSameTrackAsCached = cachedTrack?.uri == queueTrack.uri;
final cachedHasBetterData = (cachedHasImage && !queueHasImage) ||
(cachedHasProperArtist && !queueHasProperArtist);
// Queue track has bad metadata if it lacks image AND lacks proper artist
final queueHasBadMetadata = !queueHasImage && !queueHasProperArtist;
// Current track (from cache at selectPlayer) has good metadata
final currentHasGoodMetadata = currentHasImage || currentHasProperArtist;
if (isSameTrackAsCached && cachedHasBetterData) {
// Use cached track which has better metadata
_currentTrack = cachedTrack;
_logger.log('🎵 Using cached track with better metadata for ${cachedTrack!.name}');
} else if (queueHasBadMetadata && currentHasGoodMetadata) {
// Queue has bad data, but _currentTrack (set from cache) is good - keep it
_logger.log('🎵 Keeping currentTrack - queue has bad metadata but current is good');
// Don't change _currentTrack, keep the good data
} else {
_currentTrack = queueTrack;
// Update cache if queue track has good metadata
if (queueHasImage || queueHasProperArtist) {
_cacheService.setCachedTrackForPlayer(_selectedPlayer!.playerId, queueTrack);
// Dual-cache for Cast<->Sendspin players
final sendspinId = _castToSendspinIdMap[_selectedPlayer!.playerId];
if (sendspinId != null) {
_cacheService.setCachedTrackForPlayer(sendspinId, queueTrack);
} else if (_selectedPlayer!.provider == 'chromecast' && _selectedPlayer!.playerId.length >= 8) {
final computedSendspinId = 'cast-${_selectedPlayer!.playerId.substring(0, 8)}';
_cacheService.setCachedTrackForPlayer(computedSendspinId, queueTrack);
}
}
}
stateChanged = true;
// Persist the updated playback state to database for instant restore on next launch
_persistPlaybackState();
// Clear audiobook context if switched to a different media item
// Check if the new track is from the same audiobook (by comparing URIs)
if (_currentAudiobook != null) {
final currentUri = _currentTrack!.uri ?? '';
final audiobookUri = _currentAudiobook!.uri ?? 'library://audiobook/${_currentAudiobook!.itemId}';
// The audiobook chapter URIs should contain the audiobook URI pattern
if (!currentUri.contains(_currentAudiobook!.itemId) &&
!currentUri.contains(audiobookUri)) {
_logger.log('📚 Track changed to non-audiobook, clearing context');
_currentAudiobook = null;
}
}
}
// Save last-known track for resume after idle-out
_cacheService.setLastKnownTrack(_selectedPlayer!.playerId, _currentTrack!);
// Update notification for ALL players
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: 512);
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
final isBuiltinPlayer = builtinPlayerId != null && _isBuiltinPlayer(_selectedPlayer!.playerId, builtinPlayerId);
if (isBuiltinPlayer) {
// Local playback - use local mode notification (keeps pause working)
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '$displayArtist • ${_selectedPlayer!.name}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(artworkUrl),
);
// Position comes from actual player in updateLocalModeNotification
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: _selectedPlayer!.state == 'playing',
duration: track.duration,
);
} else {
// Remote MA player - show notification via remote mode
// Include player name in artist line: "Artist • Player Name"
final displayArtist = _trackDisplayArtist(track);
final artistWithPlayer = '$displayArtist • ${_selectedPlayer!.name}';
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: _contentArtUri(artworkUrl),
);
// Use position tracker for consistent position (single source of truth)
final position = _positionTracker.currentPosition;
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: _selectedPlayer!.state == 'playing',
position: position,
duration: track.duration,
);
}
} else {
// No queue data available from API
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
final isBuiltinPlayer = builtinPlayerId != null && _isBuiltinPlayer(_selectedPlayer!.playerId, builtinPlayerId);
// For builtin player, try to preserve cached track data since queue API doesn't return it
// The cached track was set in selectPlayer() and contains valid data
if (isBuiltinPlayer && _currentTrack == null) {
final cachedTrack = getCachedTrackForPlayer(_selectedPlayer!.playerId);
if (cachedTrack != null) {
_currentTrack = cachedTrack;
stateChanged = true;
_logger.log('🎵 Restored cached track for builtin player: ${cachedTrack.name}');
}
}
// Only clear track if we have no cached data to fall back on
// Don't clear for builtin player - it maintains its own playback state
if (_currentTrack != null && !isBuiltinPlayer) {
_currentTrack = null;
stateChanged = true;
// Persist cleared track state
_persistPlaybackState();
}
// Show player name placeholder only if we have no track data
if (_currentTrack == null && (_selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused')) {
final mediaItem = audio_service.MediaItem(
id: 'player_${_selectedPlayer!.playerId}',
title: _selectedPlayer!.name,
artist: 'No track info',
);
if (isBuiltinPlayer) {
// Position comes from actual player in updateLocalModeNotification
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: _selectedPlayer!.state == 'playing',
);
} else {
final position = _positionTracker.currentPosition;
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: _selectedPlayer!.state == 'playing',
position: position,
duration: Duration.zero,
);
}
} else if (_currentTrack != null && isBuiltinPlayer) {
// Builtin player has track data - update notification with it
final track = _currentTrack!;
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: _trackDisplayArtist(track),
album: track.album?.name,
artUri: _contentArtUri(_api?.getImageUrl(track, size: 512)),
duration: track.duration,
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: _selectedPlayer!.state == 'playing',
);
} else if (_selectedPlayer!.state != 'playing' && _selectedPlayer!.state != 'paused') {
audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
_positionTracker.clear();
}
}
// Manage notification position timer based on current player state
_manageNotificationPositionTimer();
if (stateChanged) {
notifyListeners();
}
} catch (e) {
_logger.log('❌ Error updating player state: $e');
}
}
Future<void> playPauseSelectedPlayer() async {
if (_selectedPlayer == null) return;
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 {
// Always try resume first — even if player reports "idle", the server
// queue may still be intact (e.g., Sendspin sends "stopped" on pause).
// Only fall back to re-queuing if resume fails.
final playerId = _selectedPlayer!.playerId;
await resumePlayer(playerId);
// Sendspin PCM streaming has ~5s buffering delay on resume.
// For audiobooks, seek back to compensate so the user doesn't miss narration.
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && _isBuiltinPlayer(playerId, builtinPlayerId) && _sendspinConnected && isPlayingAudiobook) {
final pos = _positionTracker.currentPosition.inSeconds;
if (pos > 10) {
_logger.log('📚 Seeking back 10s to compensate for Sendspin buffering');
unawaited(seek(playerId, pos - 10));
}
}
// For resume: Refresh in background to get updated track info
unawaited(refreshPlayers());
}
}
Future<void> nextTrackSelectedPlayer() async {
if (_selectedPlayer == null) return;
await nextTrack(_selectedPlayer!.playerId);
await Future.delayed(Timings.trackChangeDelay);
await _updatePlayerState();
}
Future<void> previousTrackSelectedPlayer() async {
if (_selectedPlayer == null) return;
await previousTrack(_selectedPlayer!.playerId);
await Future.delayed(Timings.trackChangeDelay);
await _updatePlayerState();
}
Future<void> refreshPlayers() async {
final previousState = _selectedPlayer?.state;
final previousVolume = _selectedPlayer?.volumeLevel;
await _loadAndSelectPlayers(forceRefresh: true);
bool stateChanged = false;
if (_selectedPlayer != null && _availablePlayers.isNotEmpty) {
try {
final updatedPlayer = _availablePlayers.firstWhere(
(p) => p.playerId == _selectedPlayer!.playerId,
);
if (updatedPlayer.state != previousState ||
updatedPlayer.volumeLevel != previousVolume) {
stateChanged = true;
}
_selectedPlayer = updatedPlayer;
_applyVolumeProtection(updatedPlayer, _availablePlayers);
} catch (e) {
stateChanged = true;
}
}
if (stateChanged) {
notifyListeners();
}
}
// ============================================================================
// LIBRARY DATA
// ============================================================================
Future<void> loadLibrary() async {
if (!isConnected) return;
try {
_isLoading = true;
_error = null;
// Load from database cache first (instant)
final syncService = SyncService.instance;
if (syncService.hasCache) {
_albums = syncService.cachedAlbums;
_artists = syncService.cachedArtists;
_audiobooks = syncService.cachedAudiobooks;
_logger.log('📦 Loaded ${_albums.length} albums, ${_artists.length} artists, ${_audiobooks.length} audiobooks from cache');
_syncLibraryStatusToService();
notifyListeners();
}
_isLoading = false;
_syncLibraryStatusToService();
notifyListeners();
// Trigger background sync (non-blocking)
if (_api != null) {
_syncLibraryInBackground();
}
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load library');
_error = errorInfo.userMessage;
_isLoading = false;
notifyListeners();
}
}
/// Sync library data in background without blocking UI
Future<void> _syncLibraryInBackground() async {
if (_api == null) return;
final syncService = SyncService.instance;
// Listen for sync completion to update our lists
void onSyncComplete() {
if (syncService.status == SyncStatus.completed) {
_albums = syncService.cachedAlbums;
_artists = syncService.cachedArtists;
_audiobooks = syncService.cachedAudiobooks;
if (syncService.cachedPodcasts.isNotEmpty) {
_podcasts = syncService.cachedPodcasts;
}
_logger.log('🔄 Updated library from background sync: ${_albums.length} albums, ${_artists.length} artists, ${_audiobooks.length} audiobooks, ${_podcasts.length} podcasts');
_syncLibraryStatusToService();
notifyListeners();
_prefetchAlbumImages();
audioHandler.invalidateAutoChildren(const [
'cat|artists', 'cat|albums', 'cat|playlists', 'cat|home',
]);
}
syncService.removeListener(onSyncComplete);
}
syncService.addListener(onSyncComplete);
await syncService.syncFromApi(_api!, providerInstanceIds: providerIdsForApiCalls);
}
/// Prefetch album and audiobook images in background after sync
void _prefetchAlbumImages() {
_imagePrefetchService?.cancel();
_imagePrefetchService = ImagePrefetchService();
final urls = <String>[];
for (final album in _albums) {
final url = _api?.getImageUrl(album, size: 256);
if (url != null) urls.add(url);
}
for (final audiobook in SyncService.instance.cachedAudiobooks) {
final url = _api?.getImageUrl(audiobook, size: 256);
if (url != null) urls.add(url);
}
if (urls.isEmpty) return;
_logger.log('🖼️ Starting image prefetch for ${urls.length} items');
_imagePrefetchService!.prefetchImages(urls);
}
/// Load radio stations from the library
Future<void> loadRadioStations({String? orderBy}) async {
if (!isConnected || _api == null) return;
try {
_isLoadingRadio = true;
notifyListeners();
_radioStations = await _api!.getRadioStations(limit: 100, orderBy: orderBy);
_isLoadingRadio = false;
notifyListeners();
} catch (e) {
_logger.log('⚠️ Failed to load radio stations: $e');
_isLoadingRadio = false;
notifyListeners();
}
}
Future<void> loadPodcasts({String? orderBy}) async {
if (!isConnected || _api == null) return;
// Load from cache first (instant)
final syncService = SyncService.instance;
if (syncService.cachedPodcasts.isNotEmpty && _podcasts.isEmpty) {
_podcasts = syncService.cachedPodcasts;
_logger.log('📦 Loaded ${_podcasts.length} podcasts from cache');
notifyListeners();
}
try {
_isLoadingPodcasts = true;
if (_podcasts.isEmpty) notifyListeners();
_podcasts = await _api!.getPodcasts(limit: 100, orderBy: orderBy);
_logger.log('🎙️ Loaded ${_podcasts.length} podcasts');
_isLoadingPodcasts = false;
notifyListeners();
// Fetch episode covers in background for podcasts with low-res images
_loadPodcastCoversInBackground();
} catch (e) {
_logger.log('⚠️ Failed to load podcasts: $e');
_isLoadingPodcasts = false;
notifyListeners();
}
}
/// Load high-resolution podcast covers from iTunes in background
/// iTunes provides 800x800 artwork for most podcasts (reduced from 1400 for efficiency)
Future<void> _loadPodcastCoversInBackground() async {
if (_api == null) return;
for (final podcast in _podcasts) {
try {
// Skip if already cached (either in memory or loaded from persistence)
if (_podcastCoverCache.containsKey(podcast.itemId)) continue;
// Try iTunes for high-res artwork
final itunesArtwork = await _api!.getITunesPodcastArtwork(podcast.name);
if (itunesArtwork != null) {
_podcastCoverCache[podcast.itemId] = itunesArtwork;
_logger.log('🎙️ Cached iTunes artwork for ${podcast.name}');
// Persist to storage for instant display on next launch
SettingsService.addPodcastCoverToCache(podcast.itemId, itunesArtwork);
notifyListeners();
}
} catch (e) {
_logger.log('⚠️ Failed to load cover for ${podcast.name}: $e');
}
}
}
Future<void> loadArtists({int? limit, int? offset, String? search, String? orderBy}) async {
if (!isConnected) return;
try {
_isLoading = true;
_error = null;
notifyListeners();
_artists = await _api!.getArtists(
limit: limit ?? LibraryConstants.maxLibraryItems,
offset: offset,
search: search,
albumArtistsOnly: false, // Show ALL library artists, not just those with albums
orderBy: orderBy,
);
// Update SyncService cache with sorted data so UI sees the new order
SyncService.instance.updateCachedArtists(_artists);
_isLoading = false;
_syncLibraryStatusToService();
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
_error = errorInfo.userMessage;
_isLoading = false;
notifyListeners();
}
}
Future<void> loadAlbums({
int? limit,
int? offset,
String? search,
String? artistId,
String? orderBy,
}) async {
if (!isConnected) return;
try {
_isLoading = true;
_error = null;
notifyListeners();
_albums = await _api!.getAlbums(
limit: limit ?? LibraryConstants.maxLibraryItems,
offset: offset,
search: search,
artistId: artistId,
orderBy: orderBy,
);
// Update SyncService cache with sorted data so UI sees the new order
SyncService.instance.updateCachedAlbums(_albums);
_isLoading = false;
_syncLibraryStatusToService();
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
_error = errorInfo.userMessage;
_isLoading = false;
notifyListeners();
}
}
Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
if (!isConnected) return [];
try {
return await _api!.getAlbumTracks(provider, itemId);
} catch (e) {
ErrorHandler.logError('Get album tracks', e);
return [];
}
}
Future<Map<String, List<MediaItem>>> search(String query, {bool libraryOnly = false}) async {
if (!isConnected) {
return {'artists': [], 'albums': [], 'tracks': []};
}
try {
final results = await _api!.search(query, libraryOnly: libraryOnly);
return results;
} catch (e) {
ErrorHandler.logError('Search', e);
return {'artists': [], 'albums': [], 'tracks': []};
}
}
/// Get playlists with provider filtering applied
Future<List<Playlist>> getPlaylists({int? limit, bool? favoriteOnly, String? orderBy}) async {
if (_api == null) return [];
try {
final playlists = await _api!.getPlaylists(limit: limit, favoriteOnly: favoriteOnly, orderBy: orderBy);
return filterByProvider(playlists);
} catch (e) {
_logger.log('❌ Failed to fetch playlists: $e');
return [];
}
}
/// Get audiobooks with provider filtering applied
Future<List<Audiobook>> getAudiobooks({int? limit, bool? favoriteOnly}) async {
if (_api == null) return [];
try {
final audiobooks = await _api!.getAudiobooks(limit: limit, favoriteOnly: favoriteOnly ?? false);
return filterByProvider(audiobooks);
} catch (e) {
_logger.log('❌ Failed to fetch audiobooks: $e');
return [];
}
}
String getStreamUrl(String provider, String itemId, {String? uri, List<ProviderMapping>? providerMappings}) {
return _api?.getStreamUrl(provider, itemId, uri: uri, providerMappings: providerMappings) ?? '';
}
String? getImageUrl(MediaItem item, {int size = 256}) {
return _api?.getImageUrl(item, size: size);
}
/// Get best available podcast cover URL
/// Returns cached iTunes URL (800x800) if available, otherwise falls back to MA imageproxy
/// The cache is persisted to storage and loaded on app start for instant high-res display
String? getPodcastImageUrl(MediaItem podcast, {int size = 256}) {
// Return cached iTunes URL if available (persisted across app launches)
final cachedUrl = _podcastCoverCache[podcast.itemId];
if (cachedUrl != null) {
return cachedUrl;
}
// Fall back to MA imageproxy (will be replaced once iTunes fetch completes)
return _api?.getImageUrl(podcast, size: size);
}
/// Get artist image URL with fallback to external sources (Deezer, Fanart.tv)
/// Returns a Future since fallback requires async API calls
Future<String?> getArtistImageUrlWithFallback(Artist artist, {int size = 256}) async {
// Try Music Assistant first
final maUrl = _api?.getImageUrl(artist, size: size);
if (maUrl != null) {
return maUrl;
}
// Fall back to external sources (Deezer, Fanart.tv, etc.)
return MetadataService.getArtistImageUrl(artist.name);
}
// ============================================================================
// PLAYER AND QUEUE MANAGEMENT
// ============================================================================
Future<List<Player>> getPlayers() async {
return await _api?.getPlayers() ?? [];
}
Future<PlayerQueue?> getQueue(String playerId) async {
// If this player is a group child, fetch the leader's queue instead
// This ensures grouped players show the same queue as their leader
String effectivePlayerId = playerId;
final player = _availablePlayers.firstWhere(
(p) => p.playerId == playerId,
orElse: () => Player(
playerId: playerId,
name: '',
available: false,
powered: false,
state: 'idle',
),
);
if (player.isGroupChild && player.syncedTo != null) {
_logger.log('🔗 Player $playerId is grouped, fetching leader queue: ${player.syncedTo}');
effectivePlayerId = player.syncedTo!;
}
// Translate Sendspin ID to Cast UUID for queue fetch
// MA stores queues under the Cast UUID, not the Sendspin ID
if (effectivePlayerId.startsWith('cast-') && effectivePlayerId.length >= 13) {
// Sendspin ID format: cast-7df484e3 -> need Cast UUID starting with 7df484e3
final prefix = effectivePlayerId.substring(5); // Remove "cast-"
// Reverse lookup in the map
for (final entry in _castToSendspinIdMap.entries) {
if (entry.value == effectivePlayerId) {
_logger.log('🔗 Translated Sendspin ID $effectivePlayerId to Cast UUID ${entry.key} for queue fetch');
effectivePlayerId = entry.key;
break;
}
}
// If not in map, try to find in available players
if (effectivePlayerId.startsWith('cast-')) {
for (final p in _availablePlayers) {
if (p.provider == 'chromecast' && p.playerId.startsWith(prefix)) {
_logger.log('🔗 Found Cast UUID ${p.playerId} for Sendspin ID $effectivePlayerId via player lookup');
effectivePlayerId = p.playerId;
break;
}
}
}
}
final queue = await _api?.getQueue(effectivePlayerId);
// Persist queue to database for instant display on app resume
if (queue != null) {
_persistQueueToDatabase(playerId, queue);
}
return queue;
}
/// Get cached queue for instant display (before API refresh)
Future<PlayerQueue?> getCachedQueue(String playerId) async {
try {
if (!DatabaseService.instance.isInitialized) return null;
final cachedItems = await DatabaseService.instance.getCachedQueue(playerId);
if (cachedItems.isEmpty) return null;
final items = <QueueItem>[];
for (final cached in cachedItems) {
try {
final itemData = jsonDecode(cached.itemJson) as Map<String, dynamic>;
items.add(QueueItem.fromJson(itemData));
} catch (e) {
_logger.log('⚠️ Error parsing cached queue item: $e');
}
}
if (items.isEmpty) return null;
return PlayerQueue(
playerId: playerId,
items: items,
currentIndex: 0, // Will be updated from fresh data
);
} catch (e) {
_logger.log('⚠️ Error loading cached queue: $e');
return null;
}
}
/// Persist queue to database for app restart persistence
void _persistQueueToDatabase(String playerId, PlayerQueue queue) {
() async {
try {
if (!DatabaseService.instance.isInitialized) return;
final itemJsonList = queue.items.map((item) => jsonEncode(item.toJson())).toList();
await DatabaseService.instance.saveQueue(playerId, itemJsonList);
} catch (e) {
_logger.log('⚠️ Error persisting queue to database: $e');
}
}();
}
Future<void> playTrack(String playerId, Track track, {bool clearQueue = true}) async {
try {
await _api?.playTrack(playerId, track, clearQueue: clearQueue);
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Play track');
_error = errorInfo.userMessage;
ErrorHandler.logError('Play track', e);
notifyListeners();
rethrow;
}
}
Future<void> addTrackToQueue(String playerId, Track track) async {
try {
await _api?.addTrackToQueue(playerId, track);
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Add to queue');
_error = errorInfo.userMessage;
ErrorHandler.logError('Add to queue', e);
notifyListeners();
rethrow;
}
}
Future<void> addTracksToQueue(String playerId, List<Track> tracks, {int startIndex = 0}) async {
try {
await _api?.addTracksToQueue(playerId, tracks, startIndex: startIndex);
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Add tracks to queue');
_error = errorInfo.userMessage;
ErrorHandler.logError('Add tracks to queue', e);
notifyListeners();
rethrow;
}
}
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex, bool clearQueue = true}) async {
try {
await _api?.playTracks(playerId, tracks, startIndex: startIndex, clearQueue: clearQueue);
final trackIndex = startIndex ?? 0;
if (tracks.isNotEmpty && trackIndex < tracks.length) {
_currentTrack = tracks[trackIndex];
notifyListeners();
}
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Play tracks');
_error = errorInfo.userMessage;
ErrorHandler.logError('Play tracks', e);
notifyListeners();
rethrow;
}
}
Future<void> playRadio(String playerId, Track track) async {
try {
await _api?.playRadio(playerId, track);
} catch (e) {
_logger.log('Radio failed, falling back to single track: $e');
try {
await playTrack(playerId, track);
} catch (fallbackError) {
final errorInfo = ErrorHandler.handleError(fallbackError, context: 'Play radio fallback');
_error = errorInfo.userMessage;
ErrorHandler.logError('Play radio fallback', fallbackError);
notifyListeners();
rethrow;
}
}
}
Future<void> playArtistRadio(String playerId, Artist artist) async {
try {
await _api?.playArtistRadio(playerId, artist);
} catch (e) {
_logger.log('Artist radio failed, falling back to shuffled tracks: $e');
try {
final results = await searchWithCache(artist.name);
final tracks = (results['tracks'] ?? []).whereType<Track>().toList();
if (tracks.isEmpty) rethrow;
tracks.shuffle(Random());
await _api?.playTracks(playerId, tracks, startIndex: 0);
await _api?.toggleShuffle(playerId, true);
} catch (fallbackError) {
final errorInfo = ErrorHandler.handleError(fallbackError, context: 'Play artist radio fallback');
_error = errorInfo.userMessage;
ErrorHandler.logError('Play artist radio fallback', fallbackError);
notifyListeners();
rethrow;
}
}
}
Future<String?> getCurrentStreamUrl(String playerId) async {
return await _api?.getCurrentStreamUrl(playerId);
}
Future<void> pausePlayer(String playerId) async {
try {
// Get builtin player ID - this is cached so should be fast
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && _isBuiltinPlayer(playerId, builtinPlayerId) && _sendspinConnected) {
_logger.log('⏸️ Non-blocking local pause for builtin player');
// CRITICAL: Don't await these - they can block the UI thread
// Use unawaited to make them fire-and-forget, but log errors
unawaited((_pcmAudioPlayer?.pause() ?? Future.value()).catchError(
(e) => _logger.log('⚠️ PCM pause error (non-blocking): $e'),
));
// 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, but log errors
unawaited((_api?.pausePlayer(playerId) ?? Future.value()).catchError(
(e) => _logger.log('⚠️ MA pause command error (non-blocking): $e'),
));
} catch (e) {
ErrorHandler.logError('Pause player', e);
// 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) {
// Check if this is a "No playable item" error - means server queue is empty
final errorStr = e.toString();
if (errorStr.contains('No playable item')) {
_logger.log('⚠️ Server queue empty - attempting to restore from cached queue...');
// Try to restore queue from cached data
final restored = await _restoreQueueFromCache(playerId);
if (restored) {
_logger.log('✅ Queue restored from cache - playback started');
return; // Successfully restored and playing
}
_logger.log('❌ Could not restore queue from cache');
}
ErrorHandler.logError('Resume player', e);
rethrow;
}
}
/// Attempt to restore the queue from cached data and start playback
Future<bool> _restoreQueueFromCache(String playerId) async {
try {
final cachedQueue = await getCachedQueue(playerId);
if (cachedQueue == null || cachedQueue.items.isEmpty) {
_logger.log('⚠️ No cached queue to restore');
return false;
}
// Extract tracks from queue items
// playTracks needs either: providerMappings with available entries, OR provider+itemId
final tracks = cachedQueue.items
.map((item) => item.track)
.where((track) {
// Check for providerMappings with at least one available entry
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
return track.providerMappings!.any((m) => m.available);
}
// Fallback: provider + itemId can be used to construct URI
return track.provider.isNotEmpty && track.itemId.isNotEmpty;
})
.toList();
if (tracks.isEmpty) {
_logger.log('⚠️ Cached queue has no valid tracks');
return false;
}
_logger.log('🔄 Restoring ${tracks.length} tracks from cached queue');
// Re-queue all tracks and start playback
await playTracks(playerId, tracks, startIndex: 0);
return true;
} catch (e) {
_logger.log('❌ Error restoring queue from cache: $e');
return false;
}
}
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 && _isBuiltinPlayer(playerId, builtinPlayerId) && _sendspinConnected) {
_logger.log('⏭️ Non-blocking local stop for skip on builtin player');
// Stop current audio immediately - fire and forget, but log errors
unawaited((_pcmAudioPlayer?.pause() ?? Future.value()).catchError(
(e) => _logger.log('⚠️ PCM pause error on next (non-blocking): $e'),
));
// Don't stop just_audio - not used for Sendspin audio output
}
await _api?.nextTrack(playerId);
} catch (e) {
ErrorHandler.logError('Next track', e);
rethrow;
}
}
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 && _isBuiltinPlayer(playerId, builtinPlayerId) && _sendspinConnected) {
_logger.log('⏮️ Non-blocking local stop for previous on builtin player');
// Stop current audio immediately - fire and forget, but log errors
unawaited((_pcmAudioPlayer?.pause() ?? Future.value()).catchError(
(e) => _logger.log('⚠️ PCM pause error on previous (non-blocking): $e'),
));
// Don't stop just_audio - not used for Sendspin audio output
}
await _api?.previousTrack(playerId);
} catch (e) {
ErrorHandler.logError('Previous track', e);
rethrow;
}
}
Future<void> stopPlayer(String playerId) async {
try {
await _api?.stopPlayer(playerId);
} catch (e) {
ErrorHandler.logError('Stop player', e);
rethrow;
}
}
// ============================================================================
// SLEEP TIMER
// ============================================================================
/// Set sleep timer. Pass null to turn off, -1 for end of track, or minutes.
void setSleepTimer(int? minutes) {
// Cancel any existing timer
_cancelSleepTimerInternal();
_sleepTimerMinutes = minutes;
if (minutes == null) {
// Timer off
_logger.log('😴 Sleep timer: OFF');
notifyListeners();
return;
}
if (minutes == -1) {
// End of track - handled in player state updates
_sleepTimerEndTime = null; // No fixed end time
_logger.log('😴 Sleep timer: End of track');
notifyListeners();
return;
}
// Set timer for specified minutes
_sleepTimerEndTime = DateTime.now().add(Duration(minutes: minutes));
_logger.log('😴 Sleep timer: $minutes minutes (until $_sleepTimerEndTime)');
// Create the actual timer
_sleepTimer = Timer(Duration(minutes: minutes), _onSleepTimerExpired);
// Start display update timer (every second for countdown)
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
notifyListeners(); // Update UI with remaining time
});
notifyListeners();
}
/// Cancel sleep timer
void cancelSleepTimer() {
_cancelSleepTimerInternal();
_sleepTimerMinutes = null;
_sleepTimerEndTime = null;
_logger.log('😴 Sleep timer: Cancelled');
notifyListeners();
}
void _cancelSleepTimerInternal() {
_sleepTimer?.cancel();
_sleepTimer = null;
_sleepTimerDisplayTimer?.cancel();
_sleepTimerDisplayTimer = null;
}
void _onSleepTimerExpired() {
_logger.log('😴 Sleep timer expired - pausing playback');
_cancelSleepTimerInternal();
_sleepTimerMinutes = null;
_sleepTimerEndTime = null;
// Pause the current player
final playerId = _selectedPlayer?.playerId;
if (playerId != null) {
pausePlayer(playerId);
}
notifyListeners();
}
/// Called when track ends - checks if "end of track" sleep timer is active
void checkEndOfTrackSleepTimer() {
if (_sleepTimerMinutes == -1) {
_logger.log('😴 End of track sleep timer triggered');
_sleepTimerMinutes = null;
// Pause the current player
final playerId = _selectedPlayer?.playerId;
if (playerId != null) {
pausePlayer(playerId);
}
notifyListeners();
}
}
/// Sync a player to the currently selected player (temporary group)
/// The target player will play the same audio as the selected player
Future<void> syncPlayerToSelected(String targetPlayerId) async {
try {
final leaderPlayer = _selectedPlayer;
_logger.log('🔗 syncPlayerToSelected: target=$targetPlayerId, leader=${leaderPlayer?.playerId}');
if (leaderPlayer == null) {
_logger.log('❌ Cannot sync: no player selected');
return;
}
if (targetPlayerId == leaderPlayer.playerId) {
_logger.log('❌ Cannot sync player to itself');
return;
}
if (_api == null) {
_logger.log('❌ Cannot sync: API is null');
return;
}
// Translate Cast player IDs to Sendspin IDs for group commands
// Cast players don't support group commands - only their Sendspin counterparts do
// We need to translate BOTH target and leader IDs
//
// The mapping comes from _castToSendspinIdMap which contains:
// 1. Currently discovered mappings (from available Sendspin players)
// 2. Persisted mappings from database (survives when Sendspin player is unavailable)
String translateToSendspinId(String playerId, Player? player) {
if (_castToSendspinIdMap.containsKey(playerId)) {
_logger.log('🔗 Found Sendspin mapping: $playerId -> ${_castToSendspinIdMap[playerId]}');
return _castToSendspinIdMap[playerId]!;
}
// No Sendspin counterpart known - use original ID
return playerId;
}
// Find target player to check its provider
final targetPlayer = _availablePlayers.where((p) => p.playerId == targetPlayerId).firstOrNull;
// Translate BOTH target AND leader to Sendspin IDs
// Only Sendspin players support the SET_MEMBERS feature required for grouping
// Cast players will return "Player X does not support group commands"
final actualTargetId = translateToSendspinId(targetPlayerId, targetPlayer);
String actualLeaderId = leaderPlayer.playerId;
// If leader is a Cast player, translate to its Sendspin counterpart
if (_castToSendspinIdMap.containsKey(leaderPlayer.playerId)) {
actualLeaderId = _castToSendspinIdMap[leaderPlayer.playerId]!;
_logger.log('🔗 Translated leader Cast ID to Sendspin ID: ${leaderPlayer.playerId} -> $actualLeaderId');
}
if (actualTargetId != targetPlayerId) {
_logger.log('🔗 Translated target Cast ID to Sendspin ID: $targetPlayerId -> $actualTargetId');
}
_logger.log('🔗 Calling API syncPlayerToLeader($actualTargetId, $actualLeaderId)');
await _api!.syncPlayerToLeader(actualTargetId, actualLeaderId);
_logger.log('✅ API sync call completed');
// Refresh players to get updated group state
_logger.log('🔄 Refreshing players after sync...');
await refreshPlayers();
_logger.log('✅ Players refreshed');
} catch (e) {
_logger.log('❌ syncPlayerToSelected error: $e');
ErrorHandler.logError('Sync player to selected', e);
rethrow;
}
}
/// Remove a player from its sync group
Future<void> unsyncPlayer(String playerId) async {
try {
_logger.log('🔓 Unsyncing player: $playerId');
// Find the player to check if it's a child
final player = _availablePlayers.firstWhere(
(p) => p.playerId == playerId,
orElse: () => Player(
playerId: playerId,
name: '',
available: false,
powered: false,
state: 'idle',
),
);
// Determine the effective player ID to unsync
// For children, we unsync the child directly (not the leader!)
// Unsyncing the leader would dissolve the entire group
String effectivePlayerId = playerId;
String? leaderId = player.syncedTo;
if (leaderId != null) {
_logger.log('🔓 Player is a child synced to $leaderId, unsyncing child directly');
}
// Try to unsync the player directly
try {
await _api?.unsyncPlayer(effectivePlayerId);
} catch (e) {
// Some players (like pure Sendspin CLI players) don't support set_members
// In this case, we need to unsync via the leader instead
if (e.toString().contains('set_members') && leaderId != null) {
_logger.log('🔓 Child unsync failed (set_members not supported), unsyncing via leader');
// Try to find the Sendspin ID for the leader if it's a Cast UUID
String leaderToUnsync = leaderId;
if (_castToSendspinIdMap.containsKey(leaderId)) {
leaderToUnsync = _castToSendspinIdMap[leaderId]!;
_logger.log('🔓 Translated leader to Sendspin ID: $leaderToUnsync');
}
await _api?.unsyncPlayer(leaderToUnsync);
_logger.log('⚠️ Dissolved entire group (pure Sendspin player limitation)');
} else {
rethrow;
}
}
// Refresh players to get updated group state
await refreshPlayers();
} catch (e) {
ErrorHandler.logError('Unsync player', e);
rethrow;
}
}
/// Toggle sync state: if player is synced, unsync it; otherwise sync to selected
/// For Cast/Sendspin players that are powered off, powers on first then syncs
Future<void> togglePlayerSync(String playerId) async {
_logger.log('🔗 togglePlayerSync called for: $playerId');
try {
final player = _availablePlayers.firstWhere(
(p) => p.playerId == playerId,
orElse: () => throw Exception('Player not found'),
);
_logger.log('🔗 Player found: ${player.name}, isGrouped: ${player.isGrouped}');
_logger.log('🔗 groupMembers: ${player.groupMembers}, syncedTo: ${player.syncedTo}');
_logger.log('🔗 powered: ${player.powered}, available: ${player.available}');
if (player.isGrouped) {
_logger.log('🔓 Player is grouped, unsyncing...');
await unsyncPlayer(playerId);
} else {
// Check if this is a Cast player with a Sendspin counterpart that's powered off
final hasSendspinCounterpart = _castToSendspinIdMap.containsKey(playerId);
if (hasSendspinCounterpart && !player.powered) {
_logger.log('🔌 Cast/Sendspin player is off, powering on first...');
// Power on the Cast player
await _api?.setPower(playerId, true);
// Wait for the player to power on and Sendspin to become ready
_logger.log('🔗 Waiting for player to power on...');
// Poll for up to 10 seconds for the player to be powered and available
const maxAttempts = 20;
const pollInterval = Duration(milliseconds: 500);
for (var attempt = 0; attempt < maxAttempts; attempt++) {
await Future.delayed(pollInterval);
await refreshPlayers();
// Check if the Cast player is now powered on
final updatedPlayer = _availablePlayers.where(
(p) => p.playerId == playerId
).firstOrNull;
if (updatedPlayer != null && updatedPlayer.powered && updatedPlayer.available) {
_logger.log('✅ Player powered on, syncing...');
await syncPlayerToSelected(playerId);
return;
}
_logger.log('⏳ Attempt ${attempt + 1}/$maxAttempts - waiting for power on...');
}
_logger.log('⚠️ Timeout waiting for power on, attempting sync anyway...');
await syncPlayerToSelected(playerId);
} else {
_logger.log('🔗 Player not grouped, syncing to selected...');
await syncPlayerToSelected(playerId);
}
}
} catch (e) {
_logger.log('❌ togglePlayerSync error: $e');
rethrow;
}
}
Future<void> togglePower(String playerId) async {
try {
_logger.log('🔋 togglePower called for playerId: $playerId');
final localPlayerId = await SettingsService.getBuiltinPlayerId();
final isLocalPlayer = localPlayerId != null && playerId == localPlayerId;
_logger.log('🔋 Is local builtin player: $isLocalPlayer');
if (isLocalPlayer) {
_logger.log('🔋 Handling power toggle LOCALLY for builtin player');
_isLocalPlayerPowered = !_isLocalPlayerPowered;
_logger.log('🔋 Local player power set to: $_isLocalPlayerPowered');
if (!_isLocalPlayerPowered) {
_logger.log('🔋 Stopping playback because powered off');
await _localPlayer.stop();
}
await _reportLocalPlayerState();
await refreshPlayers();
} else {
_logger.log('🔋 Sending power command to server for regular player');
final player = _availablePlayers.firstWhere(
(p) => p.playerId == playerId,
orElse: () => _selectedPlayer != null && _selectedPlayer!.playerId == playerId
? _selectedPlayer!
: throw Exception("Player not found"),
);
_logger.log('🔋 Current power state: ${player.powered}, will set to: ${!player.powered}');
await _api?.setPower(playerId, !player.powered);
_logger.log('🔋 setPower command sent successfully');
await refreshPlayers();
}
} catch (e) {
_logger.log('🔋 ERROR in togglePower: $e');
ErrorHandler.logError('Toggle power', e);
}
}
Future<void> setVolume(String playerId, int volumeLevel) async {
try {
// Optimistically update the selected player's volume BEFORE any async calls.
// This prevents slider snapback: without this, clearing _pendingVolume on drag
// end causes the slider to read the stale player.volume until player_updated arrives.
if (_selectedPlayer != null && _selectedPlayer!.playerId == playerId) {
_selectedPlayer = _selectedPlayer!.copyWith(volumeLevel: volumeLevel);
notifyListeners();
}
// Cache pending volume for ALL players to protect optimistic updates
// from being overwritten by stale API data in _updatePlayerState()/refreshPlayers().
_pendingVolumes[playerId] = volumeLevel;
_pendingVolumeTimestamps[playerId] = DateTime.now().millisecondsSinceEpoch;
// Also cache for group players in GroupVolumeManager
// (MA API returns null for group player volume_level)
final player = _availablePlayers.where((p) => p.playerId == playerId).firstOrNull;
if (player != null && _groupVolumeManager.isGroupPlayer(player)) {
_groupVolumeManager.onVolumeSet(playerId, volumeLevel);
}
// Proportional volume propagation for in-app sync group leaders.
// When a user creates a group via long-press (players/cmd/group), the leader
// retains its own non-null volumeLevel and isGroupPlayer() returns false.
// We detect this via isGroupLeader and propagate volume changes to all
// members proportionally, preserving relative volume balance.
if (player != null &&
player.isGroupLeader &&
!_groupVolumeManager.isGroupPlayer(player)) {
final currentVol = player.volumeLevel;
final now = DateTime.now().millisecondsSinceEpoch;
for (final memberId in player.groupMembers!) {
if (memberId == playerId) continue; // Skip the leader itself
final idx = _availablePlayers.indexWhere((p) => p.playerId == memberId);
if (idx == -1) continue;
final member = _availablePlayers[idx];
if (member.volumeLevel == null) continue;
final int memberNewVol;
if (currentVol != null && currentVol > 0) {
// Ratio-based: maintain relative balance between members
memberNewVol = (member.volumeLevel! * volumeLevel / currentVol)
.round()
.clamp(0, 100);
} else {
// Can't ratio from 0 — set all to the new absolute value
memberNewVol = volumeLevel;
}
// Protect from snap-back and update optimistically
_pendingVolumes[memberId] = memberNewVol;
_pendingVolumeTimestamps[memberId] = now;
_availablePlayers[idx] = member.copyWith(volumeLevel: memberNewVol);
// Fire-and-forget — don't await, send in parallel
_api?.setVolume(memberId, memberNewVol);
}
}
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && _isBuiltinPlayer(playerId, builtinPlayerId)) {
_localPlayerVolume = volumeLevel;
FlutterVolumeController.setVolume(volumeLevel / 100.0);
}
await _api?.setVolume(playerId, volumeLevel);
} catch (e) {
ErrorHandler.logError('Set volume', e);
rethrow;
}
}
Future<void> setMute(String playerId, bool muted) async {
try {
await _api?.setMute(playerId, muted);
await refreshPlayers();
} catch (e) {
ErrorHandler.logError('Set mute', e);
rethrow;
}
}
Future<void> seek(String playerId, int position) async {
try {
// Immediately update position tracker for responsive UI
_positionTracker.onSeek(position.toDouble());
// Immediately update notification position to stay in sync with app
// Use forceUpdate since seek can happen while paused
_updateNotificationPosition(forceUpdate: true);
await _api?.seek(playerId, position);
} catch (e) {
ErrorHandler.logError('Seek', e);
rethrow;
}
}
/// Seek relative to current position (e.g., +30 or -30 seconds)
Future<void> seekRelative(String playerId, int deltaSeconds) async {
try {
final currentPosition = _positionTracker.currentPosition.inSeconds;
final totalDuration = _currentTrack?.duration?.inSeconds ?? 0;
final newPosition = (currentPosition + deltaSeconds).clamp(0, totalDuration);
await seek(playerId, newPosition);
} catch (e) {
ErrorHandler.logError('Seek relative', e);
rethrow;
}
}
// ============================================================================
// AUDIOBOOK CONTEXT
// ============================================================================
/// Set the currently playing audiobook context (with chapters)
void setCurrentAudiobook(Audiobook audiobook) {
_currentAudiobook = audiobook;
_logger.log('📚 Set current audiobook: ${audiobook.name}, chapters: ${audiobook.chapters?.length ?? 0}');
notifyListeners();
}
/// Clear the audiobook context
void clearCurrentAudiobook() {
if (_currentAudiobook != null) {
_logger.log('📚 Cleared audiobook context');
_currentAudiobook = null;
notifyListeners();
}
}
/// Get the current chapter based on playback position
Chapter? getCurrentChapter() {
if (_currentAudiobook == null || _currentAudiobook!.chapters == null) return null;
final chapters = _currentAudiobook!.chapters!;
if (chapters.isEmpty) return null;
final currentPositionMs = _positionTracker.currentPosition.inMilliseconds;
// Find the chapter that contains the current position
for (int i = chapters.length - 1; i >= 0; i--) {
if (currentPositionMs >= chapters[i].positionMs) {
return chapters[i];
}
}
return chapters.first;
}
/// Get the index of the current chapter
int getCurrentChapterIndex() {
if (_currentAudiobook == null || _currentAudiobook!.chapters == null) return -1;
final chapters = _currentAudiobook!.chapters!;
if (chapters.isEmpty) return -1;
final currentPositionMs = _positionTracker.currentPosition.inMilliseconds;
for (int i = chapters.length - 1; i >= 0; i--) {
if (currentPositionMs >= chapters[i].positionMs) {
return i;
}
}
return 0;
}
/// Seek to the next chapter
Future<void> seekToNextChapter(String playerId) async {
if (_currentAudiobook == null || _currentAudiobook!.chapters == null) return;
final chapters = _currentAudiobook!.chapters!;
if (chapters.isEmpty) return;
final currentIndex = getCurrentChapterIndex();
if (currentIndex < chapters.length - 1) {
final nextChapter = chapters[currentIndex + 1];
final positionSeconds = nextChapter.positionMs ~/ 1000;
await seek(playerId, positionSeconds);
_logger.log('📚 Jumped to next chapter: ${nextChapter.title}');
}
}
/// Seek to the previous chapter (or start of current chapter if > 3 seconds in)
Future<void> seekToPreviousChapter(String playerId) async {
if (_currentAudiobook == null || _currentAudiobook!.chapters == null) return;
final chapters = _currentAudiobook!.chapters!;
if (chapters.isEmpty) return;
final currentIndex = getCurrentChapterIndex();
if (currentIndex < 0) return;
final currentChapter = chapters[currentIndex];
final currentPositionMs = _positionTracker.currentPosition.inMilliseconds;
final chapterProgressMs = currentPositionMs - currentChapter.positionMs;
// If more than 3 seconds into current chapter, go to start of current chapter
// Otherwise, go to previous chapter
if (chapterProgressMs > 3000 || currentIndex == 0) {
final positionSeconds = currentChapter.positionMs ~/ 1000;
await seek(playerId, positionSeconds);
_logger.log('📚 Jumped to start of chapter: ${currentChapter.title}');
} else {
final previousChapter = chapters[currentIndex - 1];
final positionSeconds = previousChapter.positionMs ~/ 1000;
await seek(playerId, positionSeconds);
_logger.log('📚 Jumped to previous chapter: ${previousChapter.title}');
}
}
Future<void> toggleShuffle(String queueId, bool shuffleEnabled) async {
_logger.log('🔀 toggleShuffle called: queueId=$queueId, shuffleEnabled=$shuffleEnabled');
try {
await _api?.toggleShuffle(queueId, shuffleEnabled);
_logger.log('🔀 toggleShuffle: API call completed');
} catch (e) {
_logger.log('🔀 toggleShuffle ERROR: $e');
ErrorHandler.logError('Toggle shuffle', e);
rethrow;
}
}
Future<void> setRepeatMode(String queueId, String mode) async {
try {
await _api?.setRepeatMode(queueId, mode);
} catch (e) {
ErrorHandler.logError('Set repeat mode', e);
rethrow;
}
}
Future<void> cycleRepeatMode(String queueId, String? currentMode) async {
String nextMode;
switch (currentMode) {
case 'off':
case null:
nextMode = 'all';
break;
case 'all':
nextMode = 'one';
break;
case 'one':
nextMode = 'off';
break;
default:
nextMode = 'off';
}
await setRepeatMode(queueId, nextMode);
}
@override
void dispose() {
_playerStateTimer?.cancel();
_notificationPositionTimer?.cancel();
_localPlayerStateReportTimer?.cancel();
_sleepTimer?.cancel();
_sleepTimerDisplayTimer?.cancel();
_idleServiceTimer?.cancel();
_connectionStateSubscription?.cancel();
_localPlayerEventSubscription?.cancel();
_playerUpdatedEventSubscription?.cancel();
_playerAddedEventSubscription?.cancel();
_mediaItemAddedEventSubscription?.cancel();
_mediaItemDeletedEventSubscription?.cancel();
_positionTracker.dispose();
_pcmAudioPlayer?.dispose();
_sendspinService?.dispose();
_api?.dispose();
super.dispose();
}
// ============================================================================
// UTILITY METHODS
// ============================================================================
/// Check if a hostname is a local/private network address
bool _isLocalNetworkHost(String host) {
return host.startsWith('192.168.') ||
host.startsWith('10.') ||
host.startsWith('172.16.') ||
host.startsWith('172.17.') ||
host.startsWith('172.18.') ||
host.startsWith('172.19.') ||
host.startsWith('172.2') ||
host.startsWith('172.30.') ||
host.startsWith('172.31.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local') ||
host.endsWith('.ts.net'); // Tailscale - treat as local since it's a VPN
}
}