Compare commits
67
Commits
@@ -153,12 +153,27 @@ class CachedQueue extends Table {
|
||||
IntColumn get position => integer()();
|
||||
}
|
||||
|
||||
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue])
|
||||
/// Cached home row data for instant display on startup
|
||||
class HomeRowCache extends Table {
|
||||
/// Row type: 'recent_albums', 'discover_artists', 'discover_albums'
|
||||
TextColumn get rowType => text()();
|
||||
|
||||
/// Serialized list of items as JSON array
|
||||
TextColumn get itemsJson => text()();
|
||||
|
||||
/// When this was last updated
|
||||
DateTimeColumn get lastUpdated => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {rowType};
|
||||
}
|
||||
|
||||
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue, HomeRowCache])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 2;
|
||||
int get schemaVersion => 3;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@@ -173,6 +188,10 @@ class AppDatabase extends _$AppDatabase {
|
||||
await m.createTable(cachedPlayers);
|
||||
await m.createTable(cachedQueue);
|
||||
}
|
||||
// Migration from v2 to v3: Add home row cache table
|
||||
if (from < 3) {
|
||||
await m.createTable(homeRowCache);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -524,4 +543,30 @@ class AppDatabase extends _$AppDatabase {
|
||||
Future<void> clearAllCachedQueues() async {
|
||||
await delete(cachedQueue).go();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Home Row Cache Operations
|
||||
// ============================================
|
||||
|
||||
/// Save home row data
|
||||
Future<void> saveHomeRowCache(String rowType, String itemsJson) async {
|
||||
await into(homeRowCache).insertOnConflictUpdate(
|
||||
HomeRowCacheCompanion(
|
||||
rowType: Value(rowType),
|
||||
itemsJson: Value(itemsJson),
|
||||
lastUpdated: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get cached home row data
|
||||
Future<HomeRowCacheData?> getHomeRowCache(String rowType) async {
|
||||
return (select(homeRowCache)..where((h) => h.rowType.equals(rowType)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Clear all home row cache
|
||||
Future<void> clearHomeRowCache() async {
|
||||
await delete(homeRowCache).go();
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -126,6 +126,7 @@
|
||||
|
||||
"playing": "Spiele {name}",
|
||||
"playingTrack": "Spiele Song",
|
||||
"nowPlaying": "Wird gespielt",
|
||||
|
||||
"markedAsFinished": "{name} als beendet markiert",
|
||||
"failedToMarkFinished": "Konnte nicht als beendet markieren: {error}",
|
||||
@@ -280,5 +281,10 @@
|
||||
"playerStateIdle": "Inaktiv",
|
||||
"playerSelected": "Ausgewählt",
|
||||
"actionQueuedForSync": "Wird synchronisiert wenn online",
|
||||
"pendingOfflineActions": "{count} ausstehende Synchronisierung"
|
||||
"pendingOfflineActions": "{count} ausstehende Synchronisierung",
|
||||
"hintsAndTips": "Tipps & Hinweise",
|
||||
"showHints": "Hinweise anzeigen",
|
||||
"showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen",
|
||||
"pullToSelectPlayers": "Ziehen um Geräte auszuwählen",
|
||||
"holdToSync": "Gerät gedrückt halten zum Synchronisieren"
|
||||
}
|
||||
|
||||
+56
-1
@@ -635,6 +635,11 @@
|
||||
"description": "Status when playing track"
|
||||
},
|
||||
|
||||
"nowPlaying": "Now playing",
|
||||
"@nowPlaying": {
|
||||
"description": "Label shown when device selector is open to indicate current player"
|
||||
},
|
||||
|
||||
"markedAsFinished": "{name} marked as finished",
|
||||
"@markedAsFinished": {
|
||||
"description": "Audiobook marked finished",
|
||||
@@ -1073,7 +1078,7 @@
|
||||
"description": "Hint for Last.fm API key field"
|
||||
},
|
||||
|
||||
"swipeToSwitchDevice": "Swipe to switch device",
|
||||
"swipeToSwitchDevice": "Swipe to change player",
|
||||
"@swipeToSwitchDevice": {
|
||||
"description": "Hint for device switching gesture"
|
||||
},
|
||||
@@ -1206,5 +1211,55 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"hintsAndTips": "Hints & Tips",
|
||||
"@hintsAndTips": {
|
||||
"description": "Settings section title for hints"
|
||||
},
|
||||
|
||||
"showHints": "Show Hints",
|
||||
"@showHints": {
|
||||
"description": "Toggle label for showing hints"
|
||||
},
|
||||
|
||||
"showHintsDescription": "Display helpful tips for discovering features",
|
||||
"@showHintsDescription": {
|
||||
"description": "Description for show hints toggle"
|
||||
},
|
||||
|
||||
"pullToSelectPlayers": "Pull to select players",
|
||||
"@pullToSelectPlayers": {
|
||||
"description": "Hint shown when mini player bounces"
|
||||
},
|
||||
|
||||
"holdToSync": "Tap and hold a player to sync",
|
||||
"@holdToSync": {
|
||||
"description": "Hint for long-press to sync players"
|
||||
},
|
||||
|
||||
"selectPlayerHint": "Choose a player, or dismiss by swiping down",
|
||||
"@selectPlayerHint": {
|
||||
"description": "First-time hint for selecting a player in device selector"
|
||||
},
|
||||
|
||||
"welcomeToEnsemble": "Welcome to Ensemble",
|
||||
"@welcomeToEnsemble": {
|
||||
"description": "Welcome message title for onboarding"
|
||||
},
|
||||
|
||||
"welcomeMessage": "By default your phone is the selected player.\nPull the mini player down to select another player.",
|
||||
"@welcomeMessage": {
|
||||
"description": "Welcome message body explaining how to select players"
|
||||
},
|
||||
|
||||
"skip": "Skip",
|
||||
"@skip": {
|
||||
"description": "Button to skip onboarding or hints"
|
||||
},
|
||||
|
||||
"dismissPlayerHint": "Swipe down, tap outside, or press back to return",
|
||||
"@dismissPlayerHint": {
|
||||
"description": "Hint explaining how to dismiss the player selector"
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -225,6 +225,7 @@
|
||||
}
|
||||
},
|
||||
"playingTrack": "Reproduciendo canción",
|
||||
"nowPlaying": "Reproduciendo ahora",
|
||||
"markedAsFinished": "{name} marcado como terminado",
|
||||
"@markedAsFinished": {
|
||||
"placeholders": {
|
||||
@@ -433,7 +434,7 @@
|
||||
"metadataApisDescription": "Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.",
|
||||
"lastFmApiKey": "Clave API de Last.fm",
|
||||
"lastFmApiKeyHint": "Obtén una clave gratuita en last.fm/api",
|
||||
"swipeToSwitchDevice": "Desliza para cambiar de dispositivo",
|
||||
"swipeToSwitchDevice": "Desliza para cambiar dispositivo",
|
||||
"chapterNumber": "Capítulo {number}",
|
||||
"@chapterNumber": {
|
||||
"placeholders": {
|
||||
@@ -476,5 +477,10 @@
|
||||
"playerStateIdle": "Inactivo",
|
||||
"playerSelected": "Seleccionado",
|
||||
"actionQueuedForSync": "Se sincronizará cuando esté en línea",
|
||||
"pendingOfflineActions": "{count} sincronización pendiente"
|
||||
"pendingOfflineActions": "{count} sincronización pendiente",
|
||||
"hintsAndTips": "Consejos y Sugerencias",
|
||||
"showHints": "Mostrar Sugerencias",
|
||||
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
|
||||
"pullToSelectPlayers": "Desliza para seleccionar dispositivos",
|
||||
"holdToSync": "Mantén presionado un dispositivo para sincronizar"
|
||||
}
|
||||
|
||||
+54
-5
@@ -348,7 +348,16 @@ class _SystemUIWrapperState extends State<SystemUIWrapper> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Startup widget that checks if user is logged in and auto-connects
|
||||
/// Startup widget that checks if user is logged in and auto-connects.
|
||||
///
|
||||
/// CRITICAL: This widget is the single source of truth for the startup transition.
|
||||
/// It loads settings FIRST (before connection), then waits for the appropriate
|
||||
/// conditions before showing HomeScreen. This eliminates the race condition between
|
||||
/// AppStartup and GlobalPlayerOverlay that caused the home screen flash.
|
||||
///
|
||||
/// For first-time users: Holds dark screen until connected + player selected,
|
||||
/// so the welcome overlay can appear seamlessly.
|
||||
/// For returning users: Transitions as soon as connection is established.
|
||||
class AppStartup extends StatefulWidget {
|
||||
const AppStartup({super.key});
|
||||
|
||||
@@ -361,9 +370,32 @@ class _AppStartupState extends State<AppStartup> {
|
||||
String? _savedServerUrl;
|
||||
bool _connectionAttempted = false;
|
||||
|
||||
// Welcome system state - loaded FIRST before connection check
|
||||
bool _settingsLoaded = false;
|
||||
bool _hasCompletedOnboarding = true; // Default to true (no welcome needed)
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettingsFirst();
|
||||
}
|
||||
|
||||
/// Load settings BEFORE checking connection.
|
||||
/// This ensures we know if this is a first-time user before transitioning.
|
||||
Future<void> _loadSettingsFirst() async {
|
||||
// Load onboarding state first - this is critical for determining
|
||||
// when to transition to HomeScreen
|
||||
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_settingsLoaded = true;
|
||||
});
|
||||
|
||||
_logger.log('🚀 AppStartup: Settings loaded, hasCompletedOnboarding=$_hasCompletedOnboarding');
|
||||
|
||||
// Now check connection
|
||||
_checkAndConnect();
|
||||
}
|
||||
|
||||
@@ -388,14 +420,30 @@ class _AppStartupState extends State<AppStartup> {
|
||||
// Just wait for it to complete or timeout
|
||||
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
|
||||
|
||||
// For first-time users, we need to wait for both connection AND player selection
|
||||
// so the welcome overlay can appear immediately without home screen flash.
|
||||
// For returning users, just wait for connection.
|
||||
final needsWelcome = !_hasCompletedOnboarding;
|
||||
|
||||
// Give the provider time to connect (it restores credentials and connects in _initialize)
|
||||
// Check connection state periodically
|
||||
for (var i = 0; i < 20; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 250));
|
||||
if (provider.isConnected) {
|
||||
_logger.log('🚀 AppStartup: Connection established');
|
||||
break;
|
||||
|
||||
if (needsWelcome) {
|
||||
// First-time user: wait for connection + player selection
|
||||
if (provider.isConnected && provider.selectedPlayer != null) {
|
||||
_logger.log('🚀 AppStartup: First-time user ready (connected + player selected)');
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Returning user: just wait for connection
|
||||
if (provider.isConnected) {
|
||||
_logger.log('🚀 AppStartup: Connection established');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (provider.connectionState == MAConnectionState.error) {
|
||||
_logger.log('🚀 AppStartup: Connection failed with error');
|
||||
break;
|
||||
@@ -422,7 +470,8 @@ class _AppStartupState extends State<AppStartup> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Show loading while checking settings or connecting
|
||||
if (!_connectionAttempted || _isConnecting) {
|
||||
// CRITICAL: Don't transition until settings are loaded - this prevents the race
|
||||
if (!_settingsLoaded || !_connectionAttempted || _isConnecting) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
body: Center(
|
||||
|
||||
@@ -12,6 +12,8 @@ class Player {
|
||||
final bool? volumeMuted;
|
||||
final double? elapsedTime; // Seconds elapsed in current track
|
||||
final double? elapsedTimeLastUpdated; // Unix timestamp when elapsed_time was last updated
|
||||
final List<String>? groupMembers; // List of player IDs in sync group (includes self)
|
||||
final String? syncedTo; // Player ID this player is synced to (null if leader or not synced)
|
||||
|
||||
Player({
|
||||
required this.playerId,
|
||||
@@ -25,6 +27,8 @@ class Player {
|
||||
this.volumeMuted,
|
||||
this.elapsedTime,
|
||||
this.elapsedTimeLastUpdated,
|
||||
this.groupMembers,
|
||||
this.syncedTo,
|
||||
});
|
||||
|
||||
/// Create a copy of this Player with some fields replaced
|
||||
@@ -40,6 +44,8 @@ class Player {
|
||||
bool? volumeMuted,
|
||||
double? elapsedTime,
|
||||
double? elapsedTimeLastUpdated,
|
||||
List<String>? groupMembers,
|
||||
String? syncedTo,
|
||||
}) {
|
||||
return Player(
|
||||
playerId: playerId ?? this.playerId,
|
||||
@@ -53,6 +59,8 @@ class Player {
|
||||
volumeMuted: volumeMuted ?? this.volumeMuted,
|
||||
elapsedTime: elapsedTime ?? this.elapsedTime,
|
||||
elapsedTimeLastUpdated: elapsedTimeLastUpdated ?? this.elapsedTimeLastUpdated,
|
||||
groupMembers: groupMembers ?? this.groupMembers,
|
||||
syncedTo: syncedTo ?? this.syncedTo,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,6 +69,12 @@ class Player {
|
||||
bool get isMuted => volumeMuted ?? false;
|
||||
int get volume => volumeLevel ?? 0;
|
||||
|
||||
// Group properties
|
||||
// A player is grouped if it's a leader with members OR a child synced to another
|
||||
bool get isGrouped => (groupMembers != null && groupMembers!.length > 1) || syncedTo != null;
|
||||
bool get isGroupLeader => groupMembers != null && groupMembers!.length > 1 && syncedTo == null;
|
||||
bool get isGroupChild => syncedTo != null;
|
||||
|
||||
// Track when this Player object was created (for local interpolation fallback)
|
||||
static final Map<String, double> _playerCreationTimes = {};
|
||||
|
||||
@@ -164,6 +178,13 @@ class Player {
|
||||
elapsedTime ??= topLevelElapsedTime;
|
||||
elapsedTimeLastUpdated ??= topLevelLastUpdated;
|
||||
|
||||
// Parse group members - MA returns this as a list of player IDs
|
||||
final groupMembersList = json['group_members'] as List<dynamic>?;
|
||||
final groupMembers = groupMembersList?.map((e) => e.toString()).toList();
|
||||
|
||||
// Parse synced_to - the player ID this player is synced to
|
||||
final syncedTo = json['synced_to'] as String?;
|
||||
|
||||
return Player(
|
||||
playerId: json['player_id'] as String,
|
||||
name: json['name'] as String,
|
||||
@@ -176,6 +197,8 @@ class Player {
|
||||
volumeMuted: json['volume_muted'] as bool?,
|
||||
elapsedTime: elapsedTime,
|
||||
elapsedTimeLastUpdated: elapsedTimeLastUpdated,
|
||||
groupMembers: groupMembers,
|
||||
syncedTo: syncedTo,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -192,6 +215,8 @@ class Player {
|
||||
if (volumeMuted != null) 'volume_muted': volumeMuted,
|
||||
if (elapsedTime != null) 'elapsed_time': elapsedTime,
|
||||
if (elapsedTimeLastUpdated != null) 'elapsed_time_last_updated': elapsedTimeLastUpdated,
|
||||
if (groupMembers != null) 'group_members': groupMembers,
|
||||
if (syncedTo != null) 'synced_to': syncedTo,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,11 +51,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
List<Artist> _artists = [];
|
||||
List<Album> _albums = [];
|
||||
List<Track> _tracks = [];
|
||||
List<Track> _cachedFavoriteTracks = []; // Cached for instant display before full library loads
|
||||
bool _isLoading = false;
|
||||
|
||||
// Player state
|
||||
Player? _selectedPlayer;
|
||||
List<Player> _availablePlayers = [];
|
||||
Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping
|
||||
Track? _currentTrack;
|
||||
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
|
||||
Timer? _playerStateTimer;
|
||||
@@ -171,11 +173,30 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Get cached track for a player (used for smooth swipe transitions)
|
||||
Track? getCachedTrackForPlayer(String playerId) => _cacheService.getCachedTrackForPlayer(playerId);
|
||||
/// For grouped child players, returns the leader's track
|
||||
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;
|
||||
|
||||
return _cacheService.getCachedTrackForPlayer(effectivePlayerId);
|
||||
}
|
||||
|
||||
/// Get artwork URL for a player from cache
|
||||
String? getCachedArtworkUrl(String playerId, {int size = 512}) {
|
||||
final track = _cacheService.getCachedTrackForPlayer(playerId);
|
||||
final track = getCachedTrackForPlayer(playerId);
|
||||
if (track == null) return null;
|
||||
return getImageUrl(track, size: size);
|
||||
}
|
||||
@@ -201,6 +222,15 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// 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();
|
||||
|
||||
// Initialize offline action queue
|
||||
await OfflineActionQueue.instance.initialize();
|
||||
|
||||
@@ -252,12 +282,122 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
_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;
|
||||
// Note: tracks are loaded separately via API, not cached in SyncService
|
||||
_logger.log('📦 Pre-loaded library for favorites: ${_albums.length} albums, ${_artists.length} artists');
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error loading library from 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
|
||||
@@ -1738,8 +1878,18 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
/// Get favorite tracks from the library
|
||||
Future<List<Track>> getFavoriteTracks() async {
|
||||
// Filter from loaded library - favorites are already loaded
|
||||
return _tracks.where((t) => t.favorite == true).toList();
|
||||
// If full tracks list is loaded, filter from it
|
||||
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;
|
||||
}
|
||||
// Otherwise return cached favorites (for instant display before full library loads)
|
||||
return _cachedFavoriteTracks;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -2028,6 +2178,81 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
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)';
|
||||
final sendspinPlayers = _availablePlayers
|
||||
.where((p) => p.name.endsWith(sendspinSuffix))
|
||||
.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>{};
|
||||
|
||||
for (final player in sendspinPlayers) {
|
||||
final baseName = player.name.substring(0, player.name.length - sendspinSuffix.length);
|
||||
sendspinByBaseName[baseName] = player;
|
||||
|
||||
// Find the corresponding regular Cast player and store the ID mapping
|
||||
final regularCastPlayer = _availablePlayers.where(
|
||||
(p) => p.name == baseName && !p.name.endsWith(sendspinSuffix)
|
||||
).firstOrNull;
|
||||
if (regularCastPlayer != null) {
|
||||
_castToSendspinIdMap[regularCastPlayer.playerId] = player.playerId;
|
||||
_logger.log('🔗 Mapped Cast ID ${regularCastPlayer.playerId} -> Sendspin ID ${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 = player.name.endsWith(sendspinSuffix);
|
||||
|
||||
if (isSendspin) {
|
||||
final baseName = player.name.substring(0, player.name.length - sendspinSuffix.length);
|
||||
// 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
|
||||
_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);
|
||||
}
|
||||
return player;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Sort players - check if smart sort is enabled
|
||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||
if (smartSort) {
|
||||
@@ -2084,14 +2309,35 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
// Keep currently selected player if still available (and not overridden by prefer local)
|
||||
// But allow switching to a playing player when preferLocalPlayer is OFF
|
||||
if (playerToSelect == null && _selectedPlayer != null) {
|
||||
final stillAvailable = _availablePlayers.any(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId && p.available,
|
||||
);
|
||||
if (stillAvailable) {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||
);
|
||||
// If preferLocalPlayer is OFF, check if we should switch to a playing player
|
||||
if (!preferLocalPlayer) {
|
||||
final currentPlayerState = _availablePlayers
|
||||
.firstWhere((p) => p.playerId == _selectedPlayer!.playerId)
|
||||
.state;
|
||||
final currentIsPlaying = currentPlayerState == 'playing';
|
||||
final playingPlayers = _availablePlayers.where(
|
||||
(p) => p.state == 'playing' && p.available,
|
||||
).toList();
|
||||
|
||||
// Switch to playing player only if current isn't playing and exactly one other is
|
||||
if (!currentIsPlaying && playingPlayers.length == 1) {
|
||||
playerToSelect = playingPlayers.first;
|
||||
_logger.log('🎵 Switched to playing player: ${playerToSelect?.name}');
|
||||
}
|
||||
}
|
||||
|
||||
// Keep current selection if no switch happened
|
||||
if (playerToSelect == null) {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2162,7 +2408,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// 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.
|
||||
_currentTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
|
||||
// For grouped child players, this returns the leader's track
|
||||
_currentTrack = getCachedTrackForPlayer(player.playerId);
|
||||
|
||||
// Initialize position tracker for this player
|
||||
_positionTracker.onPlayerSelected(player.playerId);
|
||||
@@ -2537,8 +2784,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
// 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 = _cacheService.getCachedTrackForPlayer(_selectedPlayer!.playerId);
|
||||
final cachedTrack = getCachedTrackForPlayer(_selectedPlayer!.playerId);
|
||||
if (cachedTrack != null && cachedTrack.uri == _currentTrack!.uri) {
|
||||
final cachedHasImage = cachedTrack.metadata?['images'] != null;
|
||||
final currentHasImage = _currentTrack!.metadata?['images'] != null;
|
||||
@@ -2611,6 +2859,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
if (_currentTrack != null) {
|
||||
_currentTrack = null;
|
||||
stateChanged = true;
|
||||
// Persist cleared track state
|
||||
_persistPlaybackState();
|
||||
}
|
||||
// Clear audiobook context when playback stops
|
||||
if (_currentAudiobook != null) {
|
||||
@@ -2636,7 +2886,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
if (trackChanged) {
|
||||
// Check if cached track has better metadata (images, proper artist info)
|
||||
// This prevents losing album art and artist data when resuming the app
|
||||
final cachedTrack = _cacheService.getCachedTrackForPlayer(_selectedPlayer!.playerId);
|
||||
// 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)
|
||||
@@ -2680,6 +2931,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
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) {
|
||||
@@ -2750,6 +3004,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
if (_currentTrack != null) {
|
||||
_currentTrack = null;
|
||||
stateChanged = true;
|
||||
// Persist cleared track state
|
||||
_persistPlaybackState();
|
||||
}
|
||||
|
||||
// Show player name placeholder so notification shows correct player
|
||||
@@ -3024,7 +3280,26 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<PlayerQueue?> getQueue(String playerId) async {
|
||||
final queue = await _api?.getQueue(playerId);
|
||||
// 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!;
|
||||
}
|
||||
|
||||
final queue = await _api?.getQueue(effectivePlayerId);
|
||||
|
||||
// Persist queue to database for instant display on app resume
|
||||
if (queue != null) {
|
||||
@@ -3280,6 +3555,130 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 ID to Sendspin ID if available
|
||||
// This is needed because regular Cast players can't sync with Sendspin players
|
||||
final actualTargetId = _castToSendspinIdMap[targetPlayerId] ?? targetPlayerId;
|
||||
if (actualTargetId != targetPlayerId) {
|
||||
_logger.log('🔗 Translated Cast ID to Sendspin ID: $targetPlayerId -> $actualTargetId');
|
||||
}
|
||||
|
||||
_logger.log('🔗 Calling API syncPlayerToLeader($actualTargetId, ${leaderPlayer.playerId})');
|
||||
await _api!.syncPlayerToLeader(actualTargetId, leaderPlayer.playerId);
|
||||
_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');
|
||||
await _api?.unsyncPlayer(playerId);
|
||||
|
||||
// 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');
|
||||
|
||||
@@ -443,6 +443,18 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
}
|
||||
|
||||
if (provider.isConnected) {
|
||||
// For first-time users, wait for player selection so the welcome
|
||||
// overlay can appear immediately without home screen flash.
|
||||
// This matches the logic in AppStartup._checkAndConnect().
|
||||
final hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
||||
if (!hasCompletedOnboarding) {
|
||||
// First-time user: wait for connection + player selection
|
||||
for (int i = 0; i < 20; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 250));
|
||||
if (provider.selectedPlayer != null) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to home screen
|
||||
if (mounted) {
|
||||
// Ensure keyboard is closed before navigating
|
||||
|
||||
@@ -41,6 +41,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Player settings
|
||||
bool _preferLocalPlayer = false;
|
||||
bool _smartSortPlayers = false;
|
||||
// Hint settings
|
||||
bool _showHints = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -89,6 +91,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final preferLocal = await SettingsService.getPreferLocalPlayer();
|
||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||
|
||||
// Load hint settings
|
||||
final showHints = await SettingsService.getShowHints();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showRecentAlbums = showRecent;
|
||||
@@ -105,6 +110,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_libraryEnabled = libraryEnabled;
|
||||
_preferLocalPlayer = preferLocal;
|
||||
_smartSortPlayers = smartSort;
|
||||
_showHints = showHints;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -699,6 +705,46 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Hints & Tips section
|
||||
Text(
|
||||
S.of(context)!.hintsAndTips,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.showHintsDescription,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
title: Text(
|
||||
S.of(context)!.showHints,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
value: _showHints,
|
||||
onChanged: (value) {
|
||||
setState(() => _showHints = value);
|
||||
SettingsService.setShowHints(value);
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Home Screen section
|
||||
Text(
|
||||
S.of(context)!.homeScreen,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import 'dart:convert';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../constants/timings.dart';
|
||||
|
||||
/// Centralized cache service for all app data caching
|
||||
/// This is a non-notifying service - it just stores and retrieves cached data
|
||||
class CacheService {
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
bool _homeRowsLoaded = false;
|
||||
|
||||
// Home screen row caching
|
||||
List<Album>? _cachedRecentAlbums;
|
||||
@@ -57,6 +60,8 @@ class CacheService {
|
||||
_cachedRecentAlbums = albums;
|
||||
_recentAlbumsLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${albums.length} recent albums');
|
||||
// Persist to database for instant load on next launch
|
||||
_persistHomeRowToDatabase('recent_albums', albums.map((a) => a.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Check if discover artists cache is valid
|
||||
@@ -76,6 +81,8 @@ class CacheService {
|
||||
_cachedDiscoverArtists = artists;
|
||||
_discoverArtistsLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${artists.length} discover artists');
|
||||
// Persist to database for instant load on next launch
|
||||
_persistHomeRowToDatabase('discover_artists', artists.map((a) => a.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Check if discover albums cache is valid
|
||||
@@ -95,6 +102,8 @@ class CacheService {
|
||||
_cachedDiscoverAlbums = albums;
|
||||
_discoverAlbumsLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${albums.length} discover albums');
|
||||
// Persist to database for instant load on next launch
|
||||
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Invalidate home screen cache (call on pull-to-refresh)
|
||||
@@ -276,4 +285,78 @@ class CacheService {
|
||||
_cachedSelectedPlayer = null;
|
||||
_playersLastFetched = null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DATABASE PERSISTENCE FOR HOME ROWS
|
||||
// ============================================================================
|
||||
|
||||
/// Load home row data from database for instant display on startup
|
||||
Future<void> loadHomeRowsFromDatabase() async {
|
||||
if (_homeRowsLoaded) return;
|
||||
_homeRowsLoaded = true;
|
||||
|
||||
try {
|
||||
if (!DatabaseService.instance.isInitialized) return;
|
||||
|
||||
// Load recent albums
|
||||
final recentData = await DatabaseService.instance.getHomeRowCache('recent_albums');
|
||||
if (recentData != null) {
|
||||
try {
|
||||
final items = (jsonDecode(recentData.itemsJson) as List)
|
||||
.map((json) => Album.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
_cachedRecentAlbums = items;
|
||||
_recentAlbumsLastFetched = recentData.lastUpdated;
|
||||
_logger.log('📦 Loaded ${items.length} recent albums from database');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse recent albums: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Load discover artists
|
||||
final artistsData = await DatabaseService.instance.getHomeRowCache('discover_artists');
|
||||
if (artistsData != null) {
|
||||
try {
|
||||
final items = (jsonDecode(artistsData.itemsJson) as List)
|
||||
.map((json) => Artist.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
_cachedDiscoverArtists = items;
|
||||
_discoverArtistsLastFetched = artistsData.lastUpdated;
|
||||
_logger.log('📦 Loaded ${items.length} discover artists from database');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse discover artists: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Load discover albums
|
||||
final albumsData = await DatabaseService.instance.getHomeRowCache('discover_albums');
|
||||
if (albumsData != null) {
|
||||
try {
|
||||
final items = (jsonDecode(albumsData.itemsJson) as List)
|
||||
.map((json) => Album.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
_cachedDiscoverAlbums = items;
|
||||
_discoverAlbumsLastFetched = albumsData.lastUpdated;
|
||||
_logger.log('📦 Loaded ${items.length} discover albums from database');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse discover albums: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error loading home rows from database: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist home row data to database (fire-and-forget)
|
||||
void _persistHomeRowToDatabase(String rowType, List<Map<String, dynamic>> items) {
|
||||
() async {
|
||||
try {
|
||||
if (!DatabaseService.instance.isInitialized) return;
|
||||
await DatabaseService.instance.saveHomeRowCache(rowType, jsonEncode(items));
|
||||
_logger.log('💾 Persisted $rowType to database');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to persist $rowType: $e');
|
||||
}
|
||||
}();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,4 +222,21 @@ class DatabaseService {
|
||||
|
||||
/// Clear queue for a player
|
||||
Future<void> clearCachedQueue(String playerId) => db.clearCachedQueue(playerId);
|
||||
|
||||
// ============================================
|
||||
// Home Row Cache Convenience Methods
|
||||
// ============================================
|
||||
|
||||
/// Save home row data (recent albums, discover artists, etc.)
|
||||
Future<void> saveHomeRowCache(String rowType, String itemsJson) {
|
||||
return db.saveHomeRowCache(rowType, itemsJson);
|
||||
}
|
||||
|
||||
/// Get cached home row data
|
||||
Future<HomeRowCacheData?> getHomeRowCache(String rowType) {
|
||||
return db.getHomeRowCache(rowType);
|
||||
}
|
||||
|
||||
/// Clear all home row cache
|
||||
Future<void> clearHomeRowCache() => db.clearHomeRowCache();
|
||||
}
|
||||
|
||||
@@ -1996,6 +1996,45 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync a player to a leader/group (temporary group)
|
||||
/// The player will join the leader's sync group and play the same audio
|
||||
Future<void> syncPlayerToLeader(String playerId, String leaderPlayerId) async {
|
||||
try {
|
||||
_logger.log('🔗 API syncPlayerToLeader called');
|
||||
_logger.log('🔗 player_id: $playerId');
|
||||
_logger.log('🔗 target_player: $leaderPlayerId');
|
||||
|
||||
final response = await _sendCommand(
|
||||
'players/cmd/group',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
'target_player': leaderPlayerId,
|
||||
},
|
||||
);
|
||||
_logger.log('✅ Player synced successfully, response: $response');
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error syncing player: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a player from its sync group
|
||||
Future<void> unsyncPlayer(String playerId) async {
|
||||
try {
|
||||
_logger.log('🔓 Unsyncing player: $playerId');
|
||||
await _sendCommand(
|
||||
'players/cmd/ungroup',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
},
|
||||
);
|
||||
_logger.log('✅ Player unsynced successfully');
|
||||
} catch (e) {
|
||||
_logger.log('Error unsyncing player: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle shuffle mode for queue
|
||||
Future<void> toggleShuffle(String queueId) async {
|
||||
try {
|
||||
|
||||
@@ -518,8 +518,9 @@ class SendspinService {
|
||||
if (muted != null) _isMuted = muted;
|
||||
|
||||
// Determine player state for Sendspin protocol
|
||||
// 'synchronized' = ready/playing, 'idle' = not playing
|
||||
final playerState = _isPlaying || _isPaused ? 'synchronized' : 'idle';
|
||||
// Valid values: 'synchronized', 'error', 'external_source'
|
||||
// When connected, we're always synchronized (ready to receive audio)
|
||||
const playerState = 'synchronized';
|
||||
|
||||
_sendMessage({
|
||||
'type': 'client/state',
|
||||
@@ -580,7 +581,7 @@ class SendspinService {
|
||||
_sendMessage({
|
||||
'type': 'client/time',
|
||||
'payload': {
|
||||
'timestamp': timestampMicroseconds,
|
||||
'client_transmitted': timestampMicroseconds,
|
||||
},
|
||||
}, allowDuringHandshake: allowDuringHandshake);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,11 @@ class SettingsService {
|
||||
static const String _keyEnabledAbsLibraries = 'enabled_abs_libraries'; // JSON list of library paths
|
||||
static const String _keyDiscoveredAbsLibraries = 'discovered_abs_libraries'; // JSON list of {path, name}
|
||||
|
||||
// Hint System Settings
|
||||
static const String _keyShowHints = 'show_hints'; // Master toggle for hints
|
||||
static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players
|
||||
static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen
|
||||
|
||||
static Future<String?> getServerUrl() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyServerUrl);
|
||||
@@ -762,4 +767,42 @@ class SettingsService {
|
||||
|
||||
await setEnabledAbsLibraries(enabled);
|
||||
}
|
||||
|
||||
// Hint System Settings
|
||||
|
||||
/// Get whether hints are enabled (default: true)
|
||||
static Future<bool> getShowHints() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyShowHints) ?? true;
|
||||
}
|
||||
|
||||
/// Set whether hints are enabled
|
||||
static Future<void> setShowHints(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowHints, show);
|
||||
}
|
||||
|
||||
/// Check if user has ever used the player reveal gesture
|
||||
static Future<bool> getHasUsedPlayerReveal() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyHasUsedPlayerReveal) ?? false;
|
||||
}
|
||||
|
||||
/// Mark that user has used the player reveal gesture
|
||||
static Future<void> setHasUsedPlayerReveal(bool hasUsed) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyHasUsedPlayerReveal, hasUsed);
|
||||
}
|
||||
|
||||
/// Check if user has completed onboarding (seen welcome screen)
|
||||
static Future<bool> getHasCompletedOnboarding() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyHasCompletedOnboarding) ?? false;
|
||||
}
|
||||
|
||||
/// Mark that user has completed onboarding
|
||||
static Future<void> setHasCompletedOnboarding(bool completed) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyHasCompletedOnboarding, completed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,11 +32,21 @@ class ExpandablePlayer extends StatefulWidget {
|
||||
/// Callback when swipe-down gesture triggers player reveal
|
||||
final VoidCallback? onRevealPlayers;
|
||||
|
||||
/// Whether the device reveal overlay is currently visible
|
||||
/// When true, shows player name instead of track name in collapsed state
|
||||
final bool isDeviceRevealVisible;
|
||||
|
||||
/// Whether the hint text should be shown
|
||||
/// When true, shows "Pull to select players" instead of track info
|
||||
final bool isHintVisible;
|
||||
|
||||
const ExpandablePlayer({
|
||||
super.key,
|
||||
this.slideOffset = 0.0,
|
||||
this.bounceOffset = 0.0,
|
||||
this.onRevealPlayers,
|
||||
this.isDeviceRevealVisible = false,
|
||||
this.isHintVisible = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -74,6 +84,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
static const double _bottomNavHeight = 56.0;
|
||||
static const double _edgeDeadZone = 40.0; // Dead zone for Android back gesture
|
||||
|
||||
// Pastel yellow for grouped players (matches PlayerCard.groupBorderColor)
|
||||
static const Color _groupBorderColor = Color(0xFFFFF59D);
|
||||
|
||||
// Track horizontal drag start position
|
||||
double? _horizontalDragStartX;
|
||||
|
||||
@@ -1347,13 +1360,20 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
_handleHorizontalDragEnd(details, maProvider);
|
||||
}
|
||||
},
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
elevation: _lerpDouble(4, 0, t),
|
||||
shadowColor: Colors.black.withOpacity(0.3),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SizedBox(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
border: selectedPlayer.isGrouped && t < 0.5
|
||||
? Border.all(color: _groupBorderColor, width: 1.5)
|
||||
: null,
|
||||
),
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
elevation: _lerpDouble(4, 0, t),
|
||||
shadowColor: Colors.black.withOpacity(0.3),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: Stack(
|
||||
@@ -1468,17 +1488,58 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
),
|
||||
|
||||
// Track title - with slide animation when collapsed
|
||||
// When hint is visible, show lightbulb icon + "Pull to select players"
|
||||
// When device reveal is visible, show player name instead of track name
|
||||
// Hidden during transition to prevent flash
|
||||
// FALLBACK: Show if transition active but no peek content available
|
||||
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
|
||||
if (!(_inTransition && t < 0.1 && _peekPlayer != null))
|
||||
// Hint text - vertically centered in mini player
|
||||
if (widget.isHintVisible && t < 0.5 && !(_inTransition && t < 0.1 && _peekPlayer != null))
|
||||
Positioned(
|
||||
left: titleLeft + miniPlayerSlideOffset,
|
||||
// Center vertically: (64 - ~20) / 2 = 22
|
||||
top: (MiniPlayerLayout.height - 20) / 2,
|
||||
child: SizedBox(
|
||||
width: titleWidth,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lightbulb_outline,
|
||||
size: 16,
|
||||
color: textColor,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
S.of(context)!.pullToSelectPlayers,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: titleFontSize,
|
||||
fontWeight: MiniPlayerLayout.primaryFontWeight,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Track title - with slide animation when collapsed
|
||||
// Hidden when hint is visible
|
||||
if (!(widget.isHintVisible && t < 0.5) && !(_inTransition && t < 0.1 && _peekPlayer != null))
|
||||
Positioned(
|
||||
left: titleLeft + miniPlayerSlideOffset,
|
||||
top: titleTop,
|
||||
child: SizedBox(
|
||||
width: titleWidth,
|
||||
child: Text(
|
||||
currentTrack.name,
|
||||
// Show player name when device reveal visible and collapsed
|
||||
(widget.isDeviceRevealVisible && t < 0.5)
|
||||
? selectedPlayer.name
|
||||
: currentTrack.name,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: titleFontSize,
|
||||
@@ -1495,17 +1556,20 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
),
|
||||
|
||||
// Artist/Author name - with slide animation when collapsed
|
||||
// When hint is visible, show blank line
|
||||
// When device reveal is visible, show "Now Playing" hint
|
||||
// For audiobooks: show author from audiobook context
|
||||
// Hidden during transition to prevent flash
|
||||
// FALLBACK: Show if transition active but no peek content available
|
||||
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
|
||||
if (!(_inTransition && t < 0.1 && _peekPlayer != null))
|
||||
if (!(_inTransition && t < 0.1 && _peekPlayer != null) && !(widget.isHintVisible && t < 0.5))
|
||||
Positioned(
|
||||
left: titleLeft + miniPlayerSlideOffset,
|
||||
top: artistTop,
|
||||
child: SizedBox(
|
||||
width: titleWidth,
|
||||
child: Text(
|
||||
// Always show artist/author (was showing "Now Playing" when device reveal visible)
|
||||
maProvider.isPlayingAudiobook
|
||||
? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor)
|
||||
: currentTrack.artistsString,
|
||||
@@ -1886,6 +1950,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import 'expandable_player.dart';
|
||||
import 'player/player_reveal_overlay.dart';
|
||||
@@ -129,7 +132,7 @@ class GlobalPlayerOverlay extends StatefulWidget {
|
||||
_overlayStateKey.currentState?._dismissPlayerReveal();
|
||||
}
|
||||
|
||||
/// Trigger the bounce animation on mini player (called when overlay dismiss starts)
|
||||
/// Trigger single bounce on mini player (called when device selector collapses)
|
||||
static void triggerBounce() {
|
||||
_overlayStateKey.currentState?._triggerBounce();
|
||||
}
|
||||
@@ -144,17 +147,40 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
late AnimationController _slideController;
|
||||
late Animation<double> _slideAnimation;
|
||||
|
||||
// Controller for player reveal animation with bounce
|
||||
late AnimationController _bounceController;
|
||||
late Animation<double> _bounceAnimation;
|
||||
// Single bounce controller for device selector expand/collapse
|
||||
late AnimationController _singleBounceController;
|
||||
|
||||
// Double bounce controller for hint
|
||||
late AnimationController _doubleBounceController;
|
||||
|
||||
// State for player reveal overlay
|
||||
bool _isRevealVisible = false;
|
||||
|
||||
// Use ValueNotifier instead of setState for bounce offset
|
||||
// This prevents full widget tree rebuilds during animation
|
||||
// State for interactive hint mode (blur backdrop + wait for user action)
|
||||
bool _isHintModeActive = false;
|
||||
Timer? _hintBounceTimer;
|
||||
|
||||
// Track if player reveal was triggered from onboarding (for showing extra hints)
|
||||
bool _isOnboardingReveal = false;
|
||||
|
||||
// Bounce offset for mini player (used by both single and double bounce)
|
||||
final _bounceOffsetNotifier = ValueNotifier<double>(0.0);
|
||||
|
||||
// Hint system state
|
||||
// NOTE: AppStartup now handles the settings loading gate. It waits for:
|
||||
// - First-time users: connected + player selected before showing HomeScreen
|
||||
// - Returning users: connected before showing HomeScreen
|
||||
// This eliminates the race condition that caused home screen flash.
|
||||
bool _showHints = true;
|
||||
bool _hasCompletedOnboarding = false; // First-use welcome screen
|
||||
bool _hintTriggered = false; // Prevent multiple triggers per session
|
||||
bool _settingsLoaded = false; // True once settings have been loaded from storage
|
||||
bool _miniPlayerHintsReady = false; // True once connected with player (can show hints)
|
||||
final _hintOpacityNotifier = ValueNotifier<double>(0.0);
|
||||
|
||||
// Welcome content fade-in animation
|
||||
late AnimationController _welcomeFadeController;
|
||||
|
||||
// Key for the reveal overlay
|
||||
final _revealKey = GlobalKey<PlayerRevealOverlayState>();
|
||||
|
||||
@@ -171,30 +197,134 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
|
||||
// Bounce animation - quick dip down then back up
|
||||
_bounceController = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
// Single bounce for device selector expand/collapse
|
||||
_singleBounceController = AnimationController(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
vsync: this,
|
||||
);
|
||||
_bounceAnimation = CurvedAnimation(
|
||||
parent: _bounceController,
|
||||
curve: Curves.easeOut,
|
||||
_singleBounceController.addListener(() {
|
||||
final t = Curves.easeOut.transform(_singleBounceController.value);
|
||||
// Single bounce: up to 10px then back to 0
|
||||
if (t < 0.5) {
|
||||
_bounceOffsetNotifier.value = 10.0 * (t * 2); // 0 -> 10
|
||||
} else {
|
||||
_bounceOffsetNotifier.value = 10.0 * ((1.0 - t) * 2); // 10 -> 0
|
||||
}
|
||||
});
|
||||
|
||||
// Hint bounce - single gentle bounce to draw attention
|
||||
_doubleBounceController = AnimationController(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
vsync: this,
|
||||
);
|
||||
_doubleBounceController.addListener(() {
|
||||
final t = Curves.easeOut.transform(_doubleBounceController.value);
|
||||
// Single bounce: up 20px then back down
|
||||
if (t < 0.5) {
|
||||
_bounceOffsetNotifier.value = 20.0 * (t * 2); // 0 -> 20
|
||||
} else {
|
||||
_bounceOffsetNotifier.value = 20.0 * ((1.0 - t) * 2); // 20 -> 0
|
||||
}
|
||||
});
|
||||
|
||||
// Welcome content fade-in
|
||||
_welcomeFadeController = AnimationController(
|
||||
duration: const Duration(milliseconds: 800),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
// Use ValueNotifier instead of setState to avoid full widget rebuilds
|
||||
// This isolates the rebuild to only the widgets that depend on bounce offset
|
||||
_bounceController.addListener(() {
|
||||
final t = _bounceAnimation.value;
|
||||
// Sine curve: 0 -> 1 -> 0 as t goes 0 -> 0.5 -> 1
|
||||
_bounceOffsetNotifier.value = 10.0 * (t < 0.5 ? t * 2 : (1.0 - t) * 2);
|
||||
// Load hint settings and start welcome immediately if needed
|
||||
_loadHintSettings();
|
||||
}
|
||||
|
||||
Future<void> _loadHintSettings() async {
|
||||
_showHints = await SettingsService.getShowHints();
|
||||
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_settingsLoaded = true;
|
||||
});
|
||||
|
||||
// If first-time user and already connected (AppStartup waited for us),
|
||||
// start welcome screen immediately - no race condition possible
|
||||
if (!_hasCompletedOnboarding && !_hintTriggered) {
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
if (provider.isConnected && provider.selectedPlayer != null) {
|
||||
_miniPlayerHintsReady = true;
|
||||
_startWelcomeScreen();
|
||||
_startMiniPlayerBounce();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Check if we should enable mini player hints now that we have connection
|
||||
_checkConnectionForMiniPlayerHints();
|
||||
}
|
||||
|
||||
/// Check connection state and start welcome screen if appropriate.
|
||||
/// This is called from multiple places:
|
||||
/// 1. didChangeDependencies() - when widget first builds
|
||||
/// 2. _loadHintSettings() - when settings finish loading
|
||||
/// 3. build() - when provider state changes (via post-frame callback)
|
||||
void _checkConnectionForMiniPlayerHints() {
|
||||
if (_hintTriggered || !mounted || !_settingsLoaded) return;
|
||||
if (_hasCompletedOnboarding) return; // Not first-time user
|
||||
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
if (provider.isConnected && provider.selectedPlayer != null) {
|
||||
// Connected with a player - start welcome screen now
|
||||
_miniPlayerHintsReady = true;
|
||||
_startWelcomeScreen();
|
||||
// Start the bounce animation now that mini player is visible
|
||||
_startMiniPlayerBounce();
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the bounce animation for mini player hints
|
||||
void _startMiniPlayerBounce() {
|
||||
if (!_isHintModeActive || !mounted) return;
|
||||
_doubleBounceController.reset();
|
||||
_doubleBounceController.forward();
|
||||
|
||||
// Repeat bounce every 2 seconds
|
||||
_hintBounceTimer?.cancel();
|
||||
_hintBounceTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
||||
if (_isHintModeActive && mounted) {
|
||||
_doubleBounceController.reset();
|
||||
_doubleBounceController.forward();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Start the welcome screen with fade-in animation
|
||||
void _startWelcomeScreen() {
|
||||
if (_hintTriggered) return;
|
||||
_hintTriggered = true;
|
||||
|
||||
// Activate hint mode immediately (blur backdrop visible)
|
||||
setState(() {
|
||||
_isHintModeActive = true;
|
||||
});
|
||||
|
||||
// Fade in welcome content (logo + title)
|
||||
_welcomeFadeController.forward();
|
||||
|
||||
// Mini player bounce is started separately once connected via _startMiniPlayerBounce()
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hintBounceTimer?.cancel();
|
||||
_slideController.dispose();
|
||||
_bounceController.dispose();
|
||||
_singleBounceController.dispose();
|
||||
_doubleBounceController.dispose();
|
||||
_welcomeFadeController.dispose();
|
||||
_bounceOffsetNotifier.dispose();
|
||||
_hintOpacityNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -212,11 +342,29 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
}
|
||||
HapticFeedback.mediumImpact();
|
||||
_bounceController.reset();
|
||||
|
||||
// Track if coming from hint mode (for onboarding hints in player selector)
|
||||
final wasInHintMode = _isHintModeActive;
|
||||
|
||||
// End hint mode if active (user learned the gesture!)
|
||||
_hintBounceTimer?.cancel();
|
||||
_hintBounceTimer = null;
|
||||
_hintOpacityNotifier.value = 0.0;
|
||||
_isHintModeActive = false;
|
||||
|
||||
// Mark onboarding as completed if coming from welcome screen
|
||||
if (wasInHintMode) {
|
||||
SettingsService.setHasCompletedOnboarding(true);
|
||||
}
|
||||
|
||||
// Trigger single bounce on expand
|
||||
_singleBounceController.reset();
|
||||
_singleBounceController.forward();
|
||||
|
||||
setState(() {
|
||||
_isRevealVisible = true;
|
||||
_isOnboardingReveal = wasInHintMode;
|
||||
});
|
||||
_bounceController.forward();
|
||||
}
|
||||
|
||||
void _hidePlayerReveal() {
|
||||
@@ -224,6 +372,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
_bounceOffsetNotifier.value = 0;
|
||||
setState(() {
|
||||
_isRevealVisible = false;
|
||||
_isOnboardingReveal = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -231,14 +380,27 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
void _dismissPlayerReveal() {
|
||||
if (!_isRevealVisible) return;
|
||||
// Call the overlay's dismiss method which has the slide animation
|
||||
// Note: dismiss() triggers the bounce animation internally
|
||||
_revealKey.currentState?.dismiss();
|
||||
}
|
||||
|
||||
/// Trigger bounce animation on mini player
|
||||
/// Trigger single bounce on mini player (called when device selector collapses)
|
||||
void _triggerBounce() {
|
||||
_bounceController.reset();
|
||||
_bounceController.forward();
|
||||
_singleBounceController.reset();
|
||||
_singleBounceController.forward();
|
||||
}
|
||||
|
||||
/// End hint mode (called when user taps skip button)
|
||||
void _endHintMode() {
|
||||
if (!_isHintModeActive) return;
|
||||
_hintBounceTimer?.cancel();
|
||||
_hintBounceTimer = null;
|
||||
_hintOpacityNotifier.value = 0.0;
|
||||
_bounceOffsetNotifier.value = 0.0;
|
||||
// Mark onboarding as completed (first use only)
|
||||
SettingsService.setHasCompletedOnboarding(true);
|
||||
setState(() {
|
||||
_isHintModeActive = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -246,12 +408,46 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
// Handle back gesture at top level - dismiss device list if visible
|
||||
// Watch provider to trigger rebuilds when connection state changes
|
||||
final provider = context.watch<MusicAssistantProvider>();
|
||||
|
||||
// Compute whether this is a first-time user ready for welcome screen.
|
||||
// This is SYNCHRONOUS - no setState needed - so it works in the same frame.
|
||||
final isFirstTimeUserReady = _settingsLoaded &&
|
||||
!_hasCompletedOnboarding &&
|
||||
provider.isConnected &&
|
||||
provider.selectedPlayer != null;
|
||||
|
||||
// Show welcome backdrop if:
|
||||
// 1. Already in hint mode (_isHintModeActive), OR
|
||||
// 2. First-time user ready but hint mode not started yet (!_hintTriggered)
|
||||
// This ensures backdrop appears IMMEDIATELY when HomeScreen renders,
|
||||
// preventing the flash. Welcome content fades in after via animation.
|
||||
final shouldShowWelcomeBackdrop = _isHintModeActive ||
|
||||
(isFirstTimeUserReady && !_hintTriggered);
|
||||
|
||||
// Trigger hint mode if conditions are met (for animations, bounce, etc)
|
||||
if (isFirstTimeUserReady && !_hintTriggered) {
|
||||
// Schedule for post-frame to avoid setState during build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && !_hintTriggered) {
|
||||
_miniPlayerHintsReady = true;
|
||||
_startWelcomeScreen();
|
||||
_startMiniPlayerBounce();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle back gesture at top level - dismiss hint mode or device list if visible
|
||||
return PopScope(
|
||||
canPop: !_isRevealVisible,
|
||||
canPop: !_isRevealVisible && !_isHintModeActive,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop && _isRevealVisible) {
|
||||
_dismissPlayerReveal();
|
||||
if (!didPop) {
|
||||
if (_isHintModeActive) {
|
||||
_endHintMode();
|
||||
} else if (_isRevealVisible) {
|
||||
_dismissPlayerReveal();
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
@@ -263,14 +459,18 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
child: widget.child,
|
||||
),
|
||||
// Global persistent bottom navigation bar - positioned at bottom
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: ListenableBuilder(
|
||||
listenable: navigationProvider,
|
||||
builder: (context, _) {
|
||||
return Consumer<ThemeProvider>(
|
||||
// Hide when: not connected, no player selected, OR showing welcome screen
|
||||
// For first-time users, selectedPlayer becomes non-null at the same time
|
||||
// as shouldShowWelcomeBackdrop becomes true, preventing any gap.
|
||||
if (provider.isConnected && provider.selectedPlayer != null && !shouldShowWelcomeBackdrop)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: ListenableBuilder(
|
||||
listenable: navigationProvider,
|
||||
builder: (context, _) {
|
||||
return Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, _) {
|
||||
// Use adaptive primary color for bottom nav when adaptive theme is enabled
|
||||
final sourceColor = themeProvider.adaptiveTheme
|
||||
@@ -359,9 +559,125 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
// Blur backdrop for device selector (reveal mode) - static, no animation
|
||||
if (_isRevealVisible && !_isHintModeActive)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _dismissPlayerReveal,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
|
||||
child: Container(
|
||||
color: colorScheme.surface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Welcome/hint backdrop - shows IMMEDIATELY when first-time user is ready.
|
||||
// Uses computed `shouldShowWelcomeBackdrop` which is true BEFORE _isHintModeActive,
|
||||
// preventing the 1-frame gap where home screen would be visible.
|
||||
if (shouldShowWelcomeBackdrop)
|
||||
Positioned.fill(
|
||||
child: TweenAnimationBuilder<double>(
|
||||
// Hold solid for 2s, then fade to 0.5 over 1s
|
||||
tween: Tween<double>(begin: 1.0, end: 0.5),
|
||||
duration: const Duration(seconds: 3),
|
||||
curve: const Interval(0.67, 1.0, curve: Curves.easeOut),
|
||||
builder: (context, opacity, child) {
|
||||
return BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
|
||||
child: Container(
|
||||
// Use same dark color as connection screen for seamless transition
|
||||
color: const Color(0xFF1a1a1a).withOpacity(opacity),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Welcome message during hint mode - two positioned sections with fade-in
|
||||
// Top section: Logo and Welcome title (stays fixed at top area)
|
||||
if (_isHintModeActive)
|
||||
Positioned(
|
||||
left: 24,
|
||||
right: 24,
|
||||
// Position logo section high up - about 1/3 from top
|
||||
top: MediaQuery.of(context).size.height * 0.15,
|
||||
child: FadeTransition(
|
||||
opacity: CurvedAnimation(
|
||||
parent: _welcomeFadeController,
|
||||
curve: Curves.easeOut,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Ensemble logo - same as settings screen
|
||||
Image.asset(
|
||||
'assets/images/ensemble_icon_transparent.png',
|
||||
width: MediaQuery.of(context).size.width * 0.5,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Welcome title
|
||||
Text(
|
||||
S.of(context)!.welcomeToEnsemble,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom section: Hint text and Skip button (only show once mini player is visible)
|
||||
if (_isHintModeActive && _miniPlayerHintsReady)
|
||||
Positioned(
|
||||
left: 24,
|
||||
right: 24,
|
||||
// Position so skip button is ~32px above mini player, matching skip-to-miniplayer gap
|
||||
bottom: BottomSpacing.navBarHeight + BottomSpacing.miniPlayerHeight + MediaQuery.of(context).padding.bottom + 32,
|
||||
child: FadeTransition(
|
||||
opacity: CurvedAnimation(
|
||||
parent: _welcomeFadeController,
|
||||
curve: Curves.easeOut,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Hint text
|
||||
Text(
|
||||
S.of(context)!.welcomeMessage,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
height: 1.4,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Skip button - same gap above as below to mini player
|
||||
TextButton(
|
||||
onPressed: _endHintMode,
|
||||
child: Text(
|
||||
S.of(context)!.skip,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Player reveal overlay - renders BELOW mini player so cards slide behind it
|
||||
if (_isRevealVisible)
|
||||
PlayerRevealOverlay(
|
||||
@@ -369,33 +685,43 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
onDismiss: _hidePlayerReveal,
|
||||
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).padding.bottom + 12,
|
||||
miniPlayerHeight: 64,
|
||||
showOnboardingHints: _isOnboardingReveal,
|
||||
),
|
||||
|
||||
// Global player overlay - renders ON TOP so cards slide behind it
|
||||
// Use Selector instead of Consumer to avoid rebuilds during animation
|
||||
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer})>(
|
||||
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer, bool hasTrack})>(
|
||||
selector: (_, provider) => (
|
||||
isConnected: provider.isConnected,
|
||||
hasPlayer: provider.selectedPlayer != null,
|
||||
hasTrack: provider.currentTrack != null,
|
||||
),
|
||||
builder: (context, state, child) {
|
||||
// Only show player if connected and has a selected player
|
||||
if (!state.isConnected || !state.hasPlayer) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
// Combine slide and bounce animations with ValueListenableBuilder
|
||||
|
||||
// Combine slide, bounce, and hint animations with ValueListenableBuilders
|
||||
// This prevents full widget tree rebuilds - only ExpandablePlayer updates
|
||||
return ValueListenableBuilder<double>(
|
||||
valueListenable: _bounceOffsetNotifier,
|
||||
builder: (context, bounceOffset, _) {
|
||||
return AnimatedBuilder(
|
||||
animation: _slideAnimation,
|
||||
builder: (context, _) {
|
||||
return ExpandablePlayer(
|
||||
key: globalPlayerKey,
|
||||
slideOffset: _slideAnimation.value,
|
||||
bounceOffset: bounceOffset,
|
||||
onRevealPlayers: _showPlayerReveal,
|
||||
return ValueListenableBuilder<double>(
|
||||
valueListenable: _hintOpacityNotifier,
|
||||
builder: (context, hintOpacity, _) {
|
||||
return AnimatedBuilder(
|
||||
animation: _slideAnimation,
|
||||
builder: (context, _) {
|
||||
return ExpandablePlayer(
|
||||
key: globalPlayerKey,
|
||||
slideOffset: _slideAnimation.value,
|
||||
bounceOffset: bounceOffset,
|
||||
onRevealPlayers: _showPlayerReveal,
|
||||
isDeviceRevealVisible: _isRevealVisible,
|
||||
isHintVisible: hintOpacity > 0,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -67,6 +67,7 @@ class DeviceSelectorBar extends StatelessWidget {
|
||||
textColor: textColor,
|
||||
width: width,
|
||||
slideOffset: slideOffset,
|
||||
isHint: swipeHint != null,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -97,6 +98,7 @@ class DeviceSelectorBar extends StatelessWidget {
|
||||
textColor: textColor,
|
||||
width: width,
|
||||
slideOffset: 0, // No additional slide - Transform handles positioning
|
||||
isHint: swipeHint != null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@ class MiniPlayerContent extends StatelessWidget {
|
||||
/// Horizontal slide offset for swipe animation (-1 to 1)
|
||||
final double slideOffset;
|
||||
|
||||
/// Whether secondary text is a hint (shows lightbulb icon)
|
||||
final bool isHint;
|
||||
|
||||
/// Whether to show the progress bar overlay (for playing state)
|
||||
final bool showProgress;
|
||||
|
||||
@@ -64,6 +67,7 @@ class MiniPlayerContent extends StatelessWidget {
|
||||
required this.textColor,
|
||||
required this.width,
|
||||
this.slideOffset = 0.0,
|
||||
this.isHint = false,
|
||||
this.showProgress = false,
|
||||
this.progress = 0.0,
|
||||
});
|
||||
@@ -136,21 +140,44 @@ class MiniPlayerContent extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Secondary text line
|
||||
// Secondary text line (with optional hint icon)
|
||||
if (hasSecondaryLine)
|
||||
Positioned(
|
||||
left: MiniPlayerLayout.textLeft + slidePixels,
|
||||
top: MiniPlayerLayout.secondaryTop,
|
||||
right: MiniPlayerLayout.textRightPadding - slidePixels,
|
||||
child: Text(
|
||||
secondaryText!,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
|
||||
fontSize: MiniPlayerLayout.secondaryFontSize,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
child: isHint
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lightbulb_outline,
|
||||
size: 14,
|
||||
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
secondaryText!,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
|
||||
fontSize: MiniPlayerLayout.secondaryFontSize,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Text(
|
||||
secondaryText!,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
|
||||
fontSize: MiniPlayerLayout.secondaryFontSize,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -12,13 +12,18 @@ class PlayerCard extends StatelessWidget {
|
||||
final String? albumArtUrl;
|
||||
final bool isSelected;
|
||||
final bool isPlaying;
|
||||
final bool isGrouped;
|
||||
final Color backgroundColor;
|
||||
final Color textColor;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
final VoidCallback? onPlayPause;
|
||||
final VoidCallback? onSkipNext;
|
||||
final VoidCallback? onPower;
|
||||
|
||||
// Pastel yellow for grouped players
|
||||
static const Color groupBorderColor = Color(0xFFFFF59D);
|
||||
|
||||
const PlayerCard({
|
||||
super.key,
|
||||
required this.player,
|
||||
@@ -26,9 +31,11 @@ class PlayerCard extends StatelessWidget {
|
||||
this.albumArtUrl,
|
||||
required this.isSelected,
|
||||
required this.isPlaying,
|
||||
this.isGrouped = false,
|
||||
required this.backgroundColor,
|
||||
required this.textColor,
|
||||
required this.onTap,
|
||||
this.onLongPress,
|
||||
this.onPlayPause,
|
||||
this.onSkipNext,
|
||||
this.onPower,
|
||||
@@ -43,11 +50,15 @@ class PlayerCard extends StatelessWidget {
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: Container(
|
||||
height: cardHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
border: isGrouped
|
||||
? Border.all(color: groupBorderColor, width: 1.5)
|
||||
: null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
|
||||
@@ -2,7 +2,9 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../providers/music_assistant_provider.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../../theme/theme_provider.dart';
|
||||
import '../../theme/palette_helper.dart';
|
||||
@@ -16,12 +18,14 @@ class PlayerRevealOverlay extends StatefulWidget {
|
||||
final VoidCallback onDismiss;
|
||||
final double miniPlayerBottom; // Bottom position of mini player
|
||||
final double miniPlayerHeight;
|
||||
final bool showOnboardingHints; // Show additional hints for first-time users
|
||||
|
||||
const PlayerRevealOverlay({
|
||||
super.key,
|
||||
required this.onDismiss,
|
||||
required this.miniPlayerBottom,
|
||||
required this.miniPlayerHeight,
|
||||
this.showOnboardingHints = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -43,10 +47,18 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
// Cache extracted colors per player for per-device accent colors
|
||||
final Map<String, ColorScheme?> _playerColors = {};
|
||||
|
||||
// Hint system
|
||||
bool _showHints = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Load hint settings
|
||||
SettingsService.getShowHints().then((value) {
|
||||
if (mounted) setState(() => _showHints = value);
|
||||
});
|
||||
|
||||
_revealController = AnimationController(
|
||||
vsync: this,
|
||||
);
|
||||
@@ -127,7 +139,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
|
||||
/// Animate dismissal and call callback when done
|
||||
void dismiss() {
|
||||
// Trigger settle bounce on mini player when cards are ~70% collapsed
|
||||
// Trigger single bounce on mini player when cards are ~70% collapsed
|
||||
// This creates the effect of cards "landing" on the mini player
|
||||
bool bounceFired = false;
|
||||
void checkBounce() {
|
||||
@@ -210,13 +222,12 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Backdrop - tap to dismiss
|
||||
// Backdrop - tap to dismiss (darkness handled by GlobalPlayerOverlay)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: dismiss,
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.6 * _backdropAnimation.value),
|
||||
),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -231,6 +242,53 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Hint above player list - different for onboarding vs regular use
|
||||
// Onboarding: show first-time hint (how to select)
|
||||
// Regular: show sync hint (tap and hold to sync)
|
||||
if (players.isNotEmpty && (_showHints || widget.showOnboardingHints))
|
||||
Builder(
|
||||
builder: (context) {
|
||||
// Calculate slide offset to match the topmost card
|
||||
const baseOffset = 80.0;
|
||||
final hintDistanceToTravel = baseOffset + (players.length * (cardHeight + cardSpacing));
|
||||
final hintSlideOffset = hintDistanceToTravel * (1.0 - t);
|
||||
|
||||
// Show different hint for onboarding vs regular use
|
||||
final isOnboarding = widget.showOnboardingHints;
|
||||
final hintText = isOnboarding
|
||||
? S.of(context)!.selectPlayerHint
|
||||
: S.of(context)!.holdToSync;
|
||||
|
||||
return Transform.translate(
|
||||
offset: Offset(0, hintSlideOffset),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isOnboarding ? Icons.touch_app_outlined : Icons.lightbulb_outline,
|
||||
size: 18,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
hintText,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Build player cards - all slide from behind mini player
|
||||
...List.generate(players.length, (index) {
|
||||
final player = players[index];
|
||||
@@ -269,6 +327,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
albumArtUrl: albumArtUrl,
|
||||
isSelected: false,
|
||||
isPlaying: isPlaying,
|
||||
isGrouped: player.isGrouped,
|
||||
backgroundColor: cardBgColor,
|
||||
textColor: cardTextColor,
|
||||
onTap: () {
|
||||
@@ -276,6 +335,17 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
maProvider.selectPlayer(player);
|
||||
dismiss();
|
||||
},
|
||||
onLongPress: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Syncing ${player.name}...'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
maProvider.togglePlayerSync(player.playerId);
|
||||
},
|
||||
onPlayPause: () {
|
||||
if (isPlaying) {
|
||||
maProvider.pausePlayer(player.playerId);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
@@ -18,8 +19,12 @@ class PlayerSelector extends StatelessWidget {
|
||||
final availablePlayers = maProvider.availablePlayers;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Count players that are currently playing
|
||||
final playingCount = availablePlayers.where((p) => p.state == 'playing').length;
|
||||
// Count players that are currently playing (excluding selected player)
|
||||
// User already sees if selected player is playing, badge shows "other" activity
|
||||
final selectedPlayerId = selectedPlayer?.playerId;
|
||||
final playingCount = availablePlayers
|
||||
.where((p) => p.state == 'playing' && p.playerId != selectedPlayerId)
|
||||
.length;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
@@ -214,6 +219,11 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this player is part of a sync group
|
||||
final isGrouped = player.isGrouped;
|
||||
// Pastel yellow for grouped players
|
||||
const groupBorderColor = Color(0xFFFFF59D);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: InkWell(
|
||||
@@ -221,6 +231,23 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
||||
maProvider.selectPlayer(player);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
onLongPress: () {
|
||||
// Haptic feedback for sync action
|
||||
HapticFeedback.mediumImpact();
|
||||
|
||||
// Show snackbar for visual feedback
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Syncing ${player.name}...'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
// Long-press to sync/unsync player
|
||||
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
|
||||
debugPrint('🔗 Player isGrouped: ${player.isGrouped}, groupMembers: ${player.groupMembers}');
|
||||
maProvider.togglePlayerSync(player.playerId);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
height: 72,
|
||||
@@ -229,6 +256,9 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
||||
? colorScheme.primary.withOpacity(0.15)
|
||||
: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: isGrouped
|
||||
? Border.all(color: groupBorderColor, width: 1.5)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -318,6 +348,35 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (isGrouped) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: groupBorderColor.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.link_rounded,
|
||||
size: 10,
|
||||
color: Colors.amber.shade800,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'Synced',
|
||||
style: TextStyle(
|
||||
color: Colors.amber.shade800,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (isSelected) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 2.4.1-beta+26
|
||||
version: 2.6.1-beta+29
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
Reference in New Issue
Block a user