Compare commits
98
Commits
@@ -0,0 +1,52 @@
|
|||||||
|
# Search Improvements
|
||||||
|
|
||||||
|
Branch: `feature/search-improvements`
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
- [x] Add playlists and audiobooks to search results
|
||||||
|
- Parse from MA API response
|
||||||
|
- Filter chips for each type
|
||||||
|
- Tile builders with navigation to detail screens
|
||||||
|
|
||||||
|
- [x] Unified Relevance-Based "All" Results
|
||||||
|
- Scoring algorithm implemented:
|
||||||
|
- Exact match: 100 points
|
||||||
|
- Starts with query: 80 points
|
||||||
|
- Contains query (word boundary): 60 points
|
||||||
|
- Contains query (anywhere): 40 points
|
||||||
|
- Fuzzy match: 20 points
|
||||||
|
- Bonus for library items (albums): +10 points
|
||||||
|
- Bonus for favorites: +5 points
|
||||||
|
- Secondary field matching (artist name in albums/tracks)
|
||||||
|
- Merged all results into single sorted list
|
||||||
|
- Type indicators in subtitle (e.g., "Artist • Album")
|
||||||
|
|
||||||
|
- [x] Smart Cross-Referencing
|
||||||
|
- Extracts unique artists from matched tracks/albums
|
||||||
|
- Filters to artists whose name contains query words (min 3 chars)
|
||||||
|
- Adds cross-referenced artists with lower relevance score (25)
|
||||||
|
- Avoids duplicates with direct search results
|
||||||
|
|
||||||
|
- [x] Past Searches Row
|
||||||
|
- Added SearchHistory table to database (schema v4)
|
||||||
|
- Saves successful searches (deduplicated, max 10)
|
||||||
|
- Displays as ActionChips in empty state
|
||||||
|
- Clicking chip performs that search
|
||||||
|
|
||||||
|
## Remaining Ideas (Lower Priority)
|
||||||
|
|
||||||
|
- **Library-only toggle:** Add switch to filter search to library items only (MA API supports this)
|
||||||
|
- **Voice search:** Add microphone button if platform supports speech recognition
|
||||||
|
- **Search suggestions:** Typeahead from library as user types
|
||||||
|
- **Quick actions:** Long-press result for play/queue options without navigating
|
||||||
|
- **Clear history in settings:** Add option to clear search history
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `lib/database/database.dart` | Added SearchHistory table, schema v4 |
|
||||||
|
| `lib/services/database_service.dart` | Added search history methods |
|
||||||
|
| `lib/screens/search_screen.dart` | Relevance scoring, cross-refs, history UI |
|
||||||
|
| `lib/l10n/app_en.arb` | Added localization strings |
|
||||||
+110
-2
@@ -153,12 +153,39 @@ class CachedQueue extends Table {
|
|||||||
IntColumn get position => integer()();
|
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};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search history for quick access to recent searches
|
||||||
|
class SearchHistory extends Table {
|
||||||
|
/// Auto-incrementing ID
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
|
||||||
|
/// The search query
|
||||||
|
TextColumn get query => text()();
|
||||||
|
|
||||||
|
/// When the search was performed
|
||||||
|
DateTimeColumn get searchedAt => dateTime()();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue, HomeRowCache, SearchHistory])
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 2;
|
int get schemaVersion => 4;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration {
|
MigrationStrategy get migration {
|
||||||
@@ -173,6 +200,14 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
await m.createTable(cachedPlayers);
|
await m.createTable(cachedPlayers);
|
||||||
await m.createTable(cachedQueue);
|
await m.createTable(cachedQueue);
|
||||||
}
|
}
|
||||||
|
// Migration from v2 to v3: Add home row cache table
|
||||||
|
if (from < 3) {
|
||||||
|
await m.createTable(homeRowCache);
|
||||||
|
}
|
||||||
|
// Migration from v3 to v4: Add search history table
|
||||||
|
if (from < 4) {
|
||||||
|
await m.createTable(searchHistory);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -524,4 +559,77 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
Future<void> clearAllCachedQueues() async {
|
Future<void> clearAllCachedQueues() async {
|
||||||
await delete(cachedQueue).go();
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Search History Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Save a search query to history (deduplicates and limits to 10)
|
||||||
|
Future<void> saveSearchQuery(String query) async {
|
||||||
|
final trimmedQuery = query.trim();
|
||||||
|
if (trimmedQuery.isEmpty) return;
|
||||||
|
|
||||||
|
await transaction(() async {
|
||||||
|
// Delete existing entry with same query (case-insensitive)
|
||||||
|
await (delete(searchHistory)
|
||||||
|
..where((s) => s.query.lower().equals(trimmedQuery.toLowerCase())))
|
||||||
|
.go();
|
||||||
|
|
||||||
|
// Insert the new search
|
||||||
|
await into(searchHistory).insert(SearchHistoryCompanion.insert(
|
||||||
|
query: trimmedQuery,
|
||||||
|
searchedAt: DateTime.now(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Keep only the 10 most recent searches
|
||||||
|
final allSearches = await (select(searchHistory)
|
||||||
|
..orderBy([(s) => OrderingTerm.desc(s.searchedAt)]))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (allSearches.length > 10) {
|
||||||
|
final toDelete = allSearches.skip(10).map((s) => s.id).toList();
|
||||||
|
await (delete(searchHistory)..where((s) => s.id.isIn(toDelete))).go();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get recent search queries (up to 10, most recent first)
|
||||||
|
Future<List<String>> getRecentSearches() async {
|
||||||
|
final searches = await (select(searchHistory)
|
||||||
|
..orderBy([(s) => OrderingTerm.desc(s.searchedAt)])
|
||||||
|
..limit(10))
|
||||||
|
.get();
|
||||||
|
return searches.map((s) => s.query).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all search history
|
||||||
|
Future<void> clearSearchHistory() async {
|
||||||
|
await delete(searchHistory).go();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -279,7 +279,13 @@
|
|||||||
"playerStateUnavailable": "Nicht verfügbar",
|
"playerStateUnavailable": "Nicht verfügbar",
|
||||||
"playerStateOff": "Aus",
|
"playerStateOff": "Aus",
|
||||||
"playerStateIdle": "Inaktiv",
|
"playerStateIdle": "Inaktiv",
|
||||||
|
"playerStateExternalSource": "Externe Quelle",
|
||||||
"playerSelected": "Ausgewählt",
|
"playerSelected": "Ausgewählt",
|
||||||
"actionQueuedForSync": "Wird synchronisiert wenn online",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-1
@@ -261,6 +261,26 @@
|
|||||||
"description": "Search hint text"
|
"description": "Search hint text"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"recentSearches": "Recent Searches",
|
||||||
|
"@recentSearches": {
|
||||||
|
"description": "Label for recent search history section"
|
||||||
|
},
|
||||||
|
|
||||||
|
"clearSearchHistory": "Clear Search History",
|
||||||
|
"@clearSearchHistory": {
|
||||||
|
"description": "Button to clear search history"
|
||||||
|
},
|
||||||
|
|
||||||
|
"searchHistoryCleared": "Search history cleared",
|
||||||
|
"@searchHistoryCleared": {
|
||||||
|
"description": "Confirmation message after clearing search history"
|
||||||
|
},
|
||||||
|
|
||||||
|
"libraryOnly": "Library only",
|
||||||
|
"@libraryOnly": {
|
||||||
|
"description": "Toggle to search only in library"
|
||||||
|
},
|
||||||
|
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"@retry": {
|
"@retry": {
|
||||||
"description": "Retry button label"
|
"description": "Retry button label"
|
||||||
@@ -610,6 +630,11 @@
|
|||||||
"description": "Radio button label"
|
"description": "Radio button label"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"addedToQueue": "Added to queue",
|
||||||
|
"@addedToQueue": {
|
||||||
|
"description": "Confirmation when item added to queue"
|
||||||
|
},
|
||||||
|
|
||||||
"inLibrary": "In Library",
|
"inLibrary": "In Library",
|
||||||
"@inLibrary": {
|
"@inLibrary": {
|
||||||
"description": "In library section title"
|
"description": "In library section title"
|
||||||
@@ -1078,7 +1103,7 @@
|
|||||||
"description": "Hint for Last.fm API key field"
|
"description": "Hint for Last.fm API key field"
|
||||||
},
|
},
|
||||||
|
|
||||||
"swipeToSwitchDevice": "Swipe to switch device",
|
"swipeToSwitchDevice": "Swipe to change player",
|
||||||
"@swipeToSwitchDevice": {
|
"@swipeToSwitchDevice": {
|
||||||
"description": "Hint for device switching gesture"
|
"description": "Hint for device switching gesture"
|
||||||
},
|
},
|
||||||
@@ -1133,6 +1158,16 @@
|
|||||||
"description": "Singular form of audiobook"
|
"description": "Singular form of audiobook"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"albumSingular": "Album",
|
||||||
|
"@albumSingular": {
|
||||||
|
"description": "Singular form of album for search type indicator"
|
||||||
|
},
|
||||||
|
|
||||||
|
"trackSingular": "Track",
|
||||||
|
"@trackSingular": {
|
||||||
|
"description": "Singular form of track for search type indicator"
|
||||||
|
},
|
||||||
|
|
||||||
"trackCount": "{count} {count, plural, =1{track} other{tracks}}",
|
"trackCount": "{count} {count, plural, =1{track} other{tracks}}",
|
||||||
"@trackCount": {
|
"@trackCount": {
|
||||||
"description": "Track count with plural",
|
"description": "Track count with plural",
|
||||||
@@ -1193,6 +1228,11 @@
|
|||||||
"description": "Player state when idle"
|
"description": "Player state when idle"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"playerStateExternalSource": "External Source",
|
||||||
|
"@playerStateExternalSource": {
|
||||||
|
"description": "Player state when playing from external source (optical, Spotify, etc.)"
|
||||||
|
},
|
||||||
|
|
||||||
"playerSelected": "Selected",
|
"playerSelected": "Selected",
|
||||||
"@playerSelected": {
|
"@playerSelected": {
|
||||||
"description": "Label for selected player"
|
"description": "Label for selected player"
|
||||||
@@ -1211,5 +1251,55 @@
|
|||||||
"type": "int"
|
"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
@@ -434,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.",
|
"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",
|
"lastFmApiKey": "Clave API de Last.fm",
|
||||||
"lastFmApiKeyHint": "Obtén una clave gratuita en last.fm/api",
|
"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": "Capítulo {number}",
|
||||||
"@chapterNumber": {
|
"@chapterNumber": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -475,7 +475,13 @@
|
|||||||
"playerStateUnavailable": "No disponible",
|
"playerStateUnavailable": "No disponible",
|
||||||
"playerStateOff": "Apagado",
|
"playerStateOff": "Apagado",
|
||||||
"playerStateIdle": "Inactivo",
|
"playerStateIdle": "Inactivo",
|
||||||
|
"playerStateExternalSource": "Fuente Externa",
|
||||||
"playerSelected": "Seleccionado",
|
"playerSelected": "Seleccionado",
|
||||||
"actionQueuedForSync": "Se sincronizará cuando esté en línea",
|
"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 {
|
class AppStartup extends StatefulWidget {
|
||||||
const AppStartup({super.key});
|
const AppStartup({super.key});
|
||||||
|
|
||||||
@@ -361,9 +370,32 @@ class _AppStartupState extends State<AppStartup> {
|
|||||||
String? _savedServerUrl;
|
String? _savedServerUrl;
|
||||||
bool _connectionAttempted = false;
|
bool _connectionAttempted = false;
|
||||||
|
|
||||||
|
// Welcome system state - loaded FIRST before connection check
|
||||||
|
bool _settingsLoaded = false;
|
||||||
|
bool _hasCompletedOnboarding = true; // Default to true (no welcome needed)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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();
|
_checkAndConnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,14 +420,30 @@ class _AppStartupState extends State<AppStartup> {
|
|||||||
// Just wait for it to complete or timeout
|
// Just wait for it to complete or timeout
|
||||||
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
|
_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)
|
// Give the provider time to connect (it restores credentials and connects in _initialize)
|
||||||
// Check connection state periodically
|
// Check connection state periodically
|
||||||
for (var i = 0; i < 20; i++) {
|
for (var i = 0; i < 20; i++) {
|
||||||
await Future.delayed(const Duration(milliseconds: 250));
|
await Future.delayed(const Duration(milliseconds: 250));
|
||||||
if (provider.isConnected) {
|
|
||||||
_logger.log('🚀 AppStartup: Connection established');
|
if (needsWelcome) {
|
||||||
break;
|
// 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) {
|
if (provider.connectionState == MAConnectionState.error) {
|
||||||
_logger.log('🚀 AppStartup: Connection failed with error');
|
_logger.log('🚀 AppStartup: Connection failed with error');
|
||||||
break;
|
break;
|
||||||
@@ -422,7 +470,8 @@ class _AppStartupState extends State<AppStartup> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Show loading while checking settings or connecting
|
// 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(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
backgroundColor: const Color(0xFF1a1a1a),
|
||||||
body: Center(
|
body: Center(
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ class Player {
|
|||||||
final double? elapsedTimeLastUpdated; // Unix timestamp when elapsed_time was last updated
|
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 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)
|
final String? syncedTo; // Player ID this player is synced to (null if leader or not synced)
|
||||||
|
final String? activeSource; // The currently active source for this player
|
||||||
|
final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active
|
||||||
|
final String? appId; // The app_id from MA - 'music_assistant' when MA is playing, else external source
|
||||||
|
|
||||||
Player({
|
Player({
|
||||||
required this.playerId,
|
required this.playerId,
|
||||||
@@ -29,6 +32,9 @@ class Player {
|
|||||||
this.elapsedTimeLastUpdated,
|
this.elapsedTimeLastUpdated,
|
||||||
this.groupMembers,
|
this.groupMembers,
|
||||||
this.syncedTo,
|
this.syncedTo,
|
||||||
|
this.activeSource,
|
||||||
|
this.isExternalSource = false,
|
||||||
|
this.appId,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Create a copy of this Player with some fields replaced
|
/// Create a copy of this Player with some fields replaced
|
||||||
@@ -46,6 +52,9 @@ class Player {
|
|||||||
double? elapsedTimeLastUpdated,
|
double? elapsedTimeLastUpdated,
|
||||||
List<String>? groupMembers,
|
List<String>? groupMembers,
|
||||||
String? syncedTo,
|
String? syncedTo,
|
||||||
|
String? activeSource,
|
||||||
|
bool? isExternalSource,
|
||||||
|
String? appId,
|
||||||
}) {
|
}) {
|
||||||
return Player(
|
return Player(
|
||||||
playerId: playerId ?? this.playerId,
|
playerId: playerId ?? this.playerId,
|
||||||
@@ -61,6 +70,9 @@ class Player {
|
|||||||
elapsedTimeLastUpdated: elapsedTimeLastUpdated ?? this.elapsedTimeLastUpdated,
|
elapsedTimeLastUpdated: elapsedTimeLastUpdated ?? this.elapsedTimeLastUpdated,
|
||||||
groupMembers: groupMembers ?? this.groupMembers,
|
groupMembers: groupMembers ?? this.groupMembers,
|
||||||
syncedTo: syncedTo ?? this.syncedTo,
|
syncedTo: syncedTo ?? this.syncedTo,
|
||||||
|
activeSource: activeSource ?? this.activeSource,
|
||||||
|
isExternalSource: isExternalSource ?? this.isExternalSource,
|
||||||
|
appId: appId ?? this.appId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +197,61 @@ class Player {
|
|||||||
// Parse synced_to - the player ID this player is synced to
|
// Parse synced_to - the player ID this player is synced to
|
||||||
final syncedTo = json['synced_to'] as String?;
|
final syncedTo = json['synced_to'] as String?;
|
||||||
|
|
||||||
|
// Parse active_source - the currently active source for this player
|
||||||
|
final activeSource = json['active_source'] as String?;
|
||||||
|
|
||||||
|
// Detect external source (optical, Spotify, AirPlay, etc.)
|
||||||
|
// Primary indicator: app_id - if not 'music_assistant', it's an external source
|
||||||
|
// Secondary indicators: URI patterns and media_type
|
||||||
|
bool isExternalSource = false;
|
||||||
|
|
||||||
|
// Check app_id first - this is the most reliable indicator for DLNA players
|
||||||
|
// When MA is playing: app_id = 'music_assistant'
|
||||||
|
// When external source: app_id = 'http', 'spotify', etc.
|
||||||
|
final appId = json['app_id'] as String?;
|
||||||
|
if (appId != null && appId.isNotEmpty && appId != 'music_assistant') {
|
||||||
|
// app_id is set but not 'music_assistant' - external source is active
|
||||||
|
isExternalSource = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check current_media for additional external source indicators
|
||||||
|
if (!isExternalSource && json.containsKey('current_media')) {
|
||||||
|
final currentMedia = json['current_media'] as Map<String, dynamic>?;
|
||||||
|
if (currentMedia != null) {
|
||||||
|
final uri = currentMedia['uri'] as String?;
|
||||||
|
final mediaType = currentMedia['media_type'] as String?;
|
||||||
|
|
||||||
|
// External source indicators:
|
||||||
|
// 1. URI is a simple identifier like 'optical', 'line_in', 'bluetooth', 'hdmi'
|
||||||
|
// 2. URI starts with external protocols: 'spotify://', 'airplay://', etc.
|
||||||
|
// 3. Media type is 'unknown' (MA doesn't recognize the source)
|
||||||
|
if (uri != null) {
|
||||||
|
final uriLower = uri.toLowerCase();
|
||||||
|
// Simple external source identifiers (no :// or /)
|
||||||
|
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
|
||||||
|
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
|
||||||
|
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
|
||||||
|
uriLower == 'coaxial' || uriLower == 'toslink');
|
||||||
|
// External streaming protocols
|
||||||
|
final isExternalProtocol = uriLower.startsWith('spotify://') ||
|
||||||
|
uriLower.startsWith('airplay://') ||
|
||||||
|
uriLower.startsWith('cast://') ||
|
||||||
|
uriLower.startsWith('bluetooth://');
|
||||||
|
|
||||||
|
isExternalSource = isSimpleExternalId || isExternalProtocol;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check media_type - 'unknown' often indicates external source
|
||||||
|
if (!isExternalSource && mediaType == 'unknown') {
|
||||||
|
// If media_type is unknown and URI doesn't look like MA content, it's external
|
||||||
|
final uri = currentMedia['uri'] as String?;
|
||||||
|
if (uri != null && !uri.startsWith('library://') && !uri.contains('://track/')) {
|
||||||
|
isExternalSource = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Player(
|
return Player(
|
||||||
playerId: json['player_id'] as String,
|
playerId: json['player_id'] as String,
|
||||||
name: json['name'] as String,
|
name: json['name'] as String,
|
||||||
@@ -199,6 +266,9 @@ class Player {
|
|||||||
elapsedTimeLastUpdated: elapsedTimeLastUpdated,
|
elapsedTimeLastUpdated: elapsedTimeLastUpdated,
|
||||||
groupMembers: groupMembers,
|
groupMembers: groupMembers,
|
||||||
syncedTo: syncedTo,
|
syncedTo: syncedTo,
|
||||||
|
activeSource: activeSource,
|
||||||
|
isExternalSource: isExternalSource,
|
||||||
|
appId: appId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +287,9 @@ class Player {
|
|||||||
if (elapsedTimeLastUpdated != null) 'elapsed_time_last_updated': elapsedTimeLastUpdated,
|
if (elapsedTimeLastUpdated != null) 'elapsed_time_last_updated': elapsedTimeLastUpdated,
|
||||||
if (groupMembers != null) 'group_members': groupMembers,
|
if (groupMembers != null) 'group_members': groupMembers,
|
||||||
if (syncedTo != null) 'synced_to': syncedTo,
|
if (syncedTo != null) 'synced_to': syncedTo,
|
||||||
|
if (activeSource != null) 'active_source': activeSource,
|
||||||
|
'is_external_source': isExternalSource,
|
||||||
|
if (appId != null) 'app_id': appId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
List<Artist> _artists = [];
|
List<Artist> _artists = [];
|
||||||
List<Album> _albums = [];
|
List<Album> _albums = [];
|
||||||
List<Track> _tracks = [];
|
List<Track> _tracks = [];
|
||||||
|
List<Track> _cachedFavoriteTracks = []; // Cached for instant display before full library loads
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
// Player state
|
// Player state
|
||||||
@@ -221,6 +222,15 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// Load cached players from database for instant display (before connecting)
|
// Load cached players from database for instant display (before connecting)
|
||||||
await _loadPlayersFromDatabase();
|
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
|
// Initialize offline action queue
|
||||||
await OfflineActionQueue.instance.initialize();
|
await OfflineActionQueue.instance.initialize();
|
||||||
|
|
||||||
@@ -272,12 +282,122 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
_logger.log('📦 Loaded ${players.length} players from database (instant)');
|
_logger.log('📦 Loaded ${players.length} players from database (instant)');
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
|
// Also load saved playback state for instant track display
|
||||||
|
if (_selectedPlayer != null) {
|
||||||
|
await _loadPlaybackStateFromDatabase();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('⚠️ Error loading players from database: $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
|
/// Persist players to database for app restart persistence
|
||||||
void _persistPlayersToDatabase(List<Player> players) {
|
void _persistPlayersToDatabase(List<Player> players) {
|
||||||
// Run async but don't await - this is fire-and-forget persistence
|
// Run async but don't await - this is fire-and-forget persistence
|
||||||
@@ -477,7 +597,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
await _tryAdoptGhostPlayer();
|
await _tryAdoptGhostPlayer();
|
||||||
await _registerLocalPlayer();
|
await _registerLocalPlayer();
|
||||||
await _loadAndSelectPlayers();
|
await _loadAndSelectPlayers(coldStart: true);
|
||||||
|
|
||||||
loadLibrary();
|
loadLibrary();
|
||||||
|
|
||||||
@@ -665,6 +785,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// This makes mini player and device button appear instantly on app resume
|
// This makes mini player and device button appear instantly on app resume
|
||||||
if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) {
|
if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) {
|
||||||
_availablePlayers = _cacheService.getCachedPlayers()!;
|
_availablePlayers = _cacheService.getCachedPlayers()!;
|
||||||
|
|
||||||
|
// Sort cached players immediately so list appears in correct order
|
||||||
|
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||||
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
|
_sortPlayersSync(_availablePlayers, smartSort, builtinPlayerId);
|
||||||
|
|
||||||
_selectedPlayer = _cacheService.getCachedSelectedPlayer();
|
_selectedPlayer = _cacheService.getCachedSelectedPlayer();
|
||||||
// Also try to restore from settings if cache doesn't have selected player
|
// Also try to restore from settings if cache doesn't have selected player
|
||||||
if (_selectedPlayer == null && _availablePlayers.isNotEmpty) {
|
if (_selectedPlayer == null && _availablePlayers.isNotEmpty) {
|
||||||
@@ -681,7 +807,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_selectedPlayer = _availablePlayers.first;
|
_selectedPlayer = _availablePlayers.first;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_logger.log('⚡ Loaded ${_availablePlayers.length} cached players instantly');
|
_logger.log('⚡ Loaded ${_availablePlayers.length} cached players instantly (sorted)');
|
||||||
notifyListeners(); // Update UI immediately with cached data
|
notifyListeners(); // Update UI immediately with cached data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1470,6 +1596,37 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
if (currentMedia != null) {
|
if (currentMedia != null) {
|
||||||
final mediaType = currentMedia['media_type'] as String?;
|
final mediaType = currentMedia['media_type'] as String?;
|
||||||
|
final uri = currentMedia['uri'] as String?;
|
||||||
|
|
||||||
|
// Check for external source (optical, Spotify, etc.) - skip caching stale metadata
|
||||||
|
bool isExternalSource = false;
|
||||||
|
if (uri != null) {
|
||||||
|
final uriLower = uri.toLowerCase();
|
||||||
|
// Simple external source identifiers (no :// or /)
|
||||||
|
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
|
||||||
|
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
|
||||||
|
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
|
||||||
|
uriLower == 'coaxial' || uriLower == 'toslink');
|
||||||
|
// External streaming protocols
|
||||||
|
final isExternalProtocol = uriLower.startsWith('spotify://') ||
|
||||||
|
uriLower.startsWith('airplay://') ||
|
||||||
|
uriLower.startsWith('cast://') ||
|
||||||
|
uriLower.startsWith('bluetooth://');
|
||||||
|
isExternalSource = isSimpleExternalId || isExternalProtocol;
|
||||||
|
|
||||||
|
// Also treat unknown media type with non-MA URIs as external
|
||||||
|
if (!isExternalSource && mediaType == 'unknown' &&
|
||||||
|
!uri.startsWith('library://') && !uri.contains('://track/')) {
|
||||||
|
isExternalSource = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExternalSource) {
|
||||||
|
_logger.log('📡 External source detected for $playerName (uri: $uri) - skipping metadata cache');
|
||||||
|
// Clear cached track for this player to avoid showing stale data
|
||||||
|
_cacheService.clearCachedTrackForPlayer(playerId);
|
||||||
|
return; // Skip further processing for external sources
|
||||||
|
}
|
||||||
|
|
||||||
// Clear audiobook context when switching to music (track) playback
|
// Clear audiobook context when switching to music (track) playback
|
||||||
if (mediaType == 'track' && _currentAudiobook != null) {
|
if (mediaType == 'track' && _currentAudiobook != null) {
|
||||||
@@ -1758,8 +1915,18 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Get favorite tracks from the library
|
/// Get favorite tracks from the library
|
||||||
Future<List<Track>> getFavoriteTracks() async {
|
Future<List<Track>> getFavoriteTracks() async {
|
||||||
// Filter from loaded library - favorites are already loaded
|
// If full tracks list is loaded, filter from it
|
||||||
return _tracks.where((t) => t.favorite == true).toList();
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1928,27 +2095,32 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// SEARCH CACHING
|
// SEARCH CACHING
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
Future<Map<String, List<MediaItem>>> searchWithCache(String query, {bool forceRefresh = false}) async {
|
Future<Map<String, List<MediaItem>>> searchWithCache(String query, {bool forceRefresh = false, bool libraryOnly = false}) async {
|
||||||
final cacheKey = query.toLowerCase().trim();
|
final baseKey = query.toLowerCase().trim();
|
||||||
if (cacheKey.isEmpty) return {'artists': [], 'albums': [], 'tracks': []};
|
if (baseKey.isEmpty) return {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
||||||
|
|
||||||
|
// Include libraryOnly in cache key to separate results
|
||||||
|
final cacheKey = libraryOnly ? '$baseKey:library' : baseKey;
|
||||||
|
|
||||||
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||||
_logger.log('📦 Using cached search results for "$query"');
|
_logger.log('📦 Using cached search results for "$query" (libraryOnly: $libraryOnly)');
|
||||||
return _cacheService.getCachedSearchResults(cacheKey)!;
|
return _cacheService.getCachedSearchResults(cacheKey)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_api == null) {
|
if (_api == null) {
|
||||||
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
|
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_logger.log('🔄 Searching for "$query"...');
|
_logger.log('🔄 Searching for "$query" (libraryOnly: $libraryOnly)...');
|
||||||
final results = await _api!.search(query);
|
final results = await _api!.search(query, libraryOnly: libraryOnly);
|
||||||
|
|
||||||
final cachedResults = <String, List<MediaItem>>{
|
final cachedResults = <String, List<MediaItem>>{
|
||||||
'artists': results['artists'] ?? [],
|
'artists': results['artists'] ?? [],
|
||||||
'albums': results['albums'] ?? [],
|
'albums': results['albums'] ?? [],
|
||||||
'tracks': results['tracks'] ?? [],
|
'tracks': results['tracks'] ?? [],
|
||||||
|
'playlists': results['playlists'] ?? [],
|
||||||
|
'audiobooks': results['audiobooks'] ?? [],
|
||||||
};
|
};
|
||||||
|
|
||||||
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
|
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
|
||||||
@@ -1956,7 +2128,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
return cachedResults;
|
return cachedResults;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('❌ Search failed: $e');
|
_logger.log('❌ Search failed: $e');
|
||||||
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
|
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1991,9 +2163,42 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
return await SettingsService.getBuiltinPlayerId();
|
return await SettingsService.getBuiltinPlayerId();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadAndSelectPlayers({bool forceRefresh = false}) async {
|
/// Sort players list based on smart sort setting
|
||||||
|
/// Can be called synchronously with pre-fetched settings for cached players
|
||||||
|
void _sortPlayersSync(List<Player> players, bool smartSort, String? builtinPlayerId) {
|
||||||
|
if (smartSort) {
|
||||||
|
// Smart sort: local player first, then playing, then on, then off
|
||||||
|
players.sort((a, b) {
|
||||||
|
// Local player always first
|
||||||
|
final aIsLocal = builtinPlayerId != null && a.playerId == builtinPlayerId;
|
||||||
|
final bIsLocal = builtinPlayerId != null && b.playerId == builtinPlayerId;
|
||||||
|
if (aIsLocal && !bIsLocal) return -1;
|
||||||
|
if (bIsLocal && !aIsLocal) return 1;
|
||||||
|
|
||||||
|
// Then by status: playing > on > off
|
||||||
|
int statusPriority(Player p) {
|
||||||
|
if (p.state == 'playing') return 0;
|
||||||
|
if (p.powered && p.state != 'off') return 1;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
final aPriority = statusPriority(a);
|
||||||
|
final bPriority = statusPriority(b);
|
||||||
|
if (aPriority != bPriority) return aPriority.compareTo(bPriority);
|
||||||
|
|
||||||
|
// Within same status, sort alphabetically
|
||||||
|
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Default alphabetical sort
|
||||||
|
players.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadAndSelectPlayers({bool forceRefresh = false, bool coldStart = false}) async {
|
||||||
try {
|
try {
|
||||||
|
// Don't skip on coldStart - we need to apply full auto-selection priority logic
|
||||||
if (!forceRefresh &&
|
if (!forceRefresh &&
|
||||||
|
!coldStart &&
|
||||||
_cacheService.isPlayersCacheValid() &&
|
_cacheService.isPlayersCacheValid() &&
|
||||||
_availablePlayers.isNotEmpty) {
|
_availablePlayers.isNotEmpty) {
|
||||||
return;
|
return;
|
||||||
@@ -2057,8 +2262,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
.where((p) => p.name.endsWith(sendspinSuffix))
|
.where((p) => p.name.endsWith(sendspinSuffix))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Clear the Cast-to-Sendspin ID mapping
|
// NOTE: Don't clear _castToSendspinIdMap - we want to remember mappings
|
||||||
_castToSendspinIdMap = {};
|
// 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) {
|
if (sendspinPlayers.isNotEmpty) {
|
||||||
// Build maps for Sendspin players and their grouped status
|
// Build maps for Sendspin players and their grouped status
|
||||||
@@ -2122,34 +2328,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort players - check if smart sort is enabled
|
// Sort players using helper method
|
||||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||||
if (smartSort) {
|
_sortPlayersSync(_availablePlayers, smartSort, builtinPlayerId);
|
||||||
// Smart sort: local player first, then playing, then on, then off
|
|
||||||
_availablePlayers.sort((a, b) {
|
|
||||||
// Local player always first
|
|
||||||
final aIsLocal = builtinPlayerId != null && a.playerId == builtinPlayerId;
|
|
||||||
final bIsLocal = builtinPlayerId != null && b.playerId == builtinPlayerId;
|
|
||||||
if (aIsLocal && !bIsLocal) return -1;
|
|
||||||
if (bIsLocal && !aIsLocal) return 1;
|
|
||||||
|
|
||||||
// Then by status: playing > on > off
|
|
||||||
int statusPriority(Player p) {
|
|
||||||
if (p.state == 'playing') return 0;
|
|
||||||
if (p.powered && p.state != 'off') return 1;
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
final aPriority = statusPriority(a);
|
|
||||||
final bPriority = statusPriority(b);
|
|
||||||
if (aPriority != bPriority) return aPriority.compareTo(bPriority);
|
|
||||||
|
|
||||||
// Within same status, sort alphabetically
|
|
||||||
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Default alphabetical sort
|
|
||||||
_availablePlayers.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache players for instant display on app resume
|
// Cache players for instant display on app resume
|
||||||
_cacheService.setCachedPlayers(_availablePlayers);
|
_cacheService.setCachedPlayers(_availablePlayers);
|
||||||
@@ -2179,7 +2360,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Keep currently selected player if still available (and not overridden by prefer local)
|
// Keep currently selected player if still available (and not overridden by prefer local)
|
||||||
// But allow switching to a playing player when preferLocalPlayer is OFF
|
// But allow switching to a playing player when preferLocalPlayer is OFF
|
||||||
if (playerToSelect == null && _selectedPlayer != null) {
|
// On coldStart, skip this block and apply full priority logic (playing > local > last selected)
|
||||||
|
if (playerToSelect == null && _selectedPlayer != null && !coldStart) {
|
||||||
final stillAvailable = _availablePlayers.any(
|
final stillAvailable = _availablePlayers.any(
|
||||||
(p) => p.playerId == _selectedPlayer!.playerId && p.available,
|
(p) => p.playerId == _selectedPlayer!.playerId && p.available,
|
||||||
);
|
);
|
||||||
@@ -2190,8 +2372,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
.firstWhere((p) => p.playerId == _selectedPlayer!.playerId)
|
.firstWhere((p) => p.playerId == _selectedPlayer!.playerId)
|
||||||
.state;
|
.state;
|
||||||
final currentIsPlaying = currentPlayerState == 'playing';
|
final currentIsPlaying = currentPlayerState == 'playing';
|
||||||
|
// Exclude external sources - they're not playing MA content
|
||||||
final playingPlayers = _availablePlayers.where(
|
final playingPlayers = _availablePlayers.where(
|
||||||
(p) => p.state == 'playing' && p.available,
|
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
|
||||||
).toList();
|
).toList();
|
||||||
|
|
||||||
// Switch to playing player only if current isn't playing and exactly one other is
|
// Switch to playing player only if current isn't playing and exactly one other is
|
||||||
@@ -2210,14 +2393,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (coldStart) {
|
||||||
|
_logger.log('🚀 Cold start: applying full priority logic (playing > local > last selected)');
|
||||||
|
}
|
||||||
|
|
||||||
if (playerToSelect == null) {
|
if (playerToSelect == null) {
|
||||||
// Smart auto-selection priority:
|
// Smart auto-selection priority:
|
||||||
// If "Prefer Local Player" is OFF: Single playing player -> Local player -> Last selected -> First available
|
// If "Prefer Local Player" is OFF: Single playing player -> Local player -> Last selected -> First available
|
||||||
|
|
||||||
if (!preferLocalPlayer) {
|
if (!preferLocalPlayer) {
|
||||||
// Priority 1 (normal): Single playing player (skip if multiple are playing)
|
// Priority 1 (normal): Single playing player (skip if multiple are playing)
|
||||||
|
// Exclude external sources - they're not playing MA content
|
||||||
final playingPlayers = _availablePlayers.where(
|
final playingPlayers = _availablePlayers.where(
|
||||||
(p) => p.state == 'playing' && p.available,
|
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
|
||||||
).toList();
|
).toList();
|
||||||
if (playingPlayers.length == 1) {
|
if (playingPlayers.length == 1) {
|
||||||
playerToSelect = playingPlayers.first;
|
playerToSelect = playingPlayers.first;
|
||||||
@@ -2231,7 +2419,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
(p) => p.playerId == builtinPlayerId && p.available,
|
(p) => p.playerId == builtinPlayerId && p.available,
|
||||||
);
|
);
|
||||||
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
|
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
|
||||||
} catch (e) {}
|
} catch (e) {
|
||||||
|
// Local player not found or not available - check why
|
||||||
|
final localPlayer = _availablePlayers
|
||||||
|
.where((p) => p.playerId == builtinPlayerId)
|
||||||
|
.toList();
|
||||||
|
if (localPlayer.isEmpty) {
|
||||||
|
_logger.log('⚠️ Priority 2 skipped: local player ($builtinPlayerId) not in available players list');
|
||||||
|
} else {
|
||||||
|
_logger.log('⚠️ Priority 2 skipped: local player found but available=${localPlayer.first.available}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (playerToSelect == null && builtinPlayerId == null) {
|
||||||
|
_logger.log('⚠️ Priority 2 skipped: builtinPlayerId is null');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2278,17 +2478,24 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// IMPORTANT: Always set from cache, even if null - this prevents showing
|
// IMPORTANT: Always set from cache, even if null - this prevents showing
|
||||||
// stale track info when switching to a non-playing player.
|
// stale track info when switching to a non-playing player.
|
||||||
// For grouped child players, this returns the leader's track
|
// For grouped child players, this returns the leader's track
|
||||||
_currentTrack = getCachedTrackForPlayer(player.playerId);
|
// IMPORTANT: Clear track for external sources - they're playing non-MA content
|
||||||
|
if (player.isExternalSource) {
|
||||||
|
_currentTrack = null;
|
||||||
|
} else {
|
||||||
|
_currentTrack = getCachedTrackForPlayer(player.playerId);
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize position tracker for this player
|
// Initialize position tracker for this player (skip for external sources)
|
||||||
_positionTracker.onPlayerSelected(player.playerId);
|
_positionTracker.onPlayerSelected(player.playerId);
|
||||||
_positionTracker.updateFromServer(
|
if (!player.isExternalSource) {
|
||||||
playerId: player.playerId,
|
_positionTracker.updateFromServer(
|
||||||
position: player.elapsedTime ?? 0.0,
|
playerId: player.playerId,
|
||||||
isPlaying: player.state == 'playing',
|
position: player.elapsedTime ?? 0.0,
|
||||||
duration: _currentTrack?.duration,
|
isPlaying: player.state == 'playing',
|
||||||
serverTimestamp: player.elapsedTimeLastUpdated,
|
duration: _currentTrack?.duration,
|
||||||
);
|
serverTimestamp: player.elapsedTimeLastUpdated,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Switch audio handler mode based on player type
|
// Switch audio handler mode based on player type
|
||||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
@@ -2572,7 +2779,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
if (_api == null) return;
|
if (_api == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_logger.log('🔍 Preload ${player.name}: state=${player.state}, available=${player.available}');
|
// Log app_id for playing players to help diagnose external source detection
|
||||||
|
if (player.state == 'playing') {
|
||||||
|
_logger.log('🔍 Preload ${player.name}: state=${player.state}, app_id=${player.appId}, isExternalSource=${player.isExternalSource}');
|
||||||
|
} else {
|
||||||
|
_logger.log('🔍 Preload ${player.name}: state=${player.state}, available=${player.available}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip external sources - they're not playing MA content
|
||||||
|
if (player.isExternalSource) {
|
||||||
|
_logger.log('🔍 Preload ${player.name}: SKIPPED - external source (app_id=${player.appId})');
|
||||||
|
_cacheService.setCachedTrackForPlayer(player.playerId, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!player.available || !player.powered) {
|
if (!player.available || !player.powered) {
|
||||||
_logger.log('🔍 Preload ${player.name}: SKIPPED - not available or powered');
|
_logger.log('🔍 Preload ${player.name}: SKIPPED - not available or powered');
|
||||||
@@ -2704,6 +2923,28 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_selectedPlayer = updatedPlayer;
|
_selectedPlayer = updatedPlayer;
|
||||||
stateChanged = true;
|
stateChanged = true;
|
||||||
|
|
||||||
|
// Handle external sources (Spotify Connect, TV optical, etc.)
|
||||||
|
// These players are "playing" from MA's perspective but not MA content
|
||||||
|
if (updatedPlayer.isExternalSource) {
|
||||||
|
if (_currentTrack != null) {
|
||||||
|
_currentTrack = null;
|
||||||
|
_cacheService.setCachedTrackForPlayer(updatedPlayer.playerId, null);
|
||||||
|
stateChanged = true;
|
||||||
|
_persistPlaybackState();
|
||||||
|
}
|
||||||
|
if (_currentAudiobook != null) {
|
||||||
|
_logger.log('📚 External source active, clearing audiobook context');
|
||||||
|
_currentAudiobook = null;
|
||||||
|
stateChanged = true;
|
||||||
|
}
|
||||||
|
// Clear notification for external source
|
||||||
|
audioHandler.clearRemotePlaybackState();
|
||||||
|
if (stateChanged) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Feed position tracker with server data
|
// Feed position tracker with server data
|
||||||
// Log raw values to debug position issues
|
// Log raw values to debug position issues
|
||||||
final rawElapsedTime = updatedPlayer.elapsedTime;
|
final rawElapsedTime = updatedPlayer.elapsedTime;
|
||||||
@@ -2728,6 +2969,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
if (_currentTrack != null) {
|
if (_currentTrack != null) {
|
||||||
_currentTrack = null;
|
_currentTrack = null;
|
||||||
stateChanged = true;
|
stateChanged = true;
|
||||||
|
// Persist cleared track state
|
||||||
|
_persistPlaybackState();
|
||||||
}
|
}
|
||||||
// Clear audiobook context when playback stops
|
// Clear audiobook context when playback stops
|
||||||
if (_currentAudiobook != null) {
|
if (_currentAudiobook != null) {
|
||||||
@@ -2798,6 +3041,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
stateChanged = true;
|
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
|
// Clear audiobook context if switched to a different media item
|
||||||
// Check if the new track is from the same audiobook (by comparing URIs)
|
// Check if the new track is from the same audiobook (by comparing URIs)
|
||||||
if (_currentAudiobook != null) {
|
if (_currentAudiobook != null) {
|
||||||
@@ -2868,6 +3114,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
if (_currentTrack != null) {
|
if (_currentTrack != null) {
|
||||||
_currentTrack = null;
|
_currentTrack = null;
|
||||||
stateChanged = true;
|
stateChanged = true;
|
||||||
|
// Persist cleared track state
|
||||||
|
_persistPlaybackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show player name placeholder so notification shows correct player
|
// Show player name placeholder so notification shows correct player
|
||||||
@@ -3229,6 +3477,18 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> addTrackToQueue(String playerId, Track track) async {
|
||||||
|
try {
|
||||||
|
await _api?.addTrackToQueue(playerId, track);
|
||||||
|
} catch (e) {
|
||||||
|
final errorInfo = ErrorHandler.handleError(e, context: 'Add to queue');
|
||||||
|
_error = errorInfo.userMessage;
|
||||||
|
ErrorHandler.logError('Add to queue', e);
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex, bool clearQueue = true}) async {
|
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex, bool clearQueue = true}) async {
|
||||||
try {
|
try {
|
||||||
await _api?.playTracks(playerId, tracks, startIndex: startIndex, clearQueue: clearQueue);
|
await _api?.playTracks(playerId, tracks, startIndex: startIndex, clearQueue: clearQueue);
|
||||||
@@ -3476,6 +3736,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle sync state: if player is synced, unsync it; otherwise sync to selected
|
/// 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 {
|
Future<void> togglePlayerSync(String playerId) async {
|
||||||
_logger.log('🔗 togglePlayerSync called for: $playerId');
|
_logger.log('🔗 togglePlayerSync called for: $playerId');
|
||||||
|
|
||||||
@@ -3487,13 +3748,52 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
_logger.log('🔗 Player found: ${player.name}, isGrouped: ${player.isGrouped}');
|
_logger.log('🔗 Player found: ${player.name}, isGrouped: ${player.isGrouped}');
|
||||||
_logger.log('🔗 groupMembers: ${player.groupMembers}, syncedTo: ${player.syncedTo}');
|
_logger.log('🔗 groupMembers: ${player.groupMembers}, syncedTo: ${player.syncedTo}');
|
||||||
|
_logger.log('🔗 powered: ${player.powered}, available: ${player.available}');
|
||||||
|
|
||||||
if (player.isGrouped) {
|
if (player.isGrouped) {
|
||||||
_logger.log('🔓 Player is grouped, unsyncing...');
|
_logger.log('🔓 Player is grouped, unsyncing...');
|
||||||
await unsyncPlayer(playerId);
|
await unsyncPlayer(playerId);
|
||||||
} else {
|
} else {
|
||||||
_logger.log('🔗 Player not grouped, syncing to selected...');
|
// Check if this is a Cast player with a Sendspin counterpart that's powered off
|
||||||
await syncPlayerToSelected(playerId);
|
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) {
|
} catch (e) {
|
||||||
_logger.log('❌ togglePlayerSync error: $e');
|
_logger.log('❌ togglePlayerSync error: $e');
|
||||||
@@ -3690,10 +3990,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> toggleShuffle(String queueId) async {
|
Future<void> toggleShuffle(String queueId, bool shuffleEnabled) async {
|
||||||
|
_logger.log('🔀 toggleShuffle called: queueId=$queueId, shuffleEnabled=$shuffleEnabled');
|
||||||
try {
|
try {
|
||||||
await _api?.toggleShuffle(queueId);
|
await _api?.toggleShuffle(queueId, shuffleEnabled);
|
||||||
|
_logger.log('🔀 toggleShuffle: API call completed');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
_logger.log('🔀 toggleShuffle ERROR: $e');
|
||||||
ErrorHandler.logError('Toggle shuffle', e);
|
ErrorHandler.logError('Toggle shuffle', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -575,10 +575,11 @@ class PlayerProvider with ChangeNotifier {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try playing player (exclude external sources - not playing MA content)
|
||||||
if (playerToSelect == null) {
|
if (playerToSelect == null) {
|
||||||
try {
|
try {
|
||||||
playerToSelect = _availablePlayers.firstWhere(
|
playerToSelect = _availablePlayers.firstWhere(
|
||||||
(p) => p.state == 'playing' && p.available,
|
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
|
||||||
);
|
);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
@@ -906,10 +907,10 @@ class PlayerProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> toggleShuffle(String queueId) async {
|
Future<void> toggleShuffle(String queueId, bool shuffleEnabled) async {
|
||||||
if (_api == null) return;
|
if (_api == null) return;
|
||||||
try {
|
try {
|
||||||
await _api!.toggleShuffle(queueId);
|
await _api!.toggleShuffle(queueId, shuffleEnabled);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ErrorHandler.logError('Toggle shuffle', e);
|
ErrorHandler.logError('Toggle shuffle', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
|
|||||||
@@ -558,7 +558,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -703,10 +703,16 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: colorScheme.background,
|
backgroundColor: colorScheme.background,
|
||||||
body: CustomScrollView(
|
body: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// Responsive cover size: 50% of screen width, clamped between 200-320
|
||||||
|
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
|
||||||
|
final expandedHeight = coverSize + 70;
|
||||||
|
|
||||||
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 350, // Increased height for bigger art
|
expandedHeight: expandedHeight,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.background,
|
backgroundColor: colorScheme.background,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
@@ -727,11 +733,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
child: Hero(
|
child: Hero(
|
||||||
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 280, // Increased size
|
width: coverSize,
|
||||||
height: 280,
|
height: coverSize,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surfaceVariant,
|
color: colorScheme.surfaceVariant,
|
||||||
borderRadius: BorderRadius.circular(16), // Slightly more rounded
|
borderRadius: BorderRadius.circular(16),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.3),
|
color: Colors.black.withOpacity(0.3),
|
||||||
@@ -749,7 +755,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
child: imageUrl == null
|
child: imageUrl == null
|
||||||
? Icon(
|
? Icon(
|
||||||
Icons.album_rounded,
|
Icons.album_rounded,
|
||||||
size: 120,
|
size: coverSize * 0.43,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
@@ -1080,7 +1086,9 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
),
|
),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player
|
const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1132,7 +1140,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared "Play on..." bottom sheet with fixed height and scrollable list
|
/// Shared "Play on..." bottom sheet that sizes to content
|
||||||
void _showPlayOnSheet(
|
void _showPlayOnSheet(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
List players, {
|
List players, {
|
||||||
@@ -1141,21 +1149,16 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
// Slide mini player down out of the way
|
// Slide mini player down out of the way
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
GlobalPlayerOverlay.hidePlayer();
|
||||||
|
|
||||||
final screenHeight = MediaQuery.of(context).size.height;
|
|
||||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
|
||||||
final sheetHeight = (screenHeight * 0.45) + bottomPadding; // Fixed 45% + safe area
|
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
isScrollControlled: true,
|
|
||||||
builder: (context) => Container(
|
builder: (context) => Container(
|
||||||
height: sheetHeight,
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
@@ -1163,30 +1166,33 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Expanded(
|
if (players.isEmpty)
|
||||||
child: players.isEmpty
|
Padding(
|
||||||
? Center(
|
padding: const EdgeInsets.all(32.0),
|
||||||
child: Text(S.of(context)!.noPlayersAvailable),
|
child: Text(S.of(context)!.noPlayersAvailable),
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
else
|
||||||
padding: EdgeInsets.only(bottom: bottomPadding + 16),
|
Flexible(
|
||||||
itemCount: players.length,
|
child: ListView.builder(
|
||||||
itemBuilder: (context, index) {
|
shrinkWrap: true,
|
||||||
final player = players[index];
|
itemCount: players.length,
|
||||||
return ListTile(
|
itemBuilder: (context, index) {
|
||||||
leading: Icon(
|
final player = players[index];
|
||||||
Icons.speaker,
|
return ListTile(
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
leading: Icon(
|
||||||
),
|
Icons.speaker,
|
||||||
title: Text(player.name),
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
onTap: () {
|
),
|
||||||
Navigator.pop(context);
|
title: Text(player.name),
|
||||||
onPlayerSelected(player);
|
onTap: () {
|
||||||
},
|
Navigator.pop(context);
|
||||||
);
|
onPlayerSelected(player);
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -243,57 +243,25 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _startArtistRadio() async {
|
void _showRadioMenu(BuildContext context) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
|
||||||
final selectedPlayer = maProvider.selectedPlayer;
|
|
||||||
|
|
||||||
if (selectedPlayer == null) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await maProvider.playArtistRadio(selectedPlayer.playerId, widget.artist);
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(S.of(context)!.startingRadio(widget.artist.name)),
|
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Error starting artist radio: $e');
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showPlayOnMenu(BuildContext context) {
|
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final players = maProvider.availablePlayers;
|
final players = maProvider.availablePlayers;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
// Slide mini player down out of the way
|
||||||
|
GlobalPlayerOverlay.hidePlayer();
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: Colors.transparent,
|
||||||
shape: const RoundedRectangleBorder(
|
builder: (context) => Container(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
decoration: BoxDecoration(
|
||||||
),
|
color: Theme.of(context).colorScheme.surface,
|
||||||
builder: (context) => Padding(
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
padding: const EdgeInsets.all(16.0),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
S.of(context)!.startRadioOn(widget.artist.name),
|
S.of(context)!.startRadioOn(widget.artist.name),
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
@@ -314,7 +282,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
Icons.speaker,
|
Icons.speaker,
|
||||||
color: colorScheme.onSurface,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
title: Text(player.name),
|
title: Text(player.name),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
@@ -345,46 +313,103 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
).whenComplete(() {
|
||||||
|
// Slide mini player back up when sheet is dismissed
|
||||||
|
GlobalPlayerOverlay.showPlayer();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _addArtistRadioToQueue() async {
|
void _showAddToQueueMenu(BuildContext context) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final selectedPlayer = maProvider.selectedPlayer;
|
final players = maProvider.availablePlayers;
|
||||||
|
|
||||||
if (selectedPlayer == null) {
|
// Slide mini player down out of the way
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
GlobalPlayerOverlay.hidePlayer();
|
||||||
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
showModalBottomSheet(
|
||||||
// For adding to queue with radio mode, we use radio_mode but with 'add' option
|
context: context,
|
||||||
await maProvider.api?.playArtistRadioToQueue(selectedPlayer.playerId, widget.artist);
|
backgroundColor: Colors.transparent,
|
||||||
if (mounted) {
|
builder: (context) => Container(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
decoration: BoxDecoration(
|
||||||
SnackBar(
|
color: Theme.of(context).colorScheme.surface,
|
||||||
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
duration: const Duration(seconds: 1),
|
),
|
||||||
),
|
child: Column(
|
||||||
);
|
mainAxisSize: MainAxisSize.min,
|
||||||
}
|
children: [
|
||||||
} catch (e) {
|
const SizedBox(height: 16),
|
||||||
_logger.log('Error adding artist radio to queue: $e');
|
Text(
|
||||||
if (mounted) {
|
S.of(context)!.addToQueueOn,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
SnackBar(
|
),
|
||||||
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
|
const SizedBox(height: 16),
|
||||||
backgroundColor: Colors.red,
|
if (players.isEmpty)
|
||||||
),
|
Padding(
|
||||||
);
|
padding: const EdgeInsets.all(32.0),
|
||||||
}
|
child: Text(S.of(context)!.noPlayersAvailable),
|
||||||
}
|
)
|
||||||
|
else
|
||||||
|
Flexible(
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: players.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final player = players[index];
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Icons.speaker,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
title: Text(player.name),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
final api = maProvider.api;
|
||||||
|
if (api == null) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(S.of(context)!.notConnected)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.playArtistRadioToQueue(player.playerId, widget.artist);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error adding artist radio to queue: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).whenComplete(() {
|
||||||
|
// Slide mini player back up when sheet is dismissed
|
||||||
|
GlobalPlayerOverlay.showPlayer();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadArtistImage() async {
|
Future<void> _loadArtistImage() async {
|
||||||
@@ -526,10 +551,16 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: colorScheme.background,
|
backgroundColor: colorScheme.background,
|
||||||
body: CustomScrollView(
|
body: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// Responsive cover size: 40% of screen width, clamped between 160-280 (smaller for circular artist image)
|
||||||
|
final coverSize = (constraints.maxWidth * 0.4).clamp(160.0, 280.0);
|
||||||
|
final expandedHeight = coverSize + 100;
|
||||||
|
|
||||||
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 300,
|
expandedHeight: expandedHeight,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.background,
|
backgroundColor: colorScheme.background,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
@@ -549,8 +580,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + _heroTagSuffix,
|
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + _heroTagSuffix,
|
||||||
child: ClipOval(
|
child: ClipOval(
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 200,
|
width: coverSize,
|
||||||
height: 200,
|
height: coverSize,
|
||||||
color: colorScheme.surfaceVariant,
|
color: colorScheme.surfaceVariant,
|
||||||
child: imageUrl != null
|
child: imageUrl != null
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
@@ -560,13 +591,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
errorWidget: (_, __, ___) => Icon(
|
errorWidget: (_, __, ___) => Icon(
|
||||||
Icons.person_rounded,
|
Icons.person_rounded,
|
||||||
size: 100,
|
size: coverSize * 0.5,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Icon(
|
: Icon(
|
||||||
Icons.person_rounded,
|
Icons.person_rounded,
|
||||||
size: 100,
|
size: coverSize * 0.5,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -627,7 +658,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 50,
|
height: 50,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _startArtistRadio,
|
onPressed: () => _showRadioMenu(context),
|
||||||
icon: const Icon(Icons.radio),
|
icon: const Icon(Icons.radio),
|
||||||
label: Text(S.of(context)!.radio),
|
label: Text(S.of(context)!.radio),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
@@ -640,23 +671,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
|
||||||
|
|
||||||
// "Play on..." Button (Square)
|
|
||||||
SizedBox(
|
|
||||||
height: 50,
|
|
||||||
width: 50,
|
|
||||||
child: FilledButton.tonal(
|
|
||||||
onPressed: () => _showPlayOnMenu(context),
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Icon(Icons.speaker_group_outlined),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
@@ -665,7 +679,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
height: 50,
|
height: 50,
|
||||||
width: 50,
|
width: 50,
|
||||||
child: FilledButton.tonal(
|
child: FilledButton.tonal(
|
||||||
onPressed: _addArtistRadioToQueue,
|
onPressed: () => _showAddToQueueMenu(context),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -839,6 +853,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -211,23 +211,19 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final players = maProvider.availablePlayers;
|
final players = maProvider.availablePlayers;
|
||||||
|
|
||||||
|
// Slide mini player down out of the way
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
GlobalPlayerOverlay.hidePlayer();
|
||||||
|
|
||||||
final screenHeight = MediaQuery.of(context).size.height;
|
|
||||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
|
||||||
final sheetHeight = (screenHeight * 0.45) + bottomPadding;
|
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
isScrollControlled: true,
|
|
||||||
builder: (context) => Container(
|
builder: (context) => Container(
|
||||||
height: sheetHeight,
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
@@ -235,37 +231,43 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Expanded(
|
if (players.isEmpty)
|
||||||
child: players.isEmpty
|
Padding(
|
||||||
? Center(child: Text(S.of(context)!.noPlayersAvailable))
|
padding: const EdgeInsets.all(32.0),
|
||||||
: ListView.builder(
|
child: Text(S.of(context)!.noPlayersAvailable),
|
||||||
padding: EdgeInsets.only(bottom: bottomPadding + 16),
|
)
|
||||||
itemCount: players.length,
|
else
|
||||||
itemBuilder: (context, index) {
|
Flexible(
|
||||||
final player = players[index];
|
child: ListView.builder(
|
||||||
return ListTile(
|
shrinkWrap: true,
|
||||||
leading: Icon(
|
itemCount: players.length,
|
||||||
Icons.speaker,
|
itemBuilder: (context, index) {
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
final player = players[index];
|
||||||
),
|
return ListTile(
|
||||||
title: Text(player.name),
|
leading: Icon(
|
||||||
onTap: () async {
|
Icons.speaker,
|
||||||
Navigator.pop(context);
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
maProvider.selectPlayer(player);
|
),
|
||||||
maProvider.setCurrentAudiobook(_audiobook);
|
title: Text(player.name),
|
||||||
await maProvider.api?.playAudiobook(
|
onTap: () async {
|
||||||
player.playerId,
|
Navigator.pop(context);
|
||||||
widget.audiobook,
|
maProvider.selectPlayer(player);
|
||||||
);
|
maProvider.setCurrentAudiobook(_audiobook);
|
||||||
},
|
await maProvider.api?.playAudiobook(
|
||||||
|
player.playerId,
|
||||||
|
widget.audiobook,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
).whenComplete(() {
|
).whenComplete(() {
|
||||||
|
// Slide mini player back up when sheet is dismissed
|
||||||
GlobalPlayerOverlay.showPlayer();
|
GlobalPlayerOverlay.showPlayer();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -466,10 +468,16 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
body: CustomScrollView(
|
body: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// Responsive cover size: 50% of screen width, clamped between 200-320
|
||||||
|
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
|
||||||
|
final expandedHeight = coverSize + 70;
|
||||||
|
|
||||||
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 350,
|
expandedHeight: expandedHeight,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
@@ -538,8 +546,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 280,
|
width: coverSize,
|
||||||
height: 280,
|
height: coverSize,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: imageUrl != null
|
child: imageUrl != null
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
@@ -550,14 +558,14 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
placeholder: (_, __) => Center(
|
placeholder: (_, __) => Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
MdiIcons.bookOutline,
|
MdiIcons.bookOutline,
|
||||||
size: 120,
|
size: coverSize * 0.43,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
errorWidget: (_, __, ___) => Center(
|
errorWidget: (_, __, ___) => Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
MdiIcons.bookOutline,
|
MdiIcons.bookOutline,
|
||||||
size: 120,
|
size: coverSize * 0.43,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -565,7 +573,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
: Center(
|
: Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
MdiIcons.bookOutline,
|
MdiIcons.bookOutline,
|
||||||
size: 120,
|
size: coverSize * 0.43,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -809,6 +817,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 140)),
|
const SliverToBoxAdapter(child: SizedBox(height: 140)),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -275,11 +275,17 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
body: CustomScrollView(
|
body: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// Responsive cover size: 50% of screen width, clamped between 200-320
|
||||||
|
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
|
||||||
|
final expandedHeight = coverSize + 70;
|
||||||
|
|
||||||
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// App bar with series cover collage (matches audiobook detail screen)
|
// App bar with series cover collage (matches audiobook detail screen)
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 350,
|
expandedHeight: expandedHeight,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
@@ -292,12 +298,12 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 60),
|
const SizedBox(height: 60),
|
||||||
// Series cover collage - same size as audiobook detail cover
|
// Series cover collage - responsive size
|
||||||
Hero(
|
Hero(
|
||||||
tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
|
tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 280,
|
width: coverSize,
|
||||||
height: 280,
|
height: coverSize,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -449,6 +455,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
|||||||
child: SizedBox(height: BottomSpacing.withMiniPlayer),
|
child: SizedBox(height: BottomSpacing.withMiniPlayer),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -443,6 +443,18 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (provider.isConnected) {
|
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
|
// Navigate to home screen
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
// Ensure keyboard is closed before navigating
|
// Ensure keyboard is closed before navigating
|
||||||
|
|||||||
+1176
-217
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
// Player settings
|
// Player settings
|
||||||
bool _preferLocalPlayer = false;
|
bool _preferLocalPlayer = false;
|
||||||
bool _smartSortPlayers = false;
|
bool _smartSortPlayers = false;
|
||||||
|
// Hint settings
|
||||||
|
bool _showHints = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -89,6 +91,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final preferLocal = await SettingsService.getPreferLocalPlayer();
|
final preferLocal = await SettingsService.getPreferLocalPlayer();
|
||||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||||
|
|
||||||
|
// Load hint settings
|
||||||
|
final showHints = await SettingsService.getShowHints();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_showRecentAlbums = showRecent;
|
_showRecentAlbums = showRecent;
|
||||||
@@ -105,6 +110,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_libraryEnabled = libraryEnabled;
|
_libraryEnabled = libraryEnabled;
|
||||||
_preferLocalPlayer = preferLocal;
|
_preferLocalPlayer = preferLocal;
|
||||||
_smartSortPlayers = smartSort;
|
_smartSortPlayers = smartSort;
|
||||||
|
_showHints = showHints;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -699,6 +705,46 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 32),
|
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
|
// Home Screen section
|
||||||
Text(
|
Text(
|
||||||
S.of(context)!.homeScreen,
|
S.of(context)!.homeScreen,
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../models/player.dart';
|
import '../models/player.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
|
import '../services/database_service.dart';
|
||||||
import '../constants/timings.dart';
|
import '../constants/timings.dart';
|
||||||
|
|
||||||
/// Centralized cache service for all app data caching
|
/// Centralized cache service for all app data caching
|
||||||
/// This is a non-notifying service - it just stores and retrieves cached data
|
/// This is a non-notifying service - it just stores and retrieves cached data
|
||||||
class CacheService {
|
class CacheService {
|
||||||
final DebugLogger _logger = DebugLogger();
|
final DebugLogger _logger = DebugLogger();
|
||||||
|
bool _homeRowsLoaded = false;
|
||||||
|
|
||||||
// Home screen row caching
|
// Home screen row caching
|
||||||
List<Album>? _cachedRecentAlbums;
|
List<Album>? _cachedRecentAlbums;
|
||||||
@@ -57,6 +60,8 @@ class CacheService {
|
|||||||
_cachedRecentAlbums = albums;
|
_cachedRecentAlbums = albums;
|
||||||
_recentAlbumsLastFetched = DateTime.now();
|
_recentAlbumsLastFetched = DateTime.now();
|
||||||
_logger.log('✅ Cached ${albums.length} recent albums');
|
_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
|
/// Check if discover artists cache is valid
|
||||||
@@ -76,6 +81,8 @@ class CacheService {
|
|||||||
_cachedDiscoverArtists = artists;
|
_cachedDiscoverArtists = artists;
|
||||||
_discoverArtistsLastFetched = DateTime.now();
|
_discoverArtistsLastFetched = DateTime.now();
|
||||||
_logger.log('✅ Cached ${artists.length} discover artists');
|
_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
|
/// Check if discover albums cache is valid
|
||||||
@@ -95,6 +102,8 @@ class CacheService {
|
|||||||
_cachedDiscoverAlbums = albums;
|
_cachedDiscoverAlbums = albums;
|
||||||
_discoverAlbumsLastFetched = DateTime.now();
|
_discoverAlbumsLastFetched = DateTime.now();
|
||||||
_logger.log('✅ Cached ${albums.length} discover albums');
|
_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)
|
/// Invalidate home screen cache (call on pull-to-refresh)
|
||||||
@@ -250,6 +259,11 @@ class CacheService {
|
|||||||
_playerTrackCache[playerId] = track;
|
_playerTrackCache[playerId] = track;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear cached track for a player (e.g., when external source is active)
|
||||||
|
void clearCachedTrackForPlayer(String playerId) {
|
||||||
|
_playerTrackCache.remove(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CLEAR ALL
|
// CLEAR ALL
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -276,4 +290,78 @@ class CacheService {
|
|||||||
_cachedSelectedPlayer = null;
|
_cachedSelectedPlayer = null;
|
||||||
_playersLastFetched = 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,34 @@ class DatabaseService {
|
|||||||
|
|
||||||
/// Clear queue for a player
|
/// Clear queue for a player
|
||||||
Future<void> clearCachedQueue(String playerId) => db.clearCachedQueue(playerId);
|
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();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Search History Convenience Methods
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Save a search query to history
|
||||||
|
Future<void> saveSearchQuery(String query) => db.saveSearchQuery(query);
|
||||||
|
|
||||||
|
/// Get recent search queries (up to 10)
|
||||||
|
Future<List<String>> getRecentSearches() => db.getRecentSearches();
|
||||||
|
|
||||||
|
/// Clear all search history
|
||||||
|
Future<void> clearSearchHistory() => db.clearSearchHistory();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,99 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
|
||||||
import 'debug_logger.dart';
|
import 'debug_logger.dart';
|
||||||
|
import 'settings_service.dart';
|
||||||
|
|
||||||
/// Service to generate unique per-installation player IDs
|
/// Service to generate unique per-device player IDs
|
||||||
/// Simplified to match KMP client pattern: single storage key, lazy generation
|
/// Uses stable device identifiers (ANDROID_ID) combined with MA username
|
||||||
/// This prevents the critical bug where multiple devices of the same model
|
/// to create persistent player IDs that survive storage wipes and support
|
||||||
/// would share the same player ID and trigger playback on all devices
|
/// multiple devices per account
|
||||||
class DeviceIdService {
|
class DeviceIdService {
|
||||||
static const String _keyLocalPlayerId = 'local_player_id';
|
static const String _keyLocalPlayerId = 'local_player_id';
|
||||||
static final _logger = DebugLogger();
|
static final _logger = DebugLogger();
|
||||||
static const _uuid = Uuid();
|
|
||||||
|
|
||||||
/// Get or generate a unique player ID for this installation
|
// Cache stable device ID to avoid repeated platform calls
|
||||||
/// ID is generated once and persisted across app restarts
|
static String? _cachedStableDeviceId;
|
||||||
/// Format: ensemble_<uuid>
|
|
||||||
///
|
|
||||||
/// This matches the KMP client pattern: generate on first access, store once
|
|
||||||
static Future<String> getOrCreateDevicePlayerId() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
|
|
||||||
// Single source of truth - no complex migration logic
|
/// Get stable device identifier that persists across storage wipes
|
||||||
final existingId = prefs.getString(_keyLocalPlayerId);
|
/// - Android: Uses ANDROID_ID (persists until factory reset)
|
||||||
if (existingId != null && existingId.startsWith('ensemble_')) {
|
/// - iOS: Uses identifierForVendor (persists until app reinstall)
|
||||||
_logger.log('Using existing player ID: $existingId');
|
static Future<String> _getStableDeviceId() async {
|
||||||
return existingId;
|
if (_cachedStableDeviceId != null) {
|
||||||
|
return _cachedStableDeviceId!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new ID on first access
|
final deviceInfo = DeviceInfoPlugin();
|
||||||
final playerId = 'ensemble_${_uuid.v4()}';
|
|
||||||
await prefs.setString(_keyLocalPlayerId, playerId);
|
if (Platform.isAndroid) {
|
||||||
_logger.log('Generated new player ID: $playerId');
|
final android = await deviceInfo.androidInfo;
|
||||||
return playerId;
|
// ANDROID_ID is unique per device+user combo, persists across storage wipes
|
||||||
|
// Only resets on factory reset
|
||||||
|
_cachedStableDeviceId = android.id;
|
||||||
|
} else if (Platform.isIOS) {
|
||||||
|
final ios = await deviceInfo.iosInfo;
|
||||||
|
// identifierForVendor persists across app updates but resets on reinstall
|
||||||
|
_cachedStableDeviceId = ios.identifierForVendor ?? 'unknown_ios';
|
||||||
|
} else {
|
||||||
|
// Fallback for other platforms (web, desktop)
|
||||||
|
_cachedStableDeviceId = 'unknown_platform';
|
||||||
|
}
|
||||||
|
|
||||||
|
return _cachedStableDeviceId!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect if device is a tablet based on screen size
|
||||||
|
/// Uses the standard 600dp threshold (shortest side >= 600 = tablet)
|
||||||
|
static bool get isTablet {
|
||||||
|
try {
|
||||||
|
final view = ui.PlatformDispatcher.instance.views.first;
|
||||||
|
final size = view.physicalSize / view.devicePixelRatio;
|
||||||
|
return size.shortestSide >= 600;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Failed to detect device type: $e');
|
||||||
|
return false; // Default to phone if detection fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get or generate a unique player ID for this device
|
||||||
|
/// Format: ensemble_{username}_{deviceId} or ensemble_{deviceId}
|
||||||
|
///
|
||||||
|
/// Key behaviors:
|
||||||
|
/// - Uses stable device ID that survives storage wipes
|
||||||
|
/// - Includes MA username to support multiple devices per account
|
||||||
|
/// - Regenerates if username changes (user logged in with different account)
|
||||||
|
static Future<String> getOrCreateDevicePlayerId() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final stableDeviceId = await _getStableDeviceId();
|
||||||
|
final maUsername = await SettingsService.getUsername();
|
||||||
|
|
||||||
|
// Generate expected ID based on current username and device
|
||||||
|
// Use shorter device ID suffix (first 8 chars) to keep ID reasonable length
|
||||||
|
final deviceIdSuffix = stableDeviceId.length > 8
|
||||||
|
? stableDeviceId.substring(0, 8)
|
||||||
|
: stableDeviceId;
|
||||||
|
|
||||||
|
final expectedId = maUsername != null && maUsername.isNotEmpty
|
||||||
|
? 'ensemble_${maUsername}_$deviceIdSuffix'
|
||||||
|
: 'ensemble_$deviceIdSuffix';
|
||||||
|
|
||||||
|
// Check if we already have the correct ID
|
||||||
|
final existingId = prefs.getString(_keyLocalPlayerId);
|
||||||
|
if (existingId == expectedId) {
|
||||||
|
_logger.log('Using existing player ID: $expectedId');
|
||||||
|
return expectedId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID needs to be updated (first login, username changed, or migration)
|
||||||
|
if (existingId != null) {
|
||||||
|
_logger.log('Updating player ID from $existingId to $expectedId');
|
||||||
|
} else {
|
||||||
|
_logger.log('Generated new player ID: $expectedId');
|
||||||
|
}
|
||||||
|
|
||||||
|
await prefs.setString(_keyLocalPlayerId, expectedId);
|
||||||
|
return expectedId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adopt an existing player ID (used when claiming a ghost player)
|
/// Adopt an existing player ID (used when claiming a ghost player)
|
||||||
@@ -45,4 +108,12 @@ class DeviceIdService {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
return prefs.getString(_keyLocalPlayerId) == null;
|
return prefs.getString(_keyLocalPlayerId) == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Force regeneration of player ID on next call
|
||||||
|
/// Useful after login/logout to ensure ID matches current user
|
||||||
|
static Future<void> invalidateCachedId() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_keyLocalPlayerId);
|
||||||
|
_logger.log('Invalidated cached player ID');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1399,7 +1399,7 @@ class MusicAssistantAPI {
|
|||||||
|
|
||||||
final result = searchResponse['result'] as Map<String, dynamic>?;
|
final result = searchResponse['result'] as Map<String, dynamic>?;
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': []};
|
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse results from the search response
|
// Parse results from the search response
|
||||||
@@ -1418,6 +1418,16 @@ class MusicAssistantAPI {
|
|||||||
.toList() ??
|
.toList() ??
|
||||||
[];
|
[];
|
||||||
|
|
||||||
|
final playlists = (result['playlists'] as List<dynamic>?)
|
||||||
|
?.map((item) => Playlist.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
final audiobooks = (result['audiobooks'] as List<dynamic>?)
|
||||||
|
?.map((item) => Audiobook.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
|
||||||
// Deduplicate results by name
|
// Deduplicate results by name
|
||||||
// MA returns both library items and provider items for the same content
|
// MA returns both library items and provider items for the same content
|
||||||
// Prefer library items (provider='library') as they have all provider mappings
|
// Prefer library items (provider='library') as they have all provider mappings
|
||||||
@@ -1425,15 +1435,17 @@ class MusicAssistantAPI {
|
|||||||
'artists': _deduplicateResults(artists),
|
'artists': _deduplicateResults(artists),
|
||||||
'albums': _deduplicateResults(albums),
|
'albums': _deduplicateResults(albums),
|
||||||
'tracks': _deduplicateResults(tracks),
|
'tracks': _deduplicateResults(tracks),
|
||||||
|
'playlists': _deduplicateResults(playlists),
|
||||||
|
'audiobooks': _deduplicateResults(audiobooks),
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Error searching: $e');
|
_logger.log('Error searching: $e');
|
||||||
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': []};
|
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
).catchError((e) {
|
).catchError((e) {
|
||||||
_logger.log('Error searching after retries: $e');
|
_logger.log('Error searching after retries: $e');
|
||||||
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': []};
|
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1516,6 +1528,7 @@ class MusicAssistantAPI {
|
|||||||
currentIndex = queueResult['current_index'] as int?;
|
currentIndex = queueResult['current_index'] as int?;
|
||||||
shuffleEnabled = queueResult['shuffle_enabled'] as bool?;
|
shuffleEnabled = queueResult['shuffle_enabled'] as bool?;
|
||||||
repeatMode = queueResult['repeat_mode'] as String?;
|
repeatMode = queueResult['repeat_mode'] as String?;
|
||||||
|
_logger.log('🔀 Queue metadata: shuffle_enabled=$shuffleEnabled, repeat_mode=$repeatMode');
|
||||||
|
|
||||||
final currentItemData = queueResult['current_item'] as Map<String, dynamic>?;
|
final currentItemData = queueResult['current_item'] as Map<String, dynamic>?;
|
||||||
final currentItemName = currentItemData?['name'] as String?;
|
final currentItemName = currentItemData?['name'] as String?;
|
||||||
@@ -1602,6 +1615,25 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a single track to the queue without starting playback
|
||||||
|
Future<void> addTrackToQueue(String playerId, Track track) async {
|
||||||
|
try {
|
||||||
|
final uri = _buildTrackUri(track);
|
||||||
|
|
||||||
|
await _sendCommand(
|
||||||
|
'player_queues/play_media',
|
||||||
|
args: {
|
||||||
|
'queue_id': playerId,
|
||||||
|
'media': [uri],
|
||||||
|
'option': 'add', // Add to queue without starting
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error adding track to queue: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Play multiple tracks via queue
|
/// Play multiple tracks via queue
|
||||||
/// If clearQueue is true, replaces the queue (default behavior)
|
/// If clearQueue is true, replaces the queue (default behavior)
|
||||||
/// If startIndex is provided, only tracks from that index onwards will be queued
|
/// If startIndex is provided, only tracks from that index onwards will be queued
|
||||||
@@ -2036,11 +2068,11 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle shuffle mode for queue
|
/// Toggle shuffle mode for queue
|
||||||
Future<void> toggleShuffle(String queueId) async {
|
Future<void> toggleShuffle(String queueId, bool shuffleEnabled) async {
|
||||||
try {
|
try {
|
||||||
await _sendCommand(
|
await _sendCommand(
|
||||||
'player_queues/shuffle',
|
'player_queues/shuffle',
|
||||||
args: {'queue_id': queueId},
|
args: {'queue_id': queueId, 'shuffle_enabled': shuffleEnabled},
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Error toggling shuffle: $e');
|
_logger.log('Error toggling shuffle: $e');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'device_id_service.dart';
|
||||||
|
|
||||||
class SettingsService {
|
class SettingsService {
|
||||||
static const String _keyServerUrl = 'server_url';
|
static const String _keyServerUrl = 'server_url';
|
||||||
@@ -74,6 +75,11 @@ class SettingsService {
|
|||||||
static const String _keyEnabledAbsLibraries = 'enabled_abs_libraries'; // JSON list of library paths
|
static const String _keyEnabledAbsLibraries = 'enabled_abs_libraries'; // JSON list of library paths
|
||||||
static const String _keyDiscoveredAbsLibraries = 'discovered_abs_libraries'; // JSON list of {path, name}
|
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 {
|
static Future<String?> getServerUrl() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
return prefs.getString(_keyServerUrl);
|
return prefs.getString(_keyServerUrl);
|
||||||
@@ -389,12 +395,14 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper to create player name with possessive apostrophe
|
// Helper to create player name with possessive apostrophe
|
||||||
|
// Automatically detects Phone vs Tablet based on screen size
|
||||||
static String _makePlayerName(String ownerName) {
|
static String _makePlayerName(String ownerName) {
|
||||||
// Handle possessive: "Chris" -> "Chris' Phone", "Mom" -> "Mom's Phone"
|
final deviceType = DeviceIdService.isTablet ? 'Tablet' : 'Phone';
|
||||||
|
// Handle possessive: "Chris" -> "Chris' Tablet", "Mom" -> "Mom's Phone"
|
||||||
if (ownerName.toLowerCase().endsWith('s')) {
|
if (ownerName.toLowerCase().endsWith('s')) {
|
||||||
return "$ownerName' Phone";
|
return "$ownerName' $deviceType";
|
||||||
} else {
|
} else {
|
||||||
return "$ownerName's Phone";
|
return "$ownerName's $deviceType";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,4 +770,42 @@ class SettingsService {
|
|||||||
|
|
||||||
await setEnabledAbsLibraries(enabled);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,12 +36,17 @@ class ExpandablePlayer extends StatefulWidget {
|
|||||||
/// When true, shows player name instead of track name in collapsed state
|
/// When true, shows player name instead of track name in collapsed state
|
||||||
final bool isDeviceRevealVisible;
|
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({
|
const ExpandablePlayer({
|
||||||
super.key,
|
super.key,
|
||||||
this.slideOffset = 0.0,
|
this.slideOffset = 0.0,
|
||||||
this.bounceOffset = 0.0,
|
this.bounceOffset = 0.0,
|
||||||
this.onRevealPlayers,
|
this.onRevealPlayers,
|
||||||
this.isDeviceRevealVisible = false,
|
this.isDeviceRevealVisible = false,
|
||||||
|
this.isHintVisible = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -340,14 +345,17 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Show cached queue immediately (if available)
|
// 1. Show cached queue immediately (if available)
|
||||||
final cachedQueue = await maProvider.getCachedQueue(player.playerId);
|
// Only load from cache if we don't already have a queue (preserves optimistic updates)
|
||||||
if (cachedQueue != null && cachedQueue.items.isNotEmpty && mounted) {
|
if (_queue == null) {
|
||||||
setState(() {
|
final cachedQueue = await maProvider.getCachedQueue(player.playerId);
|
||||||
_queue = cachedQueue;
|
if (cachedQueue != null && cachedQueue.items.isNotEmpty && mounted) {
|
||||||
_isLoadingQueue = false;
|
setState(() {
|
||||||
});
|
_queue = cachedQueue;
|
||||||
} else {
|
_isLoadingQueue = false;
|
||||||
setState(() => _isLoadingQueue = true);
|
});
|
||||||
|
} else {
|
||||||
|
setState(() => _isLoadingQueue = true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch fresh queue in background
|
// 2. Fetch fresh queue in background
|
||||||
@@ -355,11 +363,28 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
try {
|
try {
|
||||||
final freshQueue = await maProvider.getQueue(player.playerId);
|
final freshQueue = await maProvider.getQueue(player.playerId);
|
||||||
if (mounted && freshQueue != null) {
|
if (mounted && freshQueue != null) {
|
||||||
// Only update if queue changed
|
debugPrint('🔀 Queue loaded: shuffle=${freshQueue.shuffle}, shuffleEnabled=${freshQueue.shuffleEnabled}');
|
||||||
|
// Check if queue items were reordered by comparing first few item IDs
|
||||||
|
bool itemsReordered = false;
|
||||||
|
if (_queue != null && _queue!.items.isNotEmpty && freshQueue.items.isNotEmpty) {
|
||||||
|
// Compare first 3 items to detect reordering (shuffle changes item order)
|
||||||
|
final maxCheck = (_queue!.items.length < 3 ? _queue!.items.length : 3).clamp(1, freshQueue.items.length);
|
||||||
|
for (int i = 0; i < maxCheck; i++) {
|
||||||
|
if (_queue!.items[i].queueItemId != freshQueue.items[i].queueItemId) {
|
||||||
|
itemsReordered = true;
|
||||||
|
debugPrint('🔀 Queue items reordered detected at position $i');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update if queue metadata or item order changed
|
||||||
final queueChanged = _queue == null ||
|
final queueChanged = _queue == null ||
|
||||||
_queue!.items.length != freshQueue.items.length ||
|
_queue!.items.length != freshQueue.items.length ||
|
||||||
_queue!.currentIndex != freshQueue.currentIndex;
|
_queue!.currentIndex != freshQueue.currentIndex ||
|
||||||
|
_queue!.shuffle != freshQueue.shuffle ||
|
||||||
|
itemsReordered;
|
||||||
if (queueChanged) {
|
if (queueChanged) {
|
||||||
|
debugPrint('🔀 Queue changed, updating UI (reordered=$itemsReordered)');
|
||||||
setState(() {
|
setState(() {
|
||||||
_queue = freshQueue;
|
_queue = freshQueue;
|
||||||
_isLoadingQueue = false;
|
_isLoadingQueue = false;
|
||||||
@@ -401,16 +426,69 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleShuffle() async {
|
Future<void> _toggleShuffle() async {
|
||||||
if (_queue == null) return;
|
debugPrint('🔀 Shuffle button pressed');
|
||||||
|
if (_queue == null) {
|
||||||
|
debugPrint('🔀 Shuffle: _queue is null, returning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final newShuffleState = !_queue!.shuffle;
|
||||||
|
debugPrint('🔀 Shuffle: current=${_queue!.shuffle}, toggling to $newShuffleState');
|
||||||
|
|
||||||
|
// Optimistically update local state for immediate visual feedback
|
||||||
|
setState(() {
|
||||||
|
_queue = PlayerQueue(
|
||||||
|
playerId: _queue!.playerId,
|
||||||
|
items: _queue!.items,
|
||||||
|
currentIndex: _queue!.currentIndex,
|
||||||
|
shuffleEnabled: newShuffleState,
|
||||||
|
repeatMode: _queue!.repeatMode,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
debugPrint('🔀 Shuffle: optimistic update applied, shuffleEnabled=$newShuffleState');
|
||||||
|
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
await maProvider.toggleShuffle(_queue!.playerId);
|
// Toggle: if currently shuffled, disable; if not shuffled, enable
|
||||||
|
await maProvider.toggleShuffle(_queue!.playerId, newShuffleState);
|
||||||
|
debugPrint('🔀 Shuffle: command sent, waiting for server to reorder queue');
|
||||||
|
// Small delay to allow server to finish reordering queue items
|
||||||
|
await Future.delayed(const Duration(milliseconds: 150));
|
||||||
|
debugPrint('🔀 Shuffle: reloading queue');
|
||||||
await _loadQueue();
|
await _loadQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _cycleRepeat() async {
|
Future<void> _cycleRepeat() async {
|
||||||
if (_queue == null) return;
|
if (_queue == null) return;
|
||||||
|
|
||||||
|
// Calculate next repeat mode: off -> all -> one -> off
|
||||||
|
String nextMode;
|
||||||
|
switch (_queue!.repeatMode) {
|
||||||
|
case 'off':
|
||||||
|
case null:
|
||||||
|
nextMode = 'all';
|
||||||
|
break;
|
||||||
|
case 'all':
|
||||||
|
nextMode = 'one';
|
||||||
|
break;
|
||||||
|
case 'one':
|
||||||
|
nextMode = 'off';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
nextMode = 'off';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistically update local state for immediate visual feedback
|
||||||
|
setState(() {
|
||||||
|
_queue = PlayerQueue(
|
||||||
|
playerId: _queue!.playerId,
|
||||||
|
items: _queue!.items,
|
||||||
|
currentIndex: _queue!.currentIndex,
|
||||||
|
shuffleEnabled: _queue!.shuffleEnabled,
|
||||||
|
repeatMode: nextMode,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
await maProvider.cycleRepeatMode(_queue!.playerId, _queue!.repeatMode);
|
await maProvider.setRepeatMode(_queue!.playerId, nextMode);
|
||||||
await _loadQueue();
|
await _loadQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1483,11 +1561,48 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Track title - with slide animation when collapsed
|
// 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
|
// When device reveal is visible, show player name instead of track name
|
||||||
// Hidden during transition to prevent flash
|
// Hidden during transition to prevent flash
|
||||||
// FALLBACK: Show if transition active but no peek content available
|
// FALLBACK: Show if transition active but no peek content available
|
||||||
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
|
// 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(
|
Positioned(
|
||||||
left: titleLeft + miniPlayerSlideOffset,
|
left: titleLeft + miniPlayerSlideOffset,
|
||||||
top: titleTop,
|
top: titleTop,
|
||||||
@@ -1514,24 +1629,23 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Artist/Author name - with slide animation when collapsed
|
// Artist/Author name - with slide animation when collapsed
|
||||||
|
// When hint is visible, show blank line
|
||||||
// When device reveal is visible, show "Now Playing" hint
|
// When device reveal is visible, show "Now Playing" hint
|
||||||
// For audiobooks: show author from audiobook context
|
// For audiobooks: show author from audiobook context
|
||||||
// Hidden during transition to prevent flash
|
// Hidden during transition to prevent flash
|
||||||
// FALLBACK: Show if transition active but no peek content available
|
// FALLBACK: Show if transition active but no peek content available
|
||||||
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
|
// 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(
|
Positioned(
|
||||||
left: titleLeft + miniPlayerSlideOffset,
|
left: titleLeft + miniPlayerSlideOffset,
|
||||||
top: artistTop,
|
top: artistTop,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: titleWidth,
|
width: titleWidth,
|
||||||
child: Text(
|
child: Text(
|
||||||
// Show "Now Playing" when device reveal visible and collapsed
|
// Always show artist/author (was showing "Now Playing" when device reveal visible)
|
||||||
(widget.isDeviceRevealVisible && t < 0.5)
|
maProvider.isPlayingAudiobook
|
||||||
? S.of(context)!.nowPlaying
|
? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor)
|
||||||
: maProvider.isPlayingAudiobook
|
: currentTrack.artistsString,
|
||||||
? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor)
|
|
||||||
: currentTrack.artistsString,
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: textColor.withOpacity(t > 0.5 ? 0.7 : MiniPlayerLayout.secondaryTextOpacity),
|
color: textColor.withOpacity(t > 0.5 ? 0.7 : MiniPlayerLayout.secondaryTextOpacity),
|
||||||
fontSize: artistFontSize,
|
fontSize: artistFontSize,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -5,6 +6,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../providers/navigation_provider.dart';
|
import '../providers/navigation_provider.dart';
|
||||||
|
import '../services/settings_service.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import 'expandable_player.dart';
|
import 'expandable_player.dart';
|
||||||
import 'player/player_reveal_overlay.dart';
|
import 'player/player_reveal_overlay.dart';
|
||||||
@@ -130,7 +132,7 @@ class GlobalPlayerOverlay extends StatefulWidget {
|
|||||||
_overlayStateKey.currentState?._dismissPlayerReveal();
|
_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() {
|
static void triggerBounce() {
|
||||||
_overlayStateKey.currentState?._triggerBounce();
|
_overlayStateKey.currentState?._triggerBounce();
|
||||||
}
|
}
|
||||||
@@ -145,17 +147,40 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
late AnimationController _slideController;
|
late AnimationController _slideController;
|
||||||
late Animation<double> _slideAnimation;
|
late Animation<double> _slideAnimation;
|
||||||
|
|
||||||
// Controller for player reveal animation with bounce
|
// Single bounce controller for device selector expand/collapse
|
||||||
late AnimationController _bounceController;
|
late AnimationController _singleBounceController;
|
||||||
late Animation<double> _bounceAnimation;
|
|
||||||
|
// Double bounce controller for hint
|
||||||
|
late AnimationController _doubleBounceController;
|
||||||
|
|
||||||
// State for player reveal overlay
|
// State for player reveal overlay
|
||||||
bool _isRevealVisible = false;
|
bool _isRevealVisible = false;
|
||||||
|
|
||||||
// Use ValueNotifier instead of setState for bounce offset
|
// State for interactive hint mode (blur backdrop + wait for user action)
|
||||||
// This prevents full widget tree rebuilds during animation
|
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);
|
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
|
// Key for the reveal overlay
|
||||||
final _revealKey = GlobalKey<PlayerRevealOverlayState>();
|
final _revealKey = GlobalKey<PlayerRevealOverlayState>();
|
||||||
|
|
||||||
@@ -172,30 +197,134 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
reverseCurve: Curves.easeInCubic,
|
reverseCurve: Curves.easeInCubic,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Bounce animation - quick dip down then back up
|
// Single bounce for device selector expand/collapse
|
||||||
_bounceController = AnimationController(
|
_singleBounceController = AnimationController(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 400),
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
_bounceAnimation = CurvedAnimation(
|
_singleBounceController.addListener(() {
|
||||||
parent: _bounceController,
|
final t = Curves.easeOut.transform(_singleBounceController.value);
|
||||||
curve: Curves.easeOut,
|
// 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
|
// Load hint settings and start welcome immediately if needed
|
||||||
// This isolates the rebuild to only the widgets that depend on bounce offset
|
_loadHintSettings();
|
||||||
_bounceController.addListener(() {
|
}
|
||||||
final t = _bounceAnimation.value;
|
|
||||||
// Sine curve: 0 -> 1 -> 0 as t goes 0 -> 0.5 -> 1
|
Future<void> _loadHintSettings() async {
|
||||||
_bounceOffsetNotifier.value = 10.0 * (t < 0.5 ? t * 2 : (1.0 - t) * 2);
|
_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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_hintBounceTimer?.cancel();
|
||||||
_slideController.dispose();
|
_slideController.dispose();
|
||||||
_bounceController.dispose();
|
_singleBounceController.dispose();
|
||||||
|
_doubleBounceController.dispose();
|
||||||
|
_welcomeFadeController.dispose();
|
||||||
_bounceOffsetNotifier.dispose();
|
_bounceOffsetNotifier.dispose();
|
||||||
|
_hintOpacityNotifier.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,11 +342,29 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
GlobalPlayerOverlay.collapsePlayer();
|
GlobalPlayerOverlay.collapsePlayer();
|
||||||
}
|
}
|
||||||
HapticFeedback.mediumImpact();
|
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(() {
|
setState(() {
|
||||||
_isRevealVisible = true;
|
_isRevealVisible = true;
|
||||||
|
_isOnboardingReveal = wasInHintMode;
|
||||||
});
|
});
|
||||||
_bounceController.forward();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _hidePlayerReveal() {
|
void _hidePlayerReveal() {
|
||||||
@@ -225,6 +372,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
_bounceOffsetNotifier.value = 0;
|
_bounceOffsetNotifier.value = 0;
|
||||||
setState(() {
|
setState(() {
|
||||||
_isRevealVisible = false;
|
_isRevealVisible = false;
|
||||||
|
_isOnboardingReveal = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,14 +380,27 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
void _dismissPlayerReveal() {
|
void _dismissPlayerReveal() {
|
||||||
if (!_isRevealVisible) return;
|
if (!_isRevealVisible) return;
|
||||||
// Call the overlay's dismiss method which has the slide animation
|
// Call the overlay's dismiss method which has the slide animation
|
||||||
// Note: dismiss() triggers the bounce animation internally
|
|
||||||
_revealKey.currentState?.dismiss();
|
_revealKey.currentState?.dismiss();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trigger bounce animation on mini player
|
/// Trigger single bounce on mini player (called when device selector collapses)
|
||||||
void _triggerBounce() {
|
void _triggerBounce() {
|
||||||
_bounceController.reset();
|
_singleBounceController.reset();
|
||||||
_bounceController.forward();
|
_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
|
@override
|
||||||
@@ -247,12 +408,46 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
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(
|
return PopScope(
|
||||||
canPop: !_isRevealVisible,
|
canPop: !_isRevealVisible && !_isHintModeActive,
|
||||||
onPopInvokedWithResult: (didPop, result) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
if (!didPop && _isRevealVisible) {
|
if (!didPop) {
|
||||||
_dismissPlayerReveal();
|
if (_isHintModeActive) {
|
||||||
|
_endHintMode();
|
||||||
|
} else if (_isRevealVisible) {
|
||||||
|
_dismissPlayerReveal();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -264,14 +459,18 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
child: widget.child,
|
child: widget.child,
|
||||||
),
|
),
|
||||||
// Global persistent bottom navigation bar - positioned at bottom
|
// Global persistent bottom navigation bar - positioned at bottom
|
||||||
Positioned(
|
// Hide when: not connected, no player selected, OR showing welcome screen
|
||||||
left: 0,
|
// For first-time users, selectedPlayer becomes non-null at the same time
|
||||||
right: 0,
|
// as shouldShowWelcomeBackdrop becomes true, preventing any gap.
|
||||||
bottom: 0,
|
if (provider.isConnected && provider.selectedPlayer != null && !shouldShowWelcomeBackdrop)
|
||||||
child: ListenableBuilder(
|
Positioned(
|
||||||
listenable: navigationProvider,
|
left: 0,
|
||||||
builder: (context, _) {
|
right: 0,
|
||||||
return Consumer<ThemeProvider>(
|
bottom: 0,
|
||||||
|
child: ListenableBuilder(
|
||||||
|
listenable: navigationProvider,
|
||||||
|
builder: (context, _) {
|
||||||
|
return Consumer<ThemeProvider>(
|
||||||
builder: (context, themeProvider, _) {
|
builder: (context, themeProvider, _) {
|
||||||
// Use adaptive primary color for bottom nav when adaptive theme is enabled
|
// Use adaptive primary color for bottom nav when adaptive theme is enabled
|
||||||
final sourceColor = themeProvider.adaptiveTheme
|
final sourceColor = themeProvider.adaptiveTheme
|
||||||
@@ -360,22 +559,125 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
// Blur backdrop for device selector (reveal mode) - static, no animation
|
||||||
// Blur backdrop when device selector is open
|
if (_isRevealVisible && !_isHintModeActive)
|
||||||
if (_isRevealVisible)
|
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: IgnorePointer(
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: _dismissPlayerReveal,
|
||||||
child: BackdropFilter(
|
child: BackdropFilter(
|
||||||
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
|
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.black.withOpacity(0.1),
|
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
|
// Player reveal overlay - renders BELOW mini player so cards slide behind it
|
||||||
if (_isRevealVisible)
|
if (_isRevealVisible)
|
||||||
PlayerRevealOverlay(
|
PlayerRevealOverlay(
|
||||||
@@ -383,34 +685,43 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
onDismiss: _hidePlayerReveal,
|
onDismiss: _hidePlayerReveal,
|
||||||
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).padding.bottom + 12,
|
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).padding.bottom + 12,
|
||||||
miniPlayerHeight: 64,
|
miniPlayerHeight: 64,
|
||||||
|
showOnboardingHints: _isOnboardingReveal,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Global player overlay - renders ON TOP so cards slide behind it
|
// Global player overlay - renders ON TOP so cards slide behind it
|
||||||
// Use Selector instead of Consumer to avoid rebuilds during animation
|
// 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) => (
|
selector: (_, provider) => (
|
||||||
isConnected: provider.isConnected,
|
isConnected: provider.isConnected,
|
||||||
hasPlayer: provider.selectedPlayer != null,
|
hasPlayer: provider.selectedPlayer != null,
|
||||||
|
hasTrack: provider.currentTrack != null,
|
||||||
),
|
),
|
||||||
builder: (context, state, child) {
|
builder: (context, state, child) {
|
||||||
// Only show player if connected and has a selected player
|
// Only show player if connected and has a selected player
|
||||||
if (!state.isConnected || !state.hasPlayer) {
|
if (!state.isConnected || !state.hasPlayer) {
|
||||||
return const SizedBox.shrink();
|
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
|
// This prevents full widget tree rebuilds - only ExpandablePlayer updates
|
||||||
return ValueListenableBuilder<double>(
|
return ValueListenableBuilder<double>(
|
||||||
valueListenable: _bounceOffsetNotifier,
|
valueListenable: _bounceOffsetNotifier,
|
||||||
builder: (context, bounceOffset, _) {
|
builder: (context, bounceOffset, _) {
|
||||||
return AnimatedBuilder(
|
return ValueListenableBuilder<double>(
|
||||||
animation: _slideAnimation,
|
valueListenable: _hintOpacityNotifier,
|
||||||
builder: (context, _) {
|
builder: (context, hintOpacity, _) {
|
||||||
return ExpandablePlayer(
|
return AnimatedBuilder(
|
||||||
key: globalPlayerKey,
|
animation: _slideAnimation,
|
||||||
slideOffset: _slideAnimation.value,
|
builder: (context, _) {
|
||||||
bounceOffset: bounceOffset,
|
return ExpandablePlayer(
|
||||||
onRevealPlayers: _showPlayerReveal,
|
key: globalPlayerKey,
|
||||||
isDeviceRevealVisible: _isRevealVisible,
|
slideOffset: _slideAnimation.value,
|
||||||
|
bounceOffset: bounceOffset,
|
||||||
|
onRevealPlayers: _showPlayerReveal,
|
||||||
|
isDeviceRevealVisible: _isRevealVisible,
|
||||||
|
isHintVisible: hintOpacity > 0,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class DeviceSelectorBar extends StatelessWidget {
|
|||||||
textColor: textColor,
|
textColor: textColor,
|
||||||
width: width,
|
width: width,
|
||||||
slideOffset: slideOffset,
|
slideOffset: slideOffset,
|
||||||
|
isHint: swipeHint != null,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -97,6 +98,7 @@ class DeviceSelectorBar extends StatelessWidget {
|
|||||||
textColor: textColor,
|
textColor: textColor,
|
||||||
width: width,
|
width: width,
|
||||||
slideOffset: 0, // No additional slide - Transform handles positioning
|
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)
|
/// Horizontal slide offset for swipe animation (-1 to 1)
|
||||||
final double slideOffset;
|
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)
|
/// Whether to show the progress bar overlay (for playing state)
|
||||||
final bool showProgress;
|
final bool showProgress;
|
||||||
|
|
||||||
@@ -64,6 +67,7 @@ class MiniPlayerContent extends StatelessWidget {
|
|||||||
required this.textColor,
|
required this.textColor,
|
||||||
required this.width,
|
required this.width,
|
||||||
this.slideOffset = 0.0,
|
this.slideOffset = 0.0,
|
||||||
|
this.isHint = false,
|
||||||
this.showProgress = false,
|
this.showProgress = false,
|
||||||
this.progress = 0.0,
|
this.progress = 0.0,
|
||||||
});
|
});
|
||||||
@@ -136,21 +140,44 @@ class MiniPlayerContent extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Secondary text line
|
// Secondary text line (with optional hint icon)
|
||||||
if (hasSecondaryLine)
|
if (hasSecondaryLine)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: MiniPlayerLayout.textLeft + slidePixels,
|
left: MiniPlayerLayout.textLeft + slidePixels,
|
||||||
top: MiniPlayerLayout.secondaryTop,
|
top: MiniPlayerLayout.secondaryTop,
|
||||||
right: MiniPlayerLayout.textRightPadding - slidePixels,
|
right: MiniPlayerLayout.textRightPadding - slidePixels,
|
||||||
child: Text(
|
child: isHint
|
||||||
secondaryText!,
|
? Row(
|
||||||
style: TextStyle(
|
mainAxisSize: MainAxisSize.min,
|
||||||
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
|
children: [
|
||||||
fontSize: MiniPlayerLayout.secondaryFontSize,
|
Icon(
|
||||||
),
|
Icons.lightbulb_outline,
|
||||||
maxLines: 1,
|
size: 14,
|
||||||
overflow: TextOverflow.ellipsis,
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../providers/music_assistant_provider.dart';
|
import '../../providers/music_assistant_provider.dart';
|
||||||
|
import '../../services/settings_service.dart';
|
||||||
import '../../theme/design_tokens.dart';
|
import '../../theme/design_tokens.dart';
|
||||||
import '../../theme/theme_provider.dart';
|
import '../../theme/theme_provider.dart';
|
||||||
import '../../theme/palette_helper.dart';
|
import '../../theme/palette_helper.dart';
|
||||||
@@ -16,12 +18,14 @@ class PlayerRevealOverlay extends StatefulWidget {
|
|||||||
final VoidCallback onDismiss;
|
final VoidCallback onDismiss;
|
||||||
final double miniPlayerBottom; // Bottom position of mini player
|
final double miniPlayerBottom; // Bottom position of mini player
|
||||||
final double miniPlayerHeight;
|
final double miniPlayerHeight;
|
||||||
|
final bool showOnboardingHints; // Show additional hints for first-time users
|
||||||
|
|
||||||
const PlayerRevealOverlay({
|
const PlayerRevealOverlay({
|
||||||
super.key,
|
super.key,
|
||||||
required this.onDismiss,
|
required this.onDismiss,
|
||||||
required this.miniPlayerBottom,
|
required this.miniPlayerBottom,
|
||||||
required this.miniPlayerHeight,
|
required this.miniPlayerHeight,
|
||||||
|
this.showOnboardingHints = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -40,13 +44,25 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
double _dragOffset = 0;
|
double _dragOffset = 0;
|
||||||
bool _isDragging = false;
|
bool _isDragging = false;
|
||||||
|
|
||||||
|
// Scroll controller for when player list overflows
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
bool _isScrollable = false;
|
||||||
|
|
||||||
// Cache extracted colors per player for per-device accent colors
|
// Cache extracted colors per player for per-device accent colors
|
||||||
final Map<String, ColorScheme?> _playerColors = {};
|
final Map<String, ColorScheme?> _playerColors = {};
|
||||||
|
|
||||||
|
// Hint system
|
||||||
|
bool _showHints = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
|
// Load hint settings
|
||||||
|
SettingsService.getShowHints().then((value) {
|
||||||
|
if (mounted) setState(() => _showHints = value);
|
||||||
|
});
|
||||||
|
|
||||||
_revealController = AnimationController(
|
_revealController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
@@ -116,6 +132,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_revealController.dispose();
|
_revealController.dispose();
|
||||||
_refreshTimer?.cancel();
|
_refreshTimer?.cancel();
|
||||||
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +144,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
|
|
||||||
/// Animate dismissal and call callback when done
|
/// Animate dismissal and call callback when done
|
||||||
void dismiss() {
|
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
|
// This creates the effect of cards "landing" on the mini player
|
||||||
bool bounceFired = false;
|
bool bounceFired = false;
|
||||||
void checkBounce() {
|
void checkBounce() {
|
||||||
@@ -145,19 +162,51 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if scroll is at top (or not scrollable)
|
||||||
|
bool get _isAtTop {
|
||||||
|
if (!_isScrollable) return true;
|
||||||
|
if (!_scrollController.hasClients) return true;
|
||||||
|
return _scrollController.offset <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
void _handleVerticalDragUpdate(DragUpdateDetails details) {
|
void _handleVerticalDragUpdate(DragUpdateDetails details) {
|
||||||
|
final delta = details.primaryDelta ?? 0;
|
||||||
|
|
||||||
|
// If scrollable and not at top, let scroll handle it
|
||||||
|
if (_isScrollable && !_isAtTop) {
|
||||||
|
// Scroll the list
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
final newOffset = _scrollController.offset - delta;
|
||||||
|
_scrollController.jumpTo(newOffset.clamp(0.0, _scrollController.position.maxScrollExtent));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// At top: check if trying to scroll down (view more) or dismiss
|
||||||
|
if (_isScrollable && delta < 0 && _scrollController.hasClients) {
|
||||||
|
// Swiping up (delta negative) = scroll down to see more players
|
||||||
|
final newOffset = _scrollController.offset - delta;
|
||||||
|
_scrollController.jumpTo(newOffset.clamp(0.0, _scrollController.position.maxScrollExtent));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process dismiss gesture when swiping DOWN (positive delta = finger moving down)
|
||||||
|
// Ignore swipe up when not scrollable - cards should stay fixed
|
||||||
|
if (delta <= 0) return;
|
||||||
|
|
||||||
|
// Swiping down = dismiss gesture
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDragging = true;
|
_isDragging = true;
|
||||||
// Negate delta so swipe down = cards move down (toward dismissal)
|
_dragOffset += delta;
|
||||||
_dragOffset -= details.primaryDelta ?? 0;
|
|
||||||
// Clamp: allow moving down (positive) freely, limit moving up to 20px
|
|
||||||
_dragOffset = _dragOffset.clamp(-20.0, double.infinity);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleVerticalDragEnd(DragEndDetails details) {
|
void _handleVerticalDragEnd(DragEndDetails details) {
|
||||||
final velocity = details.primaryVelocity ?? 0;
|
final velocity = details.primaryVelocity ?? 0;
|
||||||
|
|
||||||
|
// If not dragging (was scrolling), don't dismiss
|
||||||
|
if (!_isDragging) return;
|
||||||
|
|
||||||
// Dismiss if dragged down enough or with enough velocity
|
// Dismiss if dragged down enough or with enough velocity
|
||||||
if (_dragOffset > 100 || velocity > 500) {
|
if (_dragOffset > 100 || velocity > 500) {
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
@@ -203,6 +252,21 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
const cardSpacing = 12.0;
|
const cardSpacing = 12.0;
|
||||||
final totalStackHeight = players.length * cardHeight + (players.length - 1) * cardSpacing;
|
final totalStackHeight = players.length * cardHeight + (players.length - 1) * cardSpacing;
|
||||||
|
|
||||||
|
// Calculate max available height (80% of screen minus mini player area)
|
||||||
|
final screenHeight = MediaQuery.of(context).size.height;
|
||||||
|
final statusBarHeight = MediaQuery.of(context).padding.top;
|
||||||
|
final bottomPadding = widget.miniPlayerBottom + widget.miniPlayerHeight + 20;
|
||||||
|
final maxListHeight = (screenHeight - statusBarHeight - bottomPadding) * 0.8;
|
||||||
|
|
||||||
|
// Determine if scrolling is needed
|
||||||
|
final needsScroll = totalStackHeight > maxListHeight;
|
||||||
|
// Update scrollable state (used by gesture handlers)
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (_isScrollable != needsScroll) {
|
||||||
|
_isScrollable = needsScroll;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: _revealAnimation,
|
animation: _revealAnimation,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
@@ -210,13 +274,12 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
// Backdrop - tap to dismiss
|
// Backdrop - tap to dismiss (darkness handled by GlobalPlayerOverlay)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: dismiss,
|
onTap: dismiss,
|
||||||
child: Container(
|
behavior: HitTestBehavior.opaque,
|
||||||
color: Colors.black.withOpacity(0.6 * _backdropAnimation.value),
|
child: const SizedBox.expand(),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -231,7 +294,123 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Build player cards - all slide from behind mini player
|
// 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 - scrollable when overflow, otherwise static
|
||||||
|
// Using ListView.builder for many players: only builds visible items
|
||||||
|
// This significantly improves animation performance with 10+ players
|
||||||
|
if (needsScroll)
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxHeight: maxListHeight),
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
|
itemCount: players.length,
|
||||||
|
// Fixed height per item for optimal scroll performance
|
||||||
|
itemExtent: cardHeight + cardSpacing,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final player = players[index];
|
||||||
|
final isPlaying = player.state == 'playing';
|
||||||
|
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
|
||||||
|
String? albumArtUrl;
|
||||||
|
if (playerTrack != null && player.available && player.powered) {
|
||||||
|
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
|
||||||
|
}
|
||||||
|
final playerColorScheme = _playerColors[player.playerId];
|
||||||
|
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
|
||||||
|
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
|
||||||
|
|
||||||
|
// Animation: slide from behind mini player
|
||||||
|
// Only calculate for items that will be built (visible ones)
|
||||||
|
const baseOffset = 80.0;
|
||||||
|
final reverseIndex = players.length - 1 - index;
|
||||||
|
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
|
||||||
|
final slideOffset = distanceToTravel * (1.0 - t);
|
||||||
|
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(0, slideOffset),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: cardSpacing),
|
||||||
|
child: PlayerCard(
|
||||||
|
player: player,
|
||||||
|
trackInfo: playerTrack,
|
||||||
|
albumArtUrl: albumArtUrl,
|
||||||
|
isSelected: false,
|
||||||
|
isPlaying: isPlaying,
|
||||||
|
isGrouped: player.isGrouped,
|
||||||
|
backgroundColor: cardBgColor,
|
||||||
|
textColor: cardTextColor,
|
||||||
|
onTap: () {
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
maProvider.selectPlayer(player);
|
||||||
|
dismiss();
|
||||||
|
},
|
||||||
|
onLongPress: () {
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
maProvider.togglePlayerSync(player.playerId);
|
||||||
|
},
|
||||||
|
onPlayPause: () {
|
||||||
|
if (isPlaying) {
|
||||||
|
maProvider.pausePlayer(player.playerId);
|
||||||
|
} else {
|
||||||
|
maProvider.resumePlayer(player.playerId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSkipNext: () => maProvider.nextTrack(player.playerId),
|
||||||
|
onPower: () => maProvider.togglePower(player.playerId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
// Non-scrollable list for when players fit
|
||||||
...List.generate(players.length, (index) {
|
...List.generate(players.length, (index) {
|
||||||
final player = players[index];
|
final player = players[index];
|
||||||
final isPlaying = player.state == 'playing';
|
final isPlaying = player.state == 'playing';
|
||||||
@@ -277,7 +456,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(player);
|
||||||
dismiss();
|
dismiss();
|
||||||
},
|
},
|
||||||
onLongPress: player.available ? () {
|
onLongPress: () {
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
|
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -287,7 +466,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
maProvider.togglePlayerSync(player.playerId);
|
maProvider.togglePlayerSync(player.playerId);
|
||||||
} : null,
|
},
|
||||||
onPlayPause: () {
|
onPlayPause: () {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
maProvider.pausePlayer(player.playerId);
|
maProvider.pausePlayer(player.playerId);
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../models/player.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// A themed bottom sheet for selecting a player.
|
||||||
|
///
|
||||||
|
/// Supports adaptive theming - pass [adaptiveColorScheme] to use colors
|
||||||
|
/// extracted from album art, matching the detail screen backgrounds.
|
||||||
|
class PlayerPickerSheet extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final List<Player> players;
|
||||||
|
final Player? selectedPlayer;
|
||||||
|
final ColorScheme? adaptiveColorScheme;
|
||||||
|
final Future<void> Function(Player player) onPlayerSelected;
|
||||||
|
|
||||||
|
const PlayerPickerSheet({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.players,
|
||||||
|
this.selectedPlayer,
|
||||||
|
this.adaptiveColorScheme,
|
||||||
|
required this.onPlayerSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final themeColorScheme = Theme.of(context).colorScheme;
|
||||||
|
final colorScheme = adaptiveColorScheme ?? themeColorScheme;
|
||||||
|
|
||||||
|
// Use surface container for slightly elevated look, or adaptive surface
|
||||||
|
final backgroundColor = adaptiveColorScheme?.surface ?? themeColorScheme.surfaceContainerHigh;
|
||||||
|
final onBackgroundColor = adaptiveColorScheme?.onSurface ?? themeColorScheme.onSurface;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Drag handle
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12, bottom: 8),
|
||||||
|
child: Container(
|
||||||
|
width: 32,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: onBackgroundColor.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Title
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: onBackgroundColor,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Player list
|
||||||
|
if (players.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(32.0),
|
||||||
|
child: Text(
|
||||||
|
S.of(context)!.noPlayersAvailable,
|
||||||
|
style: TextStyle(color: onBackgroundColor.withOpacity(0.6)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Flexible(
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
itemCount: players.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final player = players[index];
|
||||||
|
final isSelected = selectedPlayer?.playerId == player.playerId;
|
||||||
|
|
||||||
|
return _PlayerTile(
|
||||||
|
player: player,
|
||||||
|
isSelected: isSelected,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
onBackgroundColor: onBackgroundColor,
|
||||||
|
onTap: () => onPlayerSelected(player),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
SizedBox(height: MediaQuery.of(context).padding.bottom + 70),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlayerTile extends StatelessWidget {
|
||||||
|
final Player player;
|
||||||
|
final bool isSelected;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
final Color onBackgroundColor;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _PlayerTile({
|
||||||
|
required this.player,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.colorScheme,
|
||||||
|
required this.onBackgroundColor,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Status color based on player state
|
||||||
|
Color statusColor;
|
||||||
|
if (!player.available) {
|
||||||
|
statusColor = Colors.grey.withOpacity(0.5);
|
||||||
|
} else if (!player.powered) {
|
||||||
|
statusColor = Colors.grey;
|
||||||
|
} else if (player.state == 'playing') {
|
||||||
|
statusColor = Colors.green;
|
||||||
|
} else {
|
||||||
|
statusColor = Colors.orange; // Idle/paused but powered
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Material(
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.primaryContainer.withOpacity(0.3)
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Status indicator
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: statusColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// Speaker icon
|
||||||
|
Icon(
|
||||||
|
Icons.speaker_rounded,
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.primary
|
||||||
|
: onBackgroundColor.withOpacity(0.7),
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// Player name
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
player.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.primary
|
||||||
|
: onBackgroundColor,
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Selected checkmark
|
||||||
|
if (isSelected)
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle_rounded,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the player picker bottom sheet with adaptive theming.
|
||||||
|
///
|
||||||
|
/// Returns the selected player, or null if dismissed.
|
||||||
|
Future<void> showPlayerPickerSheet({
|
||||||
|
required BuildContext context,
|
||||||
|
required String title,
|
||||||
|
required List<Player> players,
|
||||||
|
Player? selectedPlayer,
|
||||||
|
ColorScheme? adaptiveColorScheme,
|
||||||
|
required Future<void> Function(Player player) onPlayerSelected,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => PlayerPickerSheet(
|
||||||
|
title: title,
|
||||||
|
players: players,
|
||||||
|
selectedPlayer: selectedPlayer,
|
||||||
|
adaptiveColorScheme: adaptiveColorScheme,
|
||||||
|
onPlayerSelected: (player) async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await onPlayerSelected(player);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,11 +19,12 @@ class PlayerSelector extends StatelessWidget {
|
|||||||
final availablePlayers = maProvider.availablePlayers;
|
final availablePlayers = maProvider.availablePlayers;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Count players that are currently playing (excluding selected player)
|
// Count players that are currently playing MA content (excluding selected player)
|
||||||
// User already sees if selected player is playing, badge shows "other" activity
|
// User already sees if selected player is playing, badge shows "other" activity
|
||||||
|
// Exclude external sources (Spotify Connect, TV optical, etc.) - they're not playing MA content
|
||||||
final selectedPlayerId = selectedPlayer?.playerId;
|
final selectedPlayerId = selectedPlayer?.playerId;
|
||||||
final playingCount = availablePlayers
|
final playingCount = availablePlayers
|
||||||
.where((p) => p.state == 'playing' && p.playerId != selectedPlayerId)
|
.where((p) => p.state == 'playing' && p.playerId != selectedPlayerId && !p.isExternalSource)
|
||||||
.length;
|
.length;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -202,17 +203,23 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
// but still have track info - treat as "has content" for display
|
// but still have track info - treat as "has content" for display
|
||||||
final isIdle = player.state == 'idle';
|
final isIdle = player.state == 'idle';
|
||||||
|
|
||||||
|
// Check for external source (optical, Spotify, etc.)
|
||||||
|
final isExternalSource = player.isExternalSource;
|
||||||
|
|
||||||
// Get track info - use current track for selected player, cache for others
|
// Get track info - use current track for selected player, cache for others
|
||||||
final playerTrack = isSelected
|
// Skip track info for external sources
|
||||||
? currentTrack
|
final playerTrack = isExternalSource
|
||||||
: maProvider.getCachedTrackForPlayer(player.playerId);
|
? null
|
||||||
|
: (isSelected
|
||||||
|
? currentTrack
|
||||||
|
: maProvider.getCachedTrackForPlayer(player.playerId));
|
||||||
|
|
||||||
// Player has content if playing, paused, or idle with cached track
|
// Player has content if playing, paused, or idle with cached track (but not external source)
|
||||||
final hasContent = isPlaying || isPaused || (isIdle && playerTrack != null);
|
final hasContent = !isExternalSource && (isPlaying || isPaused || (isIdle && playerTrack != null));
|
||||||
|
|
||||||
// Get album art for any player with content
|
// Get album art for any player with content (skip for external source)
|
||||||
String? albumArtUrl;
|
String? albumArtUrl;
|
||||||
if (playerTrack != null && isOn && hasContent) {
|
if (!isExternalSource && playerTrack != null && isOn && hasContent) {
|
||||||
albumArtUrl = maProvider.getImageUrl(
|
albumArtUrl = maProvider.getImageUrl(
|
||||||
playerTrack.album ?? playerTrack,
|
playerTrack.album ?? playerTrack,
|
||||||
size: 128,
|
size: 128,
|
||||||
@@ -231,25 +238,23 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(player);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
onLongPress: player.available
|
onLongPress: () {
|
||||||
? () {
|
// Haptic feedback for sync action
|
||||||
// Haptic feedback for sync action
|
HapticFeedback.mediumImpact();
|
||||||
HapticFeedback.mediumImpact();
|
|
||||||
|
|
||||||
// Show snackbar for visual feedback
|
// Show snackbar for visual feedback
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Syncing ${player.name}...'),
|
content: Text('Syncing ${player.name}...'),
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Long-press to sync/unsync player
|
// Long-press to sync/unsync player
|
||||||
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
|
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
|
||||||
debugPrint('🔗 Player isGrouped: ${player.isGrouped}, groupMembers: ${player.groupMembers}');
|
debugPrint('🔗 Player isGrouped: ${player.isGrouped}, groupMembers: ${player.groupMembers}');
|
||||||
maProvider.togglePlayerSync(player.playerId);
|
maProvider.togglePlayerSync(player.playerId);
|
||||||
}
|
},
|
||||||
: null,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 72,
|
height: 72,
|
||||||
@@ -323,11 +328,13 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
? colorScheme.onSurface.withOpacity(0.24)
|
? colorScheme.onSurface.withOpacity(0.24)
|
||||||
: !isOn
|
: !isOn
|
||||||
? colorScheme.onSurface.withOpacity(0.3)
|
? colorScheme.onSurface.withOpacity(0.3)
|
||||||
: isPlaying
|
: isExternalSource
|
||||||
? Colors.green
|
? Colors.cyan // external source (optical, Spotify, etc.)
|
||||||
: hasContent
|
: isPlaying
|
||||||
? Colors.orange // paused or idle with content
|
? Colors.green
|
||||||
: colorScheme.onSurfaceVariant.withOpacity(0.5),
|
: hasContent
|
||||||
|
? Colors.orange // paused or idle with content
|
||||||
|
: colorScheme.onSurfaceVariant.withOpacity(0.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
@@ -337,9 +344,11 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
? S.of(context)!.playerStateUnavailable
|
? S.of(context)!.playerStateUnavailable
|
||||||
: !isOn
|
: !isOn
|
||||||
? S.of(context)!.playerStateOff
|
? S.of(context)!.playerStateOff
|
||||||
: hasContent && playerTrack != null
|
: isExternalSource
|
||||||
? playerTrack.name
|
? S.of(context)!.playerStateExternalSource
|
||||||
: S.of(context)!.playerStateIdle,
|
: hasContent && playerTrack != null
|
||||||
|
? playerTrack.name
|
||||||
|
: S.of(context)!.playerStateIdle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: player.available
|
color: player.available
|
||||||
? colorScheme.onSurfaceVariant
|
? colorScheme.onSurfaceVariant
|
||||||
@@ -402,8 +411,8 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Play/Pause button
|
// Play/Pause button (hidden for external sources - controls don't work)
|
||||||
if (player.available && isOn)
|
if (player.available && isOn && !isExternalSource)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: ensemble
|
name: ensemble
|
||||||
description: A minimalistic Music Assistant client built with Flutter
|
description: A minimalistic Music Assistant client built with Flutter
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.5.0-beta+27
|
version: 2.7.3-beta
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|||||||
Reference in New Issue
Block a user