Compare commits
160
Commits
@@ -41,6 +41,9 @@ jobs:
|
|||||||
- name: Generate code (Drift database)
|
- name: Generate code (Drift database)
|
||||||
run: dart run build_runner build --delete-conflicting-outputs
|
run: dart run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
|
- name: Generate localizations
|
||||||
|
run: flutter gen-l10n
|
||||||
|
|
||||||
- name: Generate launcher icons
|
- name: Generate launcher icons
|
||||||
run: flutter pub run flutter_launcher_icons
|
run: flutter pub run flutter_launcher_icons
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
+308
-3
@@ -93,12 +93,99 @@ class SyncMetadata extends Table {
|
|||||||
Set<Column> get primaryKey => {syncType};
|
Set<Column> get primaryKey => {syncType};
|
||||||
}
|
}
|
||||||
|
|
||||||
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata])
|
/// Persisted playback state - survives app restarts and disconnects
|
||||||
|
class PlaybackState extends Table {
|
||||||
|
/// Always 'current' - single row table
|
||||||
|
TextColumn get id => text().withDefault(const Constant('current'))();
|
||||||
|
|
||||||
|
/// Selected player ID
|
||||||
|
TextColumn get playerId => text().nullable()();
|
||||||
|
|
||||||
|
/// Selected player name (for display if player unavailable)
|
||||||
|
TextColumn get playerName => text().nullable()();
|
||||||
|
|
||||||
|
/// Current track as JSON
|
||||||
|
TextColumn get currentTrackJson => text().nullable()();
|
||||||
|
|
||||||
|
/// Current position in seconds
|
||||||
|
RealColumn get positionSeconds => real().withDefault(const Constant(0.0))();
|
||||||
|
|
||||||
|
/// Whether playback was active
|
||||||
|
BoolColumn get isPlaying => boolean().withDefault(const Constant(false))();
|
||||||
|
|
||||||
|
/// When this state was saved
|
||||||
|
DateTimeColumn get savedAt => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached player list - for instant display on app resume
|
||||||
|
class CachedPlayers extends Table {
|
||||||
|
/// Player ID from Music Assistant
|
||||||
|
TextColumn get playerId => text()();
|
||||||
|
|
||||||
|
/// Player data as JSON
|
||||||
|
TextColumn get playerJson => text()();
|
||||||
|
|
||||||
|
/// Current track for this player as JSON (for mini player display)
|
||||||
|
TextColumn get currentTrackJson => text().nullable()();
|
||||||
|
|
||||||
|
/// When this was last updated
|
||||||
|
DateTimeColumn get lastUpdated => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {playerId};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached queue items for selected player
|
||||||
|
class CachedQueue extends Table {
|
||||||
|
/// Auto-incrementing ID for ordering
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
|
||||||
|
/// Player ID this queue belongs to
|
||||||
|
TextColumn get playerId => text()();
|
||||||
|
|
||||||
|
/// Queue item as JSON
|
||||||
|
TextColumn get itemJson => text()();
|
||||||
|
|
||||||
|
/// Position in queue
|
||||||
|
IntColumn get position => integer()();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 => 1;
|
int get schemaVersion => 4;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration {
|
MigrationStrategy get migration {
|
||||||
@@ -107,7 +194,20 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
await m.createAll();
|
await m.createAll();
|
||||||
},
|
},
|
||||||
onUpgrade: (Migrator m, int from, int to) async {
|
onUpgrade: (Migrator m, int from, int to) async {
|
||||||
// Future migrations will go here
|
// Migration from v1 to v2: Add playback state, cached players, cached queue tables
|
||||||
|
if (from < 2) {
|
||||||
|
await m.createTable(playbackState);
|
||||||
|
await m.createTable(cachedPlayers);
|
||||||
|
await m.createTable(cachedQueue);
|
||||||
|
}
|
||||||
|
// Migration from v2 to v3: Add home row cache table
|
||||||
|
if (from < 3) {
|
||||||
|
await m.createTable(homeRowCache);
|
||||||
|
}
|
||||||
|
// Migration from v3 to v4: Add search history table
|
||||||
|
if (from < 4) {
|
||||||
|
await m.createTable(searchHistory);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -327,4 +427,209 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
if (lastSync == null) return true;
|
if (lastSync == null) return true;
|
||||||
return DateTime.now().difference(lastSync) > maxAge;
|
return DateTime.now().difference(lastSync) > maxAge;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Playback State Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Save current playback state
|
||||||
|
Future<void> savePlaybackState({
|
||||||
|
String? playerId,
|
||||||
|
String? playerName,
|
||||||
|
String? currentTrackJson,
|
||||||
|
double positionSeconds = 0.0,
|
||||||
|
bool isPlaying = false,
|
||||||
|
}) async {
|
||||||
|
await into(playbackState).insertOnConflictUpdate(PlaybackStateCompanion.insert(
|
||||||
|
id: const Value('current'),
|
||||||
|
playerId: Value(playerId),
|
||||||
|
playerName: Value(playerName),
|
||||||
|
currentTrackJson: Value(currentTrackJson),
|
||||||
|
positionSeconds: Value(positionSeconds),
|
||||||
|
isPlaying: Value(isPlaying),
|
||||||
|
savedAt: DateTime.now(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get saved playback state
|
||||||
|
Future<PlaybackStateData?> getPlaybackState() async {
|
||||||
|
return (select(playbackState)..where((p) => p.id.equals('current')))
|
||||||
|
.getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear playback state
|
||||||
|
Future<void> clearPlaybackState() async {
|
||||||
|
await delete(playbackState).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Cached Players Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Cache a player with its current track
|
||||||
|
Future<void> cachePlayer({
|
||||||
|
required String playerId,
|
||||||
|
required String playerJson,
|
||||||
|
String? currentTrackJson,
|
||||||
|
}) async {
|
||||||
|
await into(cachedPlayers).insertOnConflictUpdate(CachedPlayersCompanion.insert(
|
||||||
|
playerId: playerId,
|
||||||
|
playerJson: playerJson,
|
||||||
|
currentTrackJson: Value(currentTrackJson),
|
||||||
|
lastUpdated: DateTime.now(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache multiple players at once
|
||||||
|
Future<void> cachePlayers(List<Map<String, dynamic>> players) async {
|
||||||
|
await transaction(() async {
|
||||||
|
for (final player in players) {
|
||||||
|
await into(cachedPlayers).insertOnConflictUpdate(CachedPlayersCompanion.insert(
|
||||||
|
playerId: player['playerId'] as String,
|
||||||
|
playerJson: player['playerJson'] as String,
|
||||||
|
currentTrackJson: Value(player['currentTrackJson'] as String?),
|
||||||
|
lastUpdated: DateTime.now(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all cached players
|
||||||
|
Future<List<CachedPlayer>> getCachedPlayers() async {
|
||||||
|
return (select(cachedPlayers)
|
||||||
|
..orderBy([(p) => OrderingTerm.desc(p.lastUpdated)]))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a specific cached player
|
||||||
|
Future<CachedPlayer?> getCachedPlayer(String playerId) async {
|
||||||
|
return (select(cachedPlayers)..where((p) => p.playerId.equals(playerId)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update cached track for a player
|
||||||
|
Future<void> updateCachedPlayerTrack(String playerId, String? trackJson) async {
|
||||||
|
await (update(cachedPlayers)..where((p) => p.playerId.equals(playerId)))
|
||||||
|
.write(CachedPlayersCompanion(
|
||||||
|
currentTrackJson: Value(trackJson),
|
||||||
|
lastUpdated: Value(DateTime.now()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all cached players
|
||||||
|
Future<void> clearCachedPlayers() async {
|
||||||
|
await delete(cachedPlayers).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Cached Queue Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Save queue for a player
|
||||||
|
Future<void> saveQueue(String playerId, List<String> itemJsonList) async {
|
||||||
|
await transaction(() async {
|
||||||
|
// Clear existing queue for this player
|
||||||
|
await (delete(cachedQueue)..where((q) => q.playerId.equals(playerId))).go();
|
||||||
|
|
||||||
|
// Insert new queue items
|
||||||
|
for (var i = 0; i < itemJsonList.length; i++) {
|
||||||
|
await into(cachedQueue).insert(CachedQueueCompanion.insert(
|
||||||
|
playerId: playerId,
|
||||||
|
itemJson: itemJsonList[i],
|
||||||
|
position: i,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached queue for a player
|
||||||
|
Future<List<CachedQueueData>> getCachedQueue(String playerId) async {
|
||||||
|
return (select(cachedQueue)
|
||||||
|
..where((q) => q.playerId.equals(playerId))
|
||||||
|
..orderBy([(q) => OrderingTerm.asc(q.position)]))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear queue for a player
|
||||||
|
Future<void> clearCachedQueue(String playerId) async {
|
||||||
|
await (delete(cachedQueue)..where((q) => q.playerId.equals(playerId))).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all cached queues
|
||||||
|
Future<void> clearAllCachedQueues() async {
|
||||||
|
await delete(cachedQueue).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Home Row Cache Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Save home row data
|
||||||
|
Future<void> saveHomeRowCache(String rowType, String itemsJson) async {
|
||||||
|
await into(homeRowCache).insertOnConflictUpdate(
|
||||||
|
HomeRowCacheCompanion(
|
||||||
|
rowType: Value(rowType),
|
||||||
|
itemsJson: Value(itemsJson),
|
||||||
|
lastUpdated: Value(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached home row data
|
||||||
|
Future<HomeRowCacheData?> getHomeRowCache(String rowType) async {
|
||||||
|
return (select(homeRowCache)..where((h) => h.rowType.equals(rowType)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all home row cache
|
||||||
|
Future<void> clearHomeRowCache() async {
|
||||||
|
await delete(homeRowCache).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -126,6 +126,7 @@
|
|||||||
|
|
||||||
"playing": "Spiele {name}",
|
"playing": "Spiele {name}",
|
||||||
"playingTrack": "Spiele Song",
|
"playingTrack": "Spiele Song",
|
||||||
|
"nowPlaying": "Wird gespielt",
|
||||||
|
|
||||||
"markedAsFinished": "{name} als beendet markiert",
|
"markedAsFinished": "{name} als beendet markiert",
|
||||||
"failedToMarkFinished": "Konnte nicht als beendet markieren: {error}",
|
"failedToMarkFinished": "Konnte nicht als beendet markieren: {error}",
|
||||||
@@ -269,8 +270,22 @@
|
|||||||
"audiobookSingular": "Hörbuch",
|
"audiobookSingular": "Hörbuch",
|
||||||
"trackCount": "{count} {count, plural, =1{Song} other{Songs}}",
|
"trackCount": "{count} {count, plural, =1{Song} other{Songs}}",
|
||||||
"materialYouDescription": "Systemfarben verwenden (Android 12+)",
|
"materialYouDescription": "Systemfarben verwenden (Android 12+)",
|
||||||
|
"accentColor": "Akzentfarbe",
|
||||||
|
"players": "Geräte",
|
||||||
|
"preferLocalPlayer": "Lokales Gerät bevorzugen",
|
||||||
|
"preferLocalPlayerDescription": "Dieses Gerät immer zuerst auswählen, wenn verfügbar",
|
||||||
|
"smartSortPlayers": "Intelligente Sortierung",
|
||||||
|
"smartSortPlayersDescription": "Nach Status sortieren (spielt, an, aus) statt alphabetisch",
|
||||||
"playerStateUnavailable": "Nicht verfügbar",
|
"playerStateUnavailable": "Nicht verfügbar",
|
||||||
"playerStateOff": "Aus",
|
"playerStateOff": "Aus",
|
||||||
"playerStateIdle": "Inaktiv",
|
"playerStateIdle": "Inaktiv",
|
||||||
"playerSelected": "Ausgewählt"
|
"playerStateExternalSource": "Externe Quelle",
|
||||||
|
"playerSelected": "Ausgewählt",
|
||||||
|
"actionQueuedForSync": "Wird synchronisiert wenn online",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
+141
-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"
|
||||||
@@ -635,6 +660,11 @@
|
|||||||
"description": "Status when playing track"
|
"description": "Status when playing track"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"nowPlaying": "Now playing",
|
||||||
|
"@nowPlaying": {
|
||||||
|
"description": "Label shown when device selector is open to indicate current player"
|
||||||
|
},
|
||||||
|
|
||||||
"markedAsFinished": "{name} marked as finished",
|
"markedAsFinished": "{name} marked as finished",
|
||||||
"@markedAsFinished": {
|
"@markedAsFinished": {
|
||||||
"description": "Audiobook marked finished",
|
"description": "Audiobook marked finished",
|
||||||
@@ -1073,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"
|
||||||
},
|
},
|
||||||
@@ -1128,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",
|
||||||
@@ -1143,6 +1183,36 @@
|
|||||||
"description": "Description for Material You theme option"
|
"description": "Description for Material You theme option"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"accentColor": "Accent Color",
|
||||||
|
"@accentColor": {
|
||||||
|
"description": "Accent color selection label"
|
||||||
|
},
|
||||||
|
|
||||||
|
"players": "Players",
|
||||||
|
"@players": {
|
||||||
|
"description": "Players settings section title"
|
||||||
|
},
|
||||||
|
|
||||||
|
"preferLocalPlayer": "Prefer Local Player",
|
||||||
|
"@preferLocalPlayer": {
|
||||||
|
"description": "Setting to always prefer local player"
|
||||||
|
},
|
||||||
|
|
||||||
|
"preferLocalPlayerDescription": "Always select this device first when available",
|
||||||
|
"@preferLocalPlayerDescription": {
|
||||||
|
"description": "Description for prefer local player setting"
|
||||||
|
},
|
||||||
|
|
||||||
|
"smartSortPlayers": "Smart Sort",
|
||||||
|
"@smartSortPlayers": {
|
||||||
|
"description": "Setting to sort players by status"
|
||||||
|
},
|
||||||
|
|
||||||
|
"smartSortPlayersDescription": "Sort by status (playing, on, off) instead of alphabetically",
|
||||||
|
"@smartSortPlayersDescription": {
|
||||||
|
"description": "Description for smart sort players setting"
|
||||||
|
},
|
||||||
|
|
||||||
"playerStateUnavailable": "Unavailable",
|
"playerStateUnavailable": "Unavailable",
|
||||||
"@playerStateUnavailable": {
|
"@playerStateUnavailable": {
|
||||||
"description": "Player state when unavailable"
|
"description": "Player state when unavailable"
|
||||||
@@ -1158,8 +1228,78 @@
|
|||||||
"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"
|
||||||
|
},
|
||||||
|
|
||||||
|
"actionQueuedForSync": "Will sync when online",
|
||||||
|
"@actionQueuedForSync": {
|
||||||
|
"description": "Message when action is queued for sync while offline"
|
||||||
|
},
|
||||||
|
|
||||||
|
"pendingOfflineActions": "{count} pending sync",
|
||||||
|
"@pendingOfflineActions": {
|
||||||
|
"description": "Shows number of pending offline actions",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-2
@@ -225,6 +225,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"playingTrack": "Reproduciendo canción",
|
"playingTrack": "Reproduciendo canción",
|
||||||
|
"nowPlaying": "Reproduciendo ahora",
|
||||||
"markedAsFinished": "{name} marcado como terminado",
|
"markedAsFinished": "{name} marcado como terminado",
|
||||||
"@markedAsFinished": {
|
"@markedAsFinished": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -433,7 +434,7 @@
|
|||||||
"metadataApisDescription": "Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.",
|
"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": {
|
||||||
@@ -465,8 +466,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"materialYouDescription": "Usar colores del sistema (Android 12+)",
|
"materialYouDescription": "Usar colores del sistema (Android 12+)",
|
||||||
|
"accentColor": "Color de acento",
|
||||||
|
"players": "Dispositivos",
|
||||||
|
"preferLocalPlayer": "Preferir dispositivo local",
|
||||||
|
"preferLocalPlayerDescription": "Seleccionar siempre este dispositivo primero cuando esté disponible",
|
||||||
|
"smartSortPlayers": "Orden inteligente",
|
||||||
|
"smartSortPlayersDescription": "Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente",
|
||||||
"playerStateUnavailable": "No disponible",
|
"playerStateUnavailable": "No disponible",
|
||||||
"playerStateOff": "Apagado",
|
"playerStateOff": "Apagado",
|
||||||
"playerStateIdle": "Inactivo",
|
"playerStateIdle": "Inactivo",
|
||||||
"playerSelected": "Seleccionado"
|
"playerStateExternalSource": "Fuente Externa",
|
||||||
|
"playerSelected": "Seleccionado",
|
||||||
|
"actionQueuedForSync": "Se sincronizará cuando esté en línea",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-9
@@ -172,11 +172,20 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
@override
|
@override
|
||||||
Future<bool> didPopRoute() async {
|
Future<bool> didPopRoute() async {
|
||||||
// Intercept back button at app level - runs BEFORE Navigator processes it
|
// Intercept back button at app level - runs BEFORE Navigator processes it
|
||||||
// If player is expanded, collapse it and consume the back gesture
|
// Priority order: device list > expanded player > normal navigation
|
||||||
|
|
||||||
|
// Device list has highest priority - dismiss it first
|
||||||
|
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
|
||||||
|
GlobalPlayerOverlay.dismissPlayerReveal();
|
||||||
|
return true; // We handled it, don't let Navigator process it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check expanded player
|
||||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||||
GlobalPlayerOverlay.collapsePlayer();
|
GlobalPlayerOverlay.collapsePlayer();
|
||||||
return true; // We handled it, don't let Navigator process it
|
return true; // We handled it, don't let Navigator process it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Let Navigator handle the back gesture normally
|
// Let Navigator handle the back gesture normally
|
||||||
return super.didPopRoute();
|
return super.didPopRoute();
|
||||||
}
|
}
|
||||||
@@ -221,9 +230,9 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
lightColorScheme = light;
|
lightColorScheme = light;
|
||||||
darkColorScheme = dark;
|
darkColorScheme = dark;
|
||||||
} else {
|
} else {
|
||||||
// Use brand color schemes
|
// Use custom color from theme provider
|
||||||
lightColorScheme = brandLightColorScheme;
|
lightColorScheme = generateLightColorScheme(themeProvider.customColor);
|
||||||
darkColorScheme = brandDarkColorScheme;
|
darkColorScheme = generateDarkColorScheme(themeProvider.customColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SystemUIWrapper(
|
return SystemUIWrapper(
|
||||||
@@ -339,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});
|
||||||
|
|
||||||
@@ -352,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,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;
|
||||||
@@ -413,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(
|
||||||
|
|||||||
@@ -110,7 +110,8 @@ class MediaItem {
|
|||||||
'media_type': mediaType.name,
|
'media_type': mediaType.name,
|
||||||
if (sortName != null) 'sort_name': sortName,
|
if (sortName != null) 'sort_name': sortName,
|
||||||
if (uri != null) 'uri': uri,
|
if (uri != null) 'uri': uri,
|
||||||
if (providerMappings != null) 'provider_mappings': providerMappings,
|
if (providerMappings != null)
|
||||||
|
'provider_mappings': providerMappings!.map((m) => m.toJson()).toList(),
|
||||||
if (metadata != null) 'metadata': metadata,
|
if (metadata != null) 'metadata': metadata,
|
||||||
if (favorite != null) 'favorite': favorite,
|
if (favorite != null) 'favorite': favorite,
|
||||||
if (position != null) 'position': position,
|
if (position != null) 'position': position,
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ class Player {
|
|||||||
final bool? volumeMuted;
|
final bool? volumeMuted;
|
||||||
final double? elapsedTime; // Seconds elapsed in current track
|
final double? elapsedTime; // Seconds elapsed in current track
|
||||||
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 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
|
||||||
|
|
||||||
Player({
|
Player({
|
||||||
required this.playerId,
|
required this.playerId,
|
||||||
@@ -25,6 +29,10 @@ class Player {
|
|||||||
this.volumeMuted,
|
this.volumeMuted,
|
||||||
this.elapsedTime,
|
this.elapsedTime,
|
||||||
this.elapsedTimeLastUpdated,
|
this.elapsedTimeLastUpdated,
|
||||||
|
this.groupMembers,
|
||||||
|
this.syncedTo,
|
||||||
|
this.activeSource,
|
||||||
|
this.isExternalSource = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Create a copy of this Player with some fields replaced
|
/// Create a copy of this Player with some fields replaced
|
||||||
@@ -40,6 +48,10 @@ class Player {
|
|||||||
bool? volumeMuted,
|
bool? volumeMuted,
|
||||||
double? elapsedTime,
|
double? elapsedTime,
|
||||||
double? elapsedTimeLastUpdated,
|
double? elapsedTimeLastUpdated,
|
||||||
|
List<String>? groupMembers,
|
||||||
|
String? syncedTo,
|
||||||
|
String? activeSource,
|
||||||
|
bool? isExternalSource,
|
||||||
}) {
|
}) {
|
||||||
return Player(
|
return Player(
|
||||||
playerId: playerId ?? this.playerId,
|
playerId: playerId ?? this.playerId,
|
||||||
@@ -53,6 +65,10 @@ class Player {
|
|||||||
volumeMuted: volumeMuted ?? this.volumeMuted,
|
volumeMuted: volumeMuted ?? this.volumeMuted,
|
||||||
elapsedTime: elapsedTime ?? this.elapsedTime,
|
elapsedTime: elapsedTime ?? this.elapsedTime,
|
||||||
elapsedTimeLastUpdated: elapsedTimeLastUpdated ?? this.elapsedTimeLastUpdated,
|
elapsedTimeLastUpdated: elapsedTimeLastUpdated ?? this.elapsedTimeLastUpdated,
|
||||||
|
groupMembers: groupMembers ?? this.groupMembers,
|
||||||
|
syncedTo: syncedTo ?? this.syncedTo,
|
||||||
|
activeSource: activeSource ?? this.activeSource,
|
||||||
|
isExternalSource: isExternalSource ?? this.isExternalSource,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +77,12 @@ class Player {
|
|||||||
bool get isMuted => volumeMuted ?? false;
|
bool get isMuted => volumeMuted ?? false;
|
||||||
int get volume => volumeLevel ?? 0;
|
int get volume => volumeLevel ?? 0;
|
||||||
|
|
||||||
|
// Group properties
|
||||||
|
// A player is grouped if it's a leader with members OR a child synced to another
|
||||||
|
bool get isGrouped => (groupMembers != null && groupMembers!.length > 1) || syncedTo != null;
|
||||||
|
bool get isGroupLeader => groupMembers != null && groupMembers!.length > 1 && syncedTo == null;
|
||||||
|
bool get isGroupChild => syncedTo != null;
|
||||||
|
|
||||||
// Track when this Player object was created (for local interpolation fallback)
|
// Track when this Player object was created (for local interpolation fallback)
|
||||||
static final Map<String, double> _playerCreationTimes = {};
|
static final Map<String, double> _playerCreationTimes = {};
|
||||||
|
|
||||||
@@ -164,6 +186,68 @@ class Player {
|
|||||||
elapsedTime ??= topLevelElapsedTime;
|
elapsedTime ??= topLevelElapsedTime;
|
||||||
elapsedTimeLastUpdated ??= topLevelLastUpdated;
|
elapsedTimeLastUpdated ??= topLevelLastUpdated;
|
||||||
|
|
||||||
|
// Parse group members - MA returns this as a list of player IDs
|
||||||
|
final groupMembersList = json['group_members'] as List<dynamic>?;
|
||||||
|
final groupMembers = groupMembersList?.map((e) => e.toString()).toList();
|
||||||
|
|
||||||
|
// Parse synced_to - the player ID this player is synced to
|
||||||
|
final syncedTo = json['synced_to'] as String?;
|
||||||
|
|
||||||
|
// 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,
|
||||||
@@ -176,6 +260,10 @@ class Player {
|
|||||||
volumeMuted: json['volume_muted'] as bool?,
|
volumeMuted: json['volume_muted'] as bool?,
|
||||||
elapsedTime: elapsedTime,
|
elapsedTime: elapsedTime,
|
||||||
elapsedTimeLastUpdated: elapsedTimeLastUpdated,
|
elapsedTimeLastUpdated: elapsedTimeLastUpdated,
|
||||||
|
groupMembers: groupMembers,
|
||||||
|
syncedTo: syncedTo,
|
||||||
|
activeSource: activeSource,
|
||||||
|
isExternalSource: isExternalSource,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +280,10 @@ class Player {
|
|||||||
if (volumeMuted != null) 'volume_muted': volumeMuted,
|
if (volumeMuted != null) 'volume_muted': volumeMuted,
|
||||||
if (elapsedTime != null) 'elapsed_time': elapsedTime,
|
if (elapsedTime != null) 'elapsed_time': elapsedTime,
|
||||||
if (elapsedTimeLastUpdated != null) 'elapsed_time_last_updated': elapsedTimeLastUpdated,
|
if (elapsedTimeLastUpdated != null) 'elapsed_time_last_updated': elapsedTimeLastUpdated,
|
||||||
|
if (groupMembers != null) 'group_members': groupMembers,
|
||||||
|
if (syncedTo != null) 'synced_to': syncedTo,
|
||||||
|
if (activeSource != null) 'active_source': activeSource,
|
||||||
|
'is_external_source': isExternalSource,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,7 +189,9 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
_logger.log('✅ MA authentication successful');
|
_logger.log('✅ MA authentication successful');
|
||||||
onAuthenticated?.call();
|
onAuthenticated?.call();
|
||||||
} else if (state == MAConnectionState.disconnected) {
|
} else if (state == MAConnectionState.disconnected) {
|
||||||
_cacheService.clearAll();
|
// DON'T clear caches on disconnect - keep showing cached data
|
||||||
|
// This allows instant UI display when app resumes or reconnects
|
||||||
|
// Caches will be refreshed when connection is restored
|
||||||
onDisconnected?.call();
|
onDisconnected?.call();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -215,6 +217,12 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
await _api?.disconnect();
|
await _api?.disconnect();
|
||||||
_connectionState = MAConnectionState.disconnected;
|
_connectionState = MAConnectionState.disconnected;
|
||||||
|
// DON'T clear caches on disconnect - keep showing cached data for instant reconnect
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all caches and state (for logout or server change)
|
||||||
|
void clearCachesOnLogout() {
|
||||||
_cacheService.clearAll();
|
_cacheService.clearAll();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/painting.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../models/player.dart';
|
import '../models/player.dart';
|
||||||
import '../services/music_assistant_api.dart';
|
import '../services/music_assistant_api.dart';
|
||||||
|
|||||||
@@ -16,11 +16,14 @@ import 'artist_details_screen.dart';
|
|||||||
class AlbumDetailsScreen extends StatefulWidget {
|
class AlbumDetailsScreen extends StatefulWidget {
|
||||||
final Album album;
|
final Album album;
|
||||||
final String? heroTagSuffix;
|
final String? heroTagSuffix;
|
||||||
|
/// Initial image URL from the source (e.g., AlbumCard) for seamless hero animation
|
||||||
|
final String? initialImageUrl;
|
||||||
|
|
||||||
const AlbumDetailsScreen({
|
const AlbumDetailsScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.album,
|
required this.album,
|
||||||
this.heroTagSuffix,
|
this.heroTagSuffix,
|
||||||
|
this.initialImageUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -37,6 +40,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
int? _expandedTrackIndex;
|
int? _expandedTrackIndex;
|
||||||
bool _isDescriptionExpanded = false;
|
bool _isDescriptionExpanded = false;
|
||||||
String? _albumDescription;
|
String? _albumDescription;
|
||||||
|
Album? _freshAlbum; // Full album data with image metadata
|
||||||
|
|
||||||
|
/// Get the best album data available (fresh with images, or widget.album as fallback)
|
||||||
|
Album get _displayAlbum => _freshAlbum ?? widget.album;
|
||||||
|
|
||||||
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||||
|
|
||||||
@@ -46,29 +53,31 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
_isFavorite = widget.album.favorite ?? false;
|
_isFavorite = widget.album.favorite ?? false;
|
||||||
_loadTracks();
|
_loadTracks();
|
||||||
_loadAlbumDescription();
|
_loadAlbumDescription();
|
||||||
_refreshFavoriteStatus();
|
|
||||||
|
|
||||||
// CRITICAL FIX: Defer color extraction until after the transition completes
|
// CRITICAL FIX: Defer both fresh data loading AND color extraction until
|
||||||
// This prevents expensive palette extraction during hero animation
|
// after the hero animation completes. This prevents:
|
||||||
// Wait for the first frame to be painted, then extract colors
|
// 1. setState with new image URL during animation → grey icon flash
|
||||||
|
// 2. Expensive palette extraction blocking animation frames
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
// Add small delay to ensure transition is complete
|
// 350ms matches FadeSlidePageRoute duration (300ms) + buffer
|
||||||
Future.delayed(const Duration(milliseconds: 350), () {
|
Future.delayed(const Duration(milliseconds: 350), () {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
_loadFreshAlbumData();
|
||||||
_extractColors();
|
_extractColors();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refreshFavoriteStatus() async {
|
/// Load fresh album data from API to get full metadata including images
|
||||||
|
Future<void> _loadFreshAlbumData() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
if (maProvider.api == null) return;
|
if (maProvider.api == null) return;
|
||||||
|
|
||||||
// Need a URI to fetch fresh album data
|
// Need a URI to fetch fresh album data
|
||||||
final albumUri = widget.album.uri;
|
final albumUri = widget.album.uri;
|
||||||
if (albumUri == null || albumUri.isEmpty) {
|
if (albumUri == null || albumUri.isEmpty) {
|
||||||
_logger.log('Cannot refresh favorite status: album has no URI');
|
_logger.log('Cannot load fresh album: album has no URI');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,17 +85,20 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
final freshAlbum = await maProvider.api!.getAlbumByUri(albumUri);
|
final freshAlbum = await maProvider.api!.getAlbumByUri(albumUri);
|
||||||
if (freshAlbum != null && mounted) {
|
if (freshAlbum != null && mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_freshAlbum = freshAlbum;
|
||||||
_isFavorite = freshAlbum.favorite ?? false;
|
_isFavorite = freshAlbum.favorite ?? false;
|
||||||
});
|
});
|
||||||
|
// Re-extract colors now that we have fresh album with images
|
||||||
|
_extractColors();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Error refreshing favorite status: $e');
|
_logger.log('Error loading fresh album data: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _extractColors() async {
|
Future<void> _extractColors() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final imageUrl = maProvider.getImageUrl(widget.album, size: 512);
|
final imageUrl = maProvider.getImageUrl(_displayAlbum, size: 512);
|
||||||
|
|
||||||
if (imageUrl == null) return;
|
if (imageUrl == null) return;
|
||||||
|
|
||||||
@@ -108,10 +120,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
|
|
||||||
Future<void> _toggleFavorite() async {
|
Future<void> _toggleFavorite() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
if (maProvider.api == null) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final newState = !_isFavorite;
|
final newState = !_isFavorite;
|
||||||
|
bool success;
|
||||||
|
|
||||||
if (newState) {
|
if (newState) {
|
||||||
// For adding: use the actual provider and itemId from provider_mappings
|
// For adding: use the actual provider and itemId from provider_mappings
|
||||||
@@ -132,17 +144,18 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('Adding to favorites: provider=$actualProvider, itemId=$actualItemId');
|
_logger.log('Adding to favorites: provider=$actualProvider, itemId=$actualItemId');
|
||||||
await maProvider.api!.addToFavorites('album', actualItemId, actualProvider);
|
success = await maProvider.addToFavorites(
|
||||||
|
mediaType: 'album',
|
||||||
|
itemId: actualItemId,
|
||||||
|
provider: actualProvider,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// For removing: need the library_item_id (numeric)
|
// For removing: need the library_item_id (numeric)
|
||||||
// When provider is "library", itemId is the library ID as a string
|
|
||||||
// Otherwise, we need to find it from provider_mappings
|
|
||||||
int? libraryItemId;
|
int? libraryItemId;
|
||||||
|
|
||||||
if (widget.album.provider == 'library') {
|
if (widget.album.provider == 'library') {
|
||||||
libraryItemId = int.tryParse(widget.album.itemId);
|
libraryItemId = int.tryParse(widget.album.itemId);
|
||||||
} else if (widget.album.providerMappings != null) {
|
} else if (widget.album.providerMappings != null) {
|
||||||
// Find the library mapping
|
|
||||||
final libraryMapping = widget.album.providerMappings!.firstWhere(
|
final libraryMapping = widget.album.providerMappings!.firstWhere(
|
||||||
(m) => m.providerInstance == 'library',
|
(m) => m.providerInstance == 'library',
|
||||||
orElse: () => widget.album.providerMappings!.first,
|
orElse: () => widget.album.providerMappings!.first,
|
||||||
@@ -158,25 +171,34 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('Removing from favorites: libraryItemId=$libraryItemId');
|
_logger.log('Removing from favorites: libraryItemId=$libraryItemId');
|
||||||
await maProvider.api!.removeFromFavorites('album', libraryItemId);
|
success = await maProvider.removeFromFavorites(
|
||||||
|
mediaType: 'album',
|
||||||
|
libraryItemId: libraryItemId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
if (success) {
|
||||||
_isFavorite = newState;
|
// Optimistically update UI regardless of online/offline state
|
||||||
});
|
setState(() {
|
||||||
|
_isFavorite = newState;
|
||||||
|
});
|
||||||
|
|
||||||
// Invalidate home cache so the home screen shows updated favorite status
|
// Invalidate home cache so the home screen shows updated favorite status
|
||||||
maProvider.invalidateHomeCache();
|
maProvider.invalidateHomeCache();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final isOffline = !maProvider.isConnected;
|
||||||
SnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
content: Text(
|
SnackBar(
|
||||||
_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
|
content: Text(
|
||||||
|
isOffline
|
||||||
|
? S.of(context)!.actionQueuedForSync
|
||||||
|
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
duration: const Duration(seconds: 1),
|
);
|
||||||
),
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Error toggling favorite: $e');
|
_logger.log('Error toggling favorite: $e');
|
||||||
@@ -196,11 +218,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
|
|
||||||
final track = _tracks[trackIndex];
|
final track = _tracks[trackIndex];
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
if (maProvider.api == null) return;
|
|
||||||
|
|
||||||
final currentFavorite = track.favorite ?? false;
|
final currentFavorite = track.favorite ?? false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
bool success;
|
||||||
|
|
||||||
if (currentFavorite) {
|
if (currentFavorite) {
|
||||||
// Remove from favorites - need library_item_id
|
// Remove from favorites - need library_item_id
|
||||||
int? libraryItemId;
|
int? libraryItemId;
|
||||||
@@ -217,7 +239,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (libraryItemId != null) {
|
if (libraryItemId != null) {
|
||||||
await maProvider.api!.removeFromFavorites('track', libraryItemId);
|
success = await maProvider.removeFromFavorites(
|
||||||
|
mediaType: 'track',
|
||||||
|
libraryItemId: libraryItemId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
success = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Add to favorites
|
// Add to favorites
|
||||||
@@ -236,33 +263,42 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
actualItemId = mapping.itemId;
|
actualItemId = mapping.itemId;
|
||||||
}
|
}
|
||||||
|
|
||||||
await maProvider.api!.addToFavorites('track', actualItemId, actualProvider);
|
success = await maProvider.addToFavorites(
|
||||||
|
mediaType: 'track',
|
||||||
|
itemId: actualItemId,
|
||||||
|
provider: actualProvider,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update local state - create new track with toggled favorite
|
if (success) {
|
||||||
setState(() {
|
// Optimistically update local state
|
||||||
_tracks[trackIndex] = Track(
|
setState(() {
|
||||||
itemId: track.itemId,
|
_tracks[trackIndex] = Track(
|
||||||
provider: track.provider,
|
itemId: track.itemId,
|
||||||
name: track.name,
|
provider: track.provider,
|
||||||
uri: track.uri,
|
name: track.name,
|
||||||
favorite: !currentFavorite,
|
uri: track.uri,
|
||||||
artists: track.artists,
|
favorite: !currentFavorite,
|
||||||
album: track.album,
|
artists: track.artists,
|
||||||
duration: track.duration,
|
album: track.album,
|
||||||
providerMappings: track.providerMappings,
|
duration: track.duration,
|
||||||
);
|
providerMappings: track.providerMappings,
|
||||||
});
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final isOffline = !maProvider.isConnected;
|
||||||
SnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
content: Text(
|
SnackBar(
|
||||||
!currentFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
|
content: Text(
|
||||||
|
isOffline
|
||||||
|
? S.of(context)!.actionQueuedForSync
|
||||||
|
: (!currentFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
duration: const Duration(seconds: 1),
|
);
|
||||||
),
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Error toggling track favorite: $e');
|
_logger.log('Error toggling track favorite: $e');
|
||||||
@@ -279,16 +315,46 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
|
|
||||||
Future<void> _loadTracks() async {
|
Future<void> _loadTracks() async {
|
||||||
final provider = context.read<MusicAssistantProvider>();
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
final tracks = await provider.getAlbumTracksWithCache(
|
final cacheKey = '${widget.album.provider}_${widget.album.itemId}';
|
||||||
widget.album.provider,
|
|
||||||
widget.album.itemId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mounted) {
|
// 1. Show cached data immediately (if available)
|
||||||
setState(() {
|
final cachedTracks = provider.getCachedAlbumTracks(cacheKey);
|
||||||
_tracks = tracks;
|
if (cachedTracks != null && cachedTracks.isNotEmpty) {
|
||||||
_isLoading = false;
|
if (mounted) {
|
||||||
});
|
setState(() {
|
||||||
|
_tracks = cachedTracks;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch fresh data in background (silent refresh)
|
||||||
|
try {
|
||||||
|
final freshTracks = await provider.getAlbumTracksWithCache(
|
||||||
|
widget.album.provider,
|
||||||
|
widget.album.itemId,
|
||||||
|
forceRefresh: cachedTracks != null, // Force refresh if we had cache
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Update if we got different data
|
||||||
|
if (mounted && freshTracks.isNotEmpty) {
|
||||||
|
final tracksChanged = _tracks.length != freshTracks.length ||
|
||||||
|
(_tracks.isNotEmpty && freshTracks.isNotEmpty &&
|
||||||
|
_tracks.first.itemId != freshTracks.first.itemId);
|
||||||
|
if (tracksChanged || _tracks.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_tracks = freshTracks;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Background refresh failed: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure loading is false even if everything failed
|
||||||
|
if (mounted && _isLoading) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,7 +558,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -595,9 +661,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
|
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
|
||||||
// Only rebuild when specific properties change, not on every provider update
|
// Only rebuild when specific properties change, not on every provider update
|
||||||
final imageUrl = context.select<MusicAssistantProvider, String?>(
|
// Use _displayAlbum which has fresh data with images if available
|
||||||
(provider) => provider.getImageUrl(widget.album, size: 512),
|
final providerImageUrl = context.select<MusicAssistantProvider, String?>(
|
||||||
|
(provider) => provider.getImageUrl(_displayAlbum, size: 512),
|
||||||
);
|
);
|
||||||
|
// Use initialImageUrl as fallback for seamless hero animation
|
||||||
|
final imageUrl = providerImageUrl ?? widget.initialImageUrl;
|
||||||
final adaptiveTheme = context.select<ThemeProvider, bool>(
|
final adaptiveTheme = context.select<ThemeProvider, bool>(
|
||||||
(provider) => provider.adaptiveTheme,
|
(provider) => provider.adaptiveTheme,
|
||||||
);
|
);
|
||||||
@@ -1063,7 +1132,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, {
|
||||||
@@ -1072,21 +1141,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(
|
||||||
@@ -1094,30 +1158,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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -155,10 +155,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
|
|
||||||
Future<void> _toggleFavorite() async {
|
Future<void> _toggleFavorite() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
if (maProvider.api == null) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final newState = !_isFavorite;
|
final newState = !_isFavorite;
|
||||||
|
bool success;
|
||||||
|
|
||||||
if (newState) {
|
if (newState) {
|
||||||
String actualProvider = widget.artist.provider;
|
String actualProvider = widget.artist.provider;
|
||||||
@@ -177,7 +177,11 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('Adding artist to favorites: provider=$actualProvider, itemId=$actualItemId');
|
_logger.log('Adding artist to favorites: provider=$actualProvider, itemId=$actualItemId');
|
||||||
await maProvider.api!.addToFavorites('artist', actualItemId, actualProvider);
|
success = await maProvider.addToFavorites(
|
||||||
|
mediaType: 'artist',
|
||||||
|
itemId: actualItemId,
|
||||||
|
provider: actualProvider,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
int? libraryItemId;
|
int? libraryItemId;
|
||||||
|
|
||||||
@@ -199,24 +203,32 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('Removing artist from favorites: libraryItemId=$libraryItemId');
|
_logger.log('Removing artist from favorites: libraryItemId=$libraryItemId');
|
||||||
await maProvider.api!.removeFromFavorites('artist', libraryItemId);
|
success = await maProvider.removeFromFavorites(
|
||||||
|
mediaType: 'artist',
|
||||||
|
libraryItemId: libraryItemId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
if (success) {
|
||||||
_isFavorite = newState;
|
setState(() {
|
||||||
});
|
_isFavorite = newState;
|
||||||
|
});
|
||||||
|
|
||||||
maProvider.invalidateHomeCache();
|
maProvider.invalidateHomeCache();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final isOffline = !maProvider.isConnected;
|
||||||
SnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
content: Text(
|
SnackBar(
|
||||||
_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
|
content: Text(
|
||||||
|
isOffline
|
||||||
|
? S.of(context)!.actionQueuedForSync
|
||||||
|
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
duration: const Duration(seconds: 1),
|
);
|
||||||
),
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Error toggling artist favorite: $e');
|
_logger.log('Error toggling artist favorite: $e');
|
||||||
@@ -231,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,
|
||||||
@@ -302,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 {
|
||||||
@@ -333,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 {
|
||||||
@@ -427,20 +464,51 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
Future<void> _loadArtistAlbums() async {
|
Future<void> _loadArtistAlbums() async {
|
||||||
final provider = context.read<MusicAssistantProvider>();
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
// Use cached artist albums method
|
// 1. Show cached data immediately (if available)
|
||||||
final allAlbums = await provider.getArtistAlbumsWithCache(widget.artist.name);
|
final cachedAlbums = provider.getCachedArtistAlbums(widget.artist.name);
|
||||||
|
if (cachedAlbums != null && cachedAlbums.isNotEmpty) {
|
||||||
|
final libraryAlbums = cachedAlbums.where((a) => a.inLibrary).toList();
|
||||||
|
final providerOnlyAlbums = cachedAlbums.where((a) => !a.inLibrary).toList();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
// Separate library albums from provider-only albums
|
setState(() {
|
||||||
final libraryAlbums = allAlbums.where((a) => a.inLibrary).toList();
|
_albums = libraryAlbums;
|
||||||
final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList();
|
_providerAlbums = providerOnlyAlbums;
|
||||||
|
_sortAlbums();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
// 2. Fetch fresh data in background (silent refresh)
|
||||||
_albums = libraryAlbums;
|
try {
|
||||||
_providerAlbums = providerOnlyAlbums;
|
final allAlbums = await provider.getArtistAlbumsWithCache(
|
||||||
_sortAlbums(); // Apply saved sort order
|
widget.artist.name,
|
||||||
_isLoading = false;
|
forceRefresh: cachedAlbums != null,
|
||||||
});
|
);
|
||||||
|
|
||||||
|
if (mounted && allAlbums.isNotEmpty) {
|
||||||
|
// Check if data actually changed
|
||||||
|
final albumsChanged = _albums.length != allAlbums.where((a) => a.inLibrary).length;
|
||||||
|
|
||||||
|
if (albumsChanged || _albums.isEmpty) {
|
||||||
|
final libraryAlbums = allAlbums.where((a) => a.inLibrary).toList();
|
||||||
|
final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList();
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_albums = libraryAlbums;
|
||||||
|
_providerAlbums = providerOnlyAlbums;
|
||||||
|
_sortAlbums();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent failure - keep showing cached data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted && _isLoading) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,10 +578,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
height: 200,
|
height: 200,
|
||||||
color: colorScheme.surfaceVariant,
|
color: colorScheme.surfaceVariant,
|
||||||
child: imageUrl != null
|
child: imageUrl != null
|
||||||
? Image.network(
|
? CachedNetworkImage(
|
||||||
imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => Icon(
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
errorWidget: (_, __, ___) => Icon(
|
||||||
Icons.person_rounded,
|
Icons.person_rounded,
|
||||||
size: 100,
|
size: 100,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
@@ -582,7 +652,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(
|
||||||
@@ -595,23 +665,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),
|
||||||
|
|
||||||
@@ -620,7 +673,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(
|
||||||
@@ -817,6 +870,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
child: AlbumDetailsScreen(
|
child: AlbumDetailsScreen(
|
||||||
album: album,
|
album: album,
|
||||||
heroTagSuffix: heroSuffix,
|
heroTagSuffix: heroSuffix,
|
||||||
|
initialImageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -833,12 +887,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
color: colorScheme.surfaceVariant,
|
color: colorScheme.surfaceVariant,
|
||||||
child: imageUrl != null
|
child: imageUrl != null
|
||||||
? Image.network(
|
? CachedNetworkImage(
|
||||||
imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
errorBuilder: (_, __, ___) => Center(
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
errorWidget: (_, __, ___) => Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.album_rounded,
|
Icons.album_rounded,
|
||||||
size: 64,
|
size: 64,
|
||||||
@@ -940,10 +996,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
height: 56,
|
height: 56,
|
||||||
color: colorScheme.surfaceVariant,
|
color: colorScheme.surfaceVariant,
|
||||||
child: imageUrl != null
|
child: imageUrl != null
|
||||||
? Image.network(
|
? CachedNetworkImage(
|
||||||
imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => Icon(
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
errorWidget: (_, __, ___) => Icon(
|
||||||
Icons.album_rounded,
|
Icons.album_rounded,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -979,6 +1037,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
child: AlbumDetailsScreen(
|
child: AlbumDetailsScreen(
|
||||||
album: album,
|
album: album,
|
||||||
heroTagSuffix: 'artist_albums',
|
heroTagSuffix: 'artist_albums',
|
||||||
|
initialImageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ class AudiobookAuthorScreen extends StatefulWidget {
|
|||||||
final String authorName;
|
final String authorName;
|
||||||
final List<Audiobook> audiobooks;
|
final List<Audiobook> audiobooks;
|
||||||
final String? heroTagSuffix;
|
final String? heroTagSuffix;
|
||||||
|
final String? initialAuthorImageUrl;
|
||||||
|
|
||||||
const AudiobookAuthorScreen({
|
const AudiobookAuthorScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.authorName,
|
required this.authorName,
|
||||||
required this.audiobooks,
|
required this.audiobooks,
|
||||||
this.heroTagSuffix,
|
this.heroTagSuffix,
|
||||||
|
this.initialAuthorImageUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -48,6 +50,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_audiobooks = List.from(widget.audiobooks);
|
_audiobooks = List.from(widget.audiobooks);
|
||||||
|
// Use initial image URL immediately for smooth hero animation
|
||||||
|
_authorImageUrl = widget.initialAuthorImageUrl;
|
||||||
_loadViewPreferences();
|
_loadViewPreferences();
|
||||||
_sortAudiobooks();
|
_sortAudiobooks();
|
||||||
_loadAuthorImage();
|
_loadAuthorImage();
|
||||||
@@ -181,6 +185,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
width: 200,
|
width: 200,
|
||||||
height: 200,
|
height: 200,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (_, __) => Icon(
|
placeholder: (_, __) => Icon(
|
||||||
MdiIcons.accountOutline,
|
MdiIcons.accountOutline,
|
||||||
size: 100,
|
size: 100,
|
||||||
@@ -329,7 +335,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
|||||||
final heroSuffix = 'author${_heroTagSuffix}';
|
final heroSuffix = 'author${_heroTagSuffix}';
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix),
|
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -349,6 +355,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (_, __) => Center(
|
placeholder: (_, __) => Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
MdiIcons.bookOutline,
|
MdiIcons.bookOutline,
|
||||||
@@ -443,6 +451,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (_, __) => Icon(
|
placeholder: (_, __) => Icon(
|
||||||
MdiIcons.bookOutline,
|
MdiIcons.bookOutline,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
@@ -508,17 +518,18 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix),
|
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix}) {
|
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix, String? initialImageUrl}) {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
FadeSlidePageRoute(
|
FadeSlidePageRoute(
|
||||||
child: AudiobookDetailScreen(
|
child: AudiobookDetailScreen(
|
||||||
audiobook: book,
|
audiobook: book,
|
||||||
heroTagSuffix: heroTagSuffix,
|
heroTagSuffix: heroTagSuffix,
|
||||||
|
initialImageUrl: initialImageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ import '../l10n/app_localizations.dart';
|
|||||||
class AudiobookDetailScreen extends StatefulWidget {
|
class AudiobookDetailScreen extends StatefulWidget {
|
||||||
final Audiobook audiobook;
|
final Audiobook audiobook;
|
||||||
final String? heroTagSuffix;
|
final String? heroTagSuffix;
|
||||||
|
/// Initial image URL from the source for seamless hero animation
|
||||||
|
final String? initialImageUrl;
|
||||||
|
|
||||||
const AudiobookDetailScreen({
|
const AudiobookDetailScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.audiobook,
|
required this.audiobook,
|
||||||
this.heroTagSuffix,
|
this.heroTagSuffix,
|
||||||
|
this.initialImageUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -119,10 +122,10 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
|
|
||||||
Future<void> _toggleFavorite() async {
|
Future<void> _toggleFavorite() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
if (maProvider.api == null) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final newState = !_isFavorite;
|
final newState = !_isFavorite;
|
||||||
|
bool success;
|
||||||
|
|
||||||
if (newState) {
|
if (newState) {
|
||||||
String actualProvider = widget.audiobook.provider;
|
String actualProvider = widget.audiobook.provider;
|
||||||
@@ -141,7 +144,11 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('Adding audiobook to favorites: provider=$actualProvider, itemId=$actualItemId');
|
_logger.log('Adding audiobook to favorites: provider=$actualProvider, itemId=$actualItemId');
|
||||||
await maProvider.api!.addToFavorites('audiobook', actualItemId, actualProvider);
|
success = await maProvider.addToFavorites(
|
||||||
|
mediaType: 'audiobook',
|
||||||
|
itemId: actualItemId,
|
||||||
|
provider: actualProvider,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
int? libraryItemId;
|
int? libraryItemId;
|
||||||
|
|
||||||
@@ -162,22 +169,30 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('Removing audiobook from favorites: libraryItemId=$libraryItemId');
|
_logger.log('Removing audiobook from favorites: libraryItemId=$libraryItemId');
|
||||||
await maProvider.api!.removeFromFavorites('audiobook', libraryItemId);
|
success = await maProvider.removeFromFavorites(
|
||||||
|
mediaType: 'audiobook',
|
||||||
|
libraryItemId: libraryItemId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
if (success) {
|
||||||
_isFavorite = newState;
|
setState(() {
|
||||||
});
|
_isFavorite = newState;
|
||||||
|
});
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final isOffline = !maProvider.isConnected;
|
||||||
SnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
content: Text(
|
SnackBar(
|
||||||
_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
|
content: Text(
|
||||||
|
isOffline
|
||||||
|
? S.of(context)!.actionQueuedForSync
|
||||||
|
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
duration: const Duration(seconds: 1),
|
);
|
||||||
),
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Error toggling audiobook favorite: $e');
|
_logger.log('Error toggling audiobook favorite: $e');
|
||||||
@@ -196,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(
|
||||||
@@ -220,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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -414,7 +431,9 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final imageUrl = maProvider.getImageUrl(widget.audiobook, size: 512);
|
final providerImageUrl = maProvider.getImageUrl(widget.audiobook, size: 512);
|
||||||
|
// Use initialImageUrl as fallback for seamless hero animation
|
||||||
|
final imageUrl = providerImageUrl ?? widget.initialImageUrl;
|
||||||
|
|
||||||
final adaptiveTheme = context.select<ThemeProvider, bool>(
|
final adaptiveTheme = context.select<ThemeProvider, bool>(
|
||||||
(provider) => provider.adaptiveTheme,
|
(provider) => provider.adaptiveTheme,
|
||||||
@@ -528,6 +547,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (_, __) => Center(
|
placeholder: (_, __) => Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
MdiIcons.bookOutline,
|
MdiIcons.bookOutline,
|
||||||
|
|||||||
@@ -517,6 +517,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: coverUrl,
|
imageUrl: coverUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (_, __) => Container(
|
placeholder: (_, __) => Container(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
),
|
),
|
||||||
@@ -633,7 +635,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
|||||||
final heroSuffix = 'series_${widget.series.id}';
|
final heroSuffix = 'series_${widget.series.id}';
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix),
|
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -653,6 +655,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (_, __) => Center(
|
placeholder: (_, __) => Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
MdiIcons.bookOutline,
|
MdiIcons.bookOutline,
|
||||||
@@ -746,6 +750,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (_, __) => Icon(
|
placeholder: (_, __) => Icon(
|
||||||
MdiIcons.bookOutline,
|
MdiIcons.bookOutline,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
@@ -811,17 +817,18 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix),
|
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix}) {
|
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix, String? initialImageUrl}) {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
FadeSlidePageRoute(
|
FadeSlidePageRoute(
|
||||||
child: AudiobookDetailScreen(
|
child: AudiobookDetailScreen(
|
||||||
audiobook: book,
|
audiobook: book,
|
||||||
heroTagSuffix: heroTagSuffix,
|
heroTagSuffix: heroTagSuffix,
|
||||||
|
initialImageUrl: initialImageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
onPopInvokedWithResult: (didPop, result) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
if (didPop) return;
|
if (didPop) return;
|
||||||
|
|
||||||
|
// If player reveal (device list) is visible, dismiss it first (with animation)
|
||||||
|
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
|
||||||
|
GlobalPlayerOverlay.dismissPlayerReveal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If global player is expanded, collapse it first
|
// If global player is expanded, collapse it first
|
||||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||||
GlobalPlayerOverlay.collapsePlayer();
|
GlobalPlayerOverlay.collapsePlayer();
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
|||||||
Widget _buildAlbumsList(BuildContext context, List<Album> albums, bool isLoading) {
|
Widget _buildAlbumsList(BuildContext context, List<Album> albums, bool isLoading) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (isLoading) {
|
// Show cached data immediately if available, even while loading
|
||||||
|
// Only show spinner if we have no data at all AND we're loading
|
||||||
|
if (albums.isEmpty && isLoading) {
|
||||||
return Center(
|
return Center(
|
||||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
Widget _buildArtistsList(BuildContext context, List<Artist> artists, bool isLoading) {
|
Widget _buildArtistsList(BuildContext context, List<Artist> artists, bool isLoading) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (isLoading) {
|
// Show cached data immediately if available, even while loading
|
||||||
|
// Only show spinner if we have no data at all AND we're loading
|
||||||
|
if (artists.isEmpty && isLoading) {
|
||||||
return Center(
|
return Center(
|
||||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
Widget _buildPlaylistsList(BuildContext context, MusicAssistantProvider provider) {
|
Widget _buildPlaylistsList(BuildContext context, MusicAssistantProvider provider) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (_isLoading) {
|
// Show cached data immediately if available, even while loading
|
||||||
|
// Only show spinner if we have no data at all AND we're loading
|
||||||
|
if (_playlists.isEmpty && _isLoading) {
|
||||||
return Center(
|
return Center(
|
||||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ class LibraryTracksScreen extends StatelessWidget {
|
|||||||
BuildContext context, MusicAssistantProvider provider) {
|
BuildContext context, MusicAssistantProvider provider) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (provider.isLoading) {
|
// Show cached data immediately if available, even while loading
|
||||||
|
// Only show spinner if we have no data at all AND we're loading
|
||||||
|
if (provider.tracks.isEmpty && provider.isLoading) {
|
||||||
return Center(
|
return Center(
|
||||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -304,6 +304,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
key: const ValueKey('recent-albums'),
|
key: const ValueKey('recent-albums'),
|
||||||
title: S.of(context)!.recentlyPlayed,
|
title: S.of(context)!.recentlyPlayed,
|
||||||
loadAlbums: () => provider.getRecentAlbumsWithCache(),
|
loadAlbums: () => provider.getRecentAlbumsWithCache(),
|
||||||
|
getCachedAlbums: () => provider.getCachedRecentAlbums(),
|
||||||
rowHeight: rowHeight,
|
rowHeight: rowHeight,
|
||||||
);
|
);
|
||||||
case 'discover-artists':
|
case 'discover-artists':
|
||||||
@@ -312,6 +313,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
key: const ValueKey('discover-artists'),
|
key: const ValueKey('discover-artists'),
|
||||||
title: S.of(context)!.discoverArtists,
|
title: S.of(context)!.discoverArtists,
|
||||||
loadArtists: () => provider.getDiscoverArtistsWithCache(),
|
loadArtists: () => provider.getDiscoverArtistsWithCache(),
|
||||||
|
getCachedArtists: () => provider.getCachedDiscoverArtists(),
|
||||||
rowHeight: rowHeight,
|
rowHeight: rowHeight,
|
||||||
);
|
);
|
||||||
case 'discover-albums':
|
case 'discover-albums':
|
||||||
@@ -320,6 +322,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
key: const ValueKey('discover-albums'),
|
key: const ValueKey('discover-albums'),
|
||||||
title: S.of(context)!.discoverAlbums,
|
title: S.of(context)!.discoverAlbums,
|
||||||
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
|
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
|
||||||
|
getCachedAlbums: () => provider.getCachedDiscoverAlbums(),
|
||||||
rowHeight: rowHeight,
|
rowHeight: rowHeight,
|
||||||
);
|
);
|
||||||
case 'continue-listening':
|
case 'continue-listening':
|
||||||
|
|||||||
@@ -1005,6 +1005,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (_, __) => Text(
|
placeholder: (_, __) => Text(
|
||||||
authorName.isNotEmpty ? authorName[0].toUpperCase() : '?',
|
authorName.isNotEmpty ? authorName[0].toUpperCase() : '?',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -1047,7 +1049,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
color: colorScheme.onSurface.withOpacity(0.6),
|
color: colorScheme.onSurface.withOpacity(0.6),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onTap: () => _navigateToAuthor(authorName, books, heroTagSuffix: 'library$heroSuffix'),
|
onTap: () => _navigateToAuthor(authorName, books, heroTagSuffix: 'library$heroSuffix', initialAuthorImageUrl: authorImageUrl),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1059,7 +1061,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
|
|
||||||
// Match music artist card layout
|
// Match music artist card layout
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _navigateToAuthor(authorName, books, heroTagSuffix: 'library$heroSuffix'),
|
onTap: () => _navigateToAuthor(authorName, books, heroTagSuffix: 'library$heroSuffix', initialAuthorImageUrl: authorImageUrl),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
@@ -1088,6 +1090,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (_, __) => Center(
|
placeholder: (_, __) => Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
authorName.isNotEmpty ? authorName[0].toUpperCase() : '?',
|
authorName.isNotEmpty ? authorName[0].toUpperCase() : '?',
|
||||||
@@ -1142,7 +1146,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToAuthor(String authorName, List<Audiobook> books, {String? heroTagSuffix}) {
|
void _navigateToAuthor(String authorName, List<Audiobook> books, {String? heroTagSuffix, String? initialAuthorImageUrl}) {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
FadeSlidePageRoute(
|
FadeSlidePageRoute(
|
||||||
@@ -1150,6 +1154,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
authorName: authorName,
|
authorName: authorName,
|
||||||
audiobooks: books,
|
audiobooks: books,
|
||||||
heroTagSuffix: heroTagSuffix,
|
heroTagSuffix: heroTagSuffix,
|
||||||
|
initialAuthorImageUrl: initialAuthorImageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1222,17 +1227,18 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: 'library$heroSuffix'),
|
onTap: () => _navigateToAudiobook(book, heroTagSuffix: 'library$heroSuffix', initialImageUrl: imageUrl),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix}) {
|
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix, String? initialImageUrl}) {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
FadeSlidePageRoute(
|
FadeSlidePageRoute(
|
||||||
child: AudiobookDetailScreen(
|
child: AudiobookDetailScreen(
|
||||||
audiobook: book,
|
audiobook: book,
|
||||||
heroTagSuffix: heroTagSuffix,
|
heroTagSuffix: heroTagSuffix,
|
||||||
|
initialImageUrl: initialImageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1325,7 +1331,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
final heroSuffix = _showFavoritesOnly ? '_fav' : '';
|
final heroSuffix = _showFavoritesOnly ? '_fav' : '';
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: 'library$heroSuffix'),
|
onTap: () => _navigateToAudiobook(book, heroTagSuffix: 'library$heroSuffix', initialImageUrl: imageUrl),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -1346,6 +1352,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (_, __) => const SizedBox(),
|
placeholder: (_, __) => const SizedBox(),
|
||||||
errorWidget: (_, __, ___) => Center(
|
errorWidget: (_, __, ___) => Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -2188,7 +2196,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
FadeSlidePageRoute(
|
FadeSlidePageRoute(
|
||||||
child: AlbumDetailsScreen(album: album),
|
child: AlbumDetailsScreen(
|
||||||
|
album: album,
|
||||||
|
initialImageUrl: imageUrl,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,21 +35,48 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadTracks() async {
|
Future<void> _loadTracks() async {
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final tracks = await maProvider.getPlaylistTracksWithCache(
|
final cacheKey = '${widget.provider}_${widget.itemId}';
|
||||||
widget.provider,
|
|
||||||
widget.itemId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mounted) {
|
// 1. Show cached data immediately (if available)
|
||||||
setState(() {
|
final cachedTracks = maProvider.getCachedPlaylistTracks(cacheKey);
|
||||||
_tracks = tracks;
|
if (cachedTracks != null && cachedTracks.isNotEmpty) {
|
||||||
_isLoading = false;
|
if (mounted) {
|
||||||
});
|
setState(() {
|
||||||
|
_tracks = cachedTracks;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch fresh data in background (silent refresh)
|
||||||
|
try {
|
||||||
|
final freshTracks = await maProvider.getPlaylistTracksWithCache(
|
||||||
|
widget.provider,
|
||||||
|
widget.itemId,
|
||||||
|
forceRefresh: cachedTracks != null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Update if we got different data
|
||||||
|
if (mounted && freshTracks.isNotEmpty) {
|
||||||
|
final tracksChanged = _tracks.length != freshTracks.length ||
|
||||||
|
(_tracks.isNotEmpty && freshTracks.isNotEmpty &&
|
||||||
|
_tracks.first.itemId != freshTracks.first.itemId);
|
||||||
|
if (tracksChanged || _tracks.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_tracks = freshTracks;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent failure - keep showing cached data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted && _isLoading) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1184
-217
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import '../providers/music_assistant_provider.dart';
|
|||||||
import '../services/music_assistant_api.dart';
|
import '../services/music_assistant_api.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
import 'debug_log_screen.dart';
|
import 'debug_log_screen.dart';
|
||||||
import 'login_screen.dart';
|
import 'login_screen.dart';
|
||||||
@@ -37,6 +38,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
// Audiobook libraries
|
// Audiobook libraries
|
||||||
List<Map<String, String>> _discoveredLibraries = [];
|
List<Map<String, String>> _discoveredLibraries = [];
|
||||||
Map<String, bool> _libraryEnabled = {};
|
Map<String, bool> _libraryEnabled = {};
|
||||||
|
// Player settings
|
||||||
|
bool _preferLocalPlayer = false;
|
||||||
|
bool _smartSortPlayers = false;
|
||||||
|
// Hint settings
|
||||||
|
bool _showHints = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -81,6 +87,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
libraryEnabled[path] = enabled == null || enabled.contains(path);
|
libraryEnabled[path] = enabled == null || enabled.contains(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load player settings
|
||||||
|
final preferLocal = await SettingsService.getPreferLocalPlayer();
|
||||||
|
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||||
|
|
||||||
|
// Load hint settings
|
||||||
|
final showHints = await SettingsService.getShowHints();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_showRecentAlbums = showRecent;
|
_showRecentAlbums = showRecent;
|
||||||
@@ -95,6 +108,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_homeRowOrder = rowOrder;
|
_homeRowOrder = rowOrder;
|
||||||
_discoveredLibraries = discovered;
|
_discoveredLibraries = discovered;
|
||||||
_libraryEnabled = libraryEnabled;
|
_libraryEnabled = libraryEnabled;
|
||||||
|
_preferLocalPlayer = preferLocal;
|
||||||
|
_smartSortPlayers = smartSort;
|
||||||
|
_showHints = showHints;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -410,7 +426,137 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Language picker
|
// Accent color picker
|
||||||
|
Consumer<ThemeProvider>(
|
||||||
|
builder: (context, themeProvider, _) {
|
||||||
|
final isDisabled = themeProvider.useMaterialTheme;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
S.of(context)!.accentColor,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isDisabled
|
||||||
|
? colorScheme.onSurface.withOpacity(0.4)
|
||||||
|
: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: accentColorOptions.map((color) {
|
||||||
|
final isSelected = themeProvider.customColor.value == color.value;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: isDisabled ? null : () {
|
||||||
|
themeProvider.setCustomColor(color);
|
||||||
|
},
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDisabled ? color.withOpacity(0.3) : color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: isSelected && !isDisabled
|
||||||
|
? Border.all(color: colorScheme.onSurface, width: 3)
|
||||||
|
: null,
|
||||||
|
boxShadow: isSelected && !isDisabled
|
||||||
|
? [BoxShadow(color: color.withOpacity(0.5), blurRadius: 8, spreadRadius: 2)]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: isSelected && !isDisabled
|
||||||
|
? Icon(Icons.check, color: Colors.white, size: 20)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
Consumer<ThemeProvider>(
|
||||||
|
builder: (context, themeProvider, _) {
|
||||||
|
return 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)!.materialYou,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
S.of(context)!.materialYouDescription,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
),
|
||||||
|
value: themeProvider.useMaterialTheme,
|
||||||
|
onChanged: (value) {
|
||||||
|
themeProvider.setUseMaterialTheme(value);
|
||||||
|
},
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
Consumer<ThemeProvider>(
|
||||||
|
builder: (context, themeProvider, _) {
|
||||||
|
return 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)!.adaptiveTheme,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
S.of(context)!.extractColorsFromArtwork,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
),
|
||||||
|
value: themeProvider.adaptiveTheme,
|
||||||
|
onChanged: (value) {
|
||||||
|
themeProvider.setAdaptiveTheme(value);
|
||||||
|
},
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Language section
|
||||||
|
Text(
|
||||||
|
S.of(context)!.language,
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
Consumer<LocaleProvider>(
|
Consumer<LocaleProvider>(
|
||||||
builder: (context, localeProvider, _) {
|
builder: (context, localeProvider, _) {
|
||||||
String getLanguageName(String? code) {
|
String getLanguageName(String? code) {
|
||||||
@@ -493,64 +639,108 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Player section
|
||||||
|
Text(
|
||||||
|
S.of(context)!.players,
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
Consumer<ThemeProvider>(
|
Container(
|
||||||
builder: (context, themeProvider, _) {
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
return Container(
|
decoration: BoxDecoration(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
decoration: BoxDecoration(
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
),
|
||||||
borderRadius: BorderRadius.circular(12),
|
child: SwitchListTile(
|
||||||
),
|
title: Text(
|
||||||
child: SwitchListTile(
|
S.of(context)!.preferLocalPlayer,
|
||||||
title: Text(
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
S.of(context)!.materialYou,
|
),
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
subtitle: Text(
|
||||||
),
|
S.of(context)!.preferLocalPlayerDescription,
|
||||||
subtitle: Text(
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
S.of(context)!.materialYouDescription,
|
),
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
value: _preferLocalPlayer,
|
||||||
),
|
onChanged: (value) {
|
||||||
value: themeProvider.useMaterialTheme,
|
setState(() => _preferLocalPlayer = value);
|
||||||
onChanged: (value) {
|
SettingsService.setPreferLocalPlayer(value);
|
||||||
themeProvider.setUseMaterialTheme(value);
|
},
|
||||||
},
|
activeColor: colorScheme.primary,
|
||||||
activeColor: colorScheme.primary,
|
contentPadding: EdgeInsets.zero,
|
||||||
contentPadding: EdgeInsets.zero,
|
),
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
Consumer<ThemeProvider>(
|
Container(
|
||||||
builder: (context, themeProvider, _) {
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
return Container(
|
decoration: BoxDecoration(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
decoration: BoxDecoration(
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
),
|
||||||
borderRadius: BorderRadius.circular(12),
|
child: SwitchListTile(
|
||||||
),
|
title: Text(
|
||||||
child: SwitchListTile(
|
S.of(context)!.smartSortPlayers,
|
||||||
title: Text(
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
S.of(context)!.adaptiveTheme,
|
),
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
subtitle: Text(
|
||||||
),
|
S.of(context)!.smartSortPlayersDescription,
|
||||||
subtitle: Text(
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
S.of(context)!.extractColorsFromArtwork,
|
),
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
value: _smartSortPlayers,
|
||||||
),
|
onChanged: (value) {
|
||||||
value: themeProvider.adaptiveTheme,
|
setState(() => _smartSortPlayers = value);
|
||||||
onChanged: (value) {
|
SettingsService.setSmartSortPlayers(value);
|
||||||
themeProvider.setAdaptiveTheme(value);
|
},
|
||||||
},
|
activeColor: colorScheme.primary,
|
||||||
activeColor: colorScheme.primary,
|
contentPadding: EdgeInsets.zero,
|
||||||
contentPadding: EdgeInsets.zero,
|
),
|
||||||
),
|
),
|
||||||
);
|
|
||||||
},
|
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),
|
const SizedBox(height: 32),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'auth_strategy.dart';
|
import 'auth_strategy.dart';
|
||||||
import 'no_auth_strategy.dart';
|
import 'no_auth_strategy.dart';
|
||||||
@@ -86,6 +87,22 @@ class AuthManager {
|
|||||||
return baseUrl;
|
return baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if a hostname is a local/private IP address
|
||||||
|
bool _isLocalIpAddress(String host) {
|
||||||
|
return host.startsWith('192.168.') ||
|
||||||
|
host.startsWith('10.') ||
|
||||||
|
host.startsWith('172.16.') ||
|
||||||
|
host.startsWith('172.17.') ||
|
||||||
|
host.startsWith('172.18.') ||
|
||||||
|
host.startsWith('172.19.') ||
|
||||||
|
host.startsWith('172.2') ||
|
||||||
|
host.startsWith('172.30.') ||
|
||||||
|
host.startsWith('172.31.') ||
|
||||||
|
host == 'localhost' ||
|
||||||
|
host.startsWith('127.') ||
|
||||||
|
host.endsWith('.local');
|
||||||
|
}
|
||||||
|
|
||||||
/// Try to detect auth strategy for a given URL
|
/// Try to detect auth strategy for a given URL
|
||||||
/// Returns the strategy if successful, null if connection failed
|
/// Returns the strategy if successful, null if connection failed
|
||||||
Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async {
|
Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async {
|
||||||
@@ -187,15 +204,50 @@ class AuthManager {
|
|||||||
|
|
||||||
_logger.log('Checking MA API at: $apiUrl');
|
_logger.log('Checking MA API at: $apiUrl');
|
||||||
|
|
||||||
// Try to get server info - this tells us if auth is required
|
// Use IOClient to control redirect behavior for local IPs
|
||||||
// Note: The command is just 'info', not 'server/info'
|
final isLocalIp = _isLocalIpAddress(uri.host);
|
||||||
final response = await http.post(
|
http.Response response;
|
||||||
apiUrl,
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
if (isLocalIp) {
|
||||||
body: jsonEncode({
|
// For local IPs, don't follow redirects - handle them explicitly
|
||||||
'command': 'info',
|
final client = HttpClient();
|
||||||
}),
|
try {
|
||||||
).timeout(const Duration(seconds: 5));
|
final request = await client.postUrl(apiUrl);
|
||||||
|
request.followRedirects = false;
|
||||||
|
request.headers.set('Content-Type', 'application/json');
|
||||||
|
request.write(jsonEncode({'command': 'info'}));
|
||||||
|
final ioResponse = await request.close().timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
// Check for redirect to HTTPS
|
||||||
|
if (ioResponse.statusCode == 308 || ioResponse.statusCode == 301 || ioResponse.statusCode == 302) {
|
||||||
|
final location = ioResponse.headers.value('location');
|
||||||
|
_logger.log('Local IP redirects to: $location');
|
||||||
|
if (location != null && location.startsWith('https://')) {
|
||||||
|
_logger.log('⚠️ Server redirects HTTP to HTTPS - this may cause certificate issues for local IP');
|
||||||
|
// Don't follow the redirect - it will likely fail with cert error
|
||||||
|
// Instead, return null to trigger fallback logic
|
||||||
|
client.close();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = await ioResponse.transform(utf8.decoder).join();
|
||||||
|
response = http.Response(body, ioResponse.statusCode);
|
||||||
|
client.close();
|
||||||
|
} catch (e) {
|
||||||
|
client.close();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For non-local URLs, use standard http.post which follows redirects
|
||||||
|
response = await http.post(
|
||||||
|
apiUrl,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'command': 'info',
|
||||||
|
}),
|
||||||
|
).timeout(const Duration(seconds: 5));
|
||||||
|
}
|
||||||
|
|
||||||
_logger.log('MA API response: ${response.statusCode}');
|
_logger.log('MA API response: ${response.statusCode}');
|
||||||
_logger.log('MA API body: ${response.body}');
|
_logger.log('MA API body: ${response.body}');
|
||||||
@@ -255,21 +307,47 @@ class AuthManager {
|
|||||||
/// Test if server is accessible without authentication
|
/// Test if server is accessible without authentication
|
||||||
Future<bool> _canConnectWithoutAuth(String serverUrl) async {
|
Future<bool> _canConnectWithoutAuth(String serverUrl) async {
|
||||||
try {
|
try {
|
||||||
// Test root endpoint - Music Assistant doesn't have /api/info
|
final uri = Uri.parse(serverUrl);
|
||||||
final testUrl = serverUrl;
|
_logger.log('No-auth test URL: $serverUrl');
|
||||||
_logger.log('No-auth test URL: $testUrl');
|
|
||||||
|
|
||||||
final response = await http.get(
|
final isLocalIp = _isLocalIpAddress(uri.host);
|
||||||
Uri.parse(testUrl),
|
int statusCode;
|
||||||
// Don't follow redirects - we want to see if server requires auth
|
|
||||||
).timeout(const Duration(seconds: 10));
|
|
||||||
|
|
||||||
_logger.log('No-auth status: ${response.statusCode}');
|
if (isLocalIp) {
|
||||||
|
// For local IPs, don't follow redirects to avoid HTTPS cert issues
|
||||||
|
final client = HttpClient();
|
||||||
|
try {
|
||||||
|
final request = await client.getUrl(uri);
|
||||||
|
request.followRedirects = false;
|
||||||
|
final response = await request.close().timeout(const Duration(seconds: 10));
|
||||||
|
statusCode = response.statusCode;
|
||||||
|
|
||||||
|
// Check for redirect to HTTPS
|
||||||
|
if (statusCode == 308 || statusCode == 301 || statusCode == 302) {
|
||||||
|
final location = response.headers.value('location');
|
||||||
|
if (location != null && location.startsWith('https://')) {
|
||||||
|
_logger.log('⚠️ Local IP redirects to HTTPS: $location');
|
||||||
|
client.close();
|
||||||
|
return false; // Can't connect - would need HTTPS which has cert issues
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client.close();
|
||||||
|
} catch (e) {
|
||||||
|
client.close();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For non-local URLs, use standard http.get
|
||||||
|
final response = await http.get(uri).timeout(const Duration(seconds: 10));
|
||||||
|
statusCode = response.statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('No-auth status: $statusCode');
|
||||||
|
|
||||||
// 200 = no auth required
|
// 200 = no auth required
|
||||||
// 302/307 = redirect (probably to auth)
|
// 302/307/308 = redirect (probably to auth or HTTPS)
|
||||||
// 401 = auth required
|
// 401 = auth required
|
||||||
if (response.statusCode == 200) {
|
if (statusCode == 200) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,4 +156,100 @@ class DatabaseService {
|
|||||||
|
|
||||||
/// Clear all cached data (useful for logout)
|
/// Clear all cached data (useful for logout)
|
||||||
Future<void> clearAllCache() => db.clearAllCache();
|
Future<void> clearAllCache() => db.clearAllCache();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Player Cache Convenience Methods
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Save playback state for instant resume
|
||||||
|
Future<void> savePlaybackState({
|
||||||
|
String? playerId,
|
||||||
|
String? playerName,
|
||||||
|
String? currentTrackJson,
|
||||||
|
double positionSeconds = 0.0,
|
||||||
|
bool isPlaying = false,
|
||||||
|
}) {
|
||||||
|
return db.savePlaybackState(
|
||||||
|
playerId: playerId,
|
||||||
|
playerName: playerName,
|
||||||
|
currentTrackJson: currentTrackJson,
|
||||||
|
positionSeconds: positionSeconds,
|
||||||
|
isPlaying: isPlaying,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get saved playback state
|
||||||
|
Future<PlaybackStateData?> getPlaybackState() => db.getPlaybackState();
|
||||||
|
|
||||||
|
/// Cache a player with optional track info
|
||||||
|
Future<void> cachePlayer({
|
||||||
|
required String playerId,
|
||||||
|
required String playerJson,
|
||||||
|
String? currentTrackJson,
|
||||||
|
}) {
|
||||||
|
return db.cachePlayer(
|
||||||
|
playerId: playerId,
|
||||||
|
playerJson: playerJson,
|
||||||
|
currentTrackJson: currentTrackJson,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache multiple players at once
|
||||||
|
Future<void> cachePlayers(List<Map<String, dynamic>> players) {
|
||||||
|
return db.cachePlayers(players);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all cached players
|
||||||
|
Future<List<CachedPlayer>> getCachedPlayers() => db.getCachedPlayers();
|
||||||
|
|
||||||
|
/// Update cached track for a player
|
||||||
|
Future<void> updateCachedPlayerTrack(String playerId, String? trackJson) {
|
||||||
|
return db.updateCachedPlayerTrack(playerId, trackJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all cached players
|
||||||
|
Future<void> clearCachedPlayers() => db.clearCachedPlayers();
|
||||||
|
|
||||||
|
/// Save queue for a player
|
||||||
|
Future<void> saveQueue(String playerId, List<String> itemJsonList) {
|
||||||
|
return db.saveQueue(playerId, itemJsonList);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached queue for a player
|
||||||
|
Future<List<CachedQueueData>> getCachedQueue(String playerId) {
|
||||||
|
return db.getCachedQueue(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear queue for a player
|
||||||
|
Future<void> clearCachedQueue(String playerId) => db.clearCachedQueue(playerId);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Home Row Cache Convenience Methods
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Save home row data (recent albums, discover artists, etc.)
|
||||||
|
Future<void> saveHomeRowCache(String rowType, String itemsJson) {
|
||||||
|
return db.saveHomeRowCache(rowType, itemsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached home row data
|
||||||
|
Future<HomeRowCacheData?> getHomeRowCache(String rowType) {
|
||||||
|
return db.getHomeRowCache(rowType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all home row cache
|
||||||
|
Future<void> clearHomeRowCache() => db.clearHomeRowCache();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1069,16 +1069,14 @@ class MusicAssistantAPI {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch fetch track details using parallel requests (max 5 concurrent)
|
// Step 1: Fetch tracks in batches and collect unique album URIs
|
||||||
final seenAlbumIds = <String>{};
|
final seenAlbumUris = <String>{};
|
||||||
final albums = <Album>[];
|
final albumUris = <String>[];
|
||||||
|
|
||||||
// Process in batches to avoid overwhelming the server
|
|
||||||
const batchSize = LibraryConstants.recentAlbumsBatchSize;
|
const batchSize = LibraryConstants.recentAlbumsBatchSize;
|
||||||
for (var i = 0; i < trackUris.length && albums.length < limit; i += batchSize) {
|
for (var i = 0; i < trackUris.length && albumUris.length < limit; i += batchSize) {
|
||||||
final batch = trackUris.skip(i).take(batchSize).toList();
|
final batch = trackUris.skip(i).take(batchSize).toList();
|
||||||
|
|
||||||
// Fetch batch in parallel
|
|
||||||
final futures = batch.map((uri) => _sendCommand(
|
final futures = batch.map((uri) => _sendCommand(
|
||||||
'music/item_by_uri',
|
'music/item_by_uri',
|
||||||
args: {'uri': uri},
|
args: {'uri': uri},
|
||||||
@@ -1087,27 +1085,52 @@ class MusicAssistantAPI {
|
|||||||
final results = await Future.wait(futures, eagerError: false);
|
final results = await Future.wait(futures, eagerError: false);
|
||||||
|
|
||||||
for (final trackResponse in results) {
|
for (final trackResponse in results) {
|
||||||
if (albums.length >= limit) break;
|
if (albumUris.length >= limit) break;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final fullTrack = trackResponse['result'] as Map<String, dynamic>?;
|
final fullTrack = trackResponse['result'] as Map<String, dynamic>?;
|
||||||
final albumData = fullTrack?['album'] as Map<String, dynamic>?;
|
final albumData = fullTrack?['album'] as Map<String, dynamic>?;
|
||||||
|
final albumUri = albumData?['uri'] as String?;
|
||||||
|
|
||||||
if (albumData != null) {
|
if (albumUri != null && !seenAlbumUris.contains(albumUri)) {
|
||||||
final albumId = albumData['item_id']?.toString() ?? albumData['uri']?.toString();
|
seenAlbumUris.add(albumUri);
|
||||||
if (albumId != null && !seenAlbumIds.contains(albumId)) {
|
albumUris.add(albumUri);
|
||||||
seenAlbumIds.add(albumId);
|
|
||||||
albums.add(Album.fromJson(albumData));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Skip tracks that fail to parse
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('📚 Fetched ${albums.length} recent albums using ${(trackUris.length / batchSize).ceil()} batch requests');
|
if (albumUris.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Fetch full album details (with images) for each album URI
|
||||||
|
final albums = <Album>[];
|
||||||
|
for (var i = 0; i < albumUris.length; i += batchSize) {
|
||||||
|
final batch = albumUris.skip(i).take(batchSize).toList();
|
||||||
|
|
||||||
|
final futures = batch.map((uri) => _sendCommand(
|
||||||
|
'music/item_by_uri',
|
||||||
|
args: {'uri': uri},
|
||||||
|
).timeout(const Duration(seconds: 5), onTimeout: () => <String, dynamic>{'result': null}));
|
||||||
|
|
||||||
|
final results = await Future.wait(futures, eagerError: false);
|
||||||
|
|
||||||
|
for (final albumResponse in results) {
|
||||||
|
try {
|
||||||
|
final albumData = albumResponse['result'] as Map<String, dynamic>?;
|
||||||
|
if (albumData != null) {
|
||||||
|
albums.add(Album.fromJson(albumData));
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('📚 Fetched ${albums.length} recent albums with full details');
|
||||||
return albums;
|
return albums;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('❌ Error getting recent albums: $e');
|
_logger.log('❌ Error getting recent albums: $e');
|
||||||
@@ -1376,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
|
||||||
@@ -1395,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
|
||||||
@@ -1402,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': []};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1579,6 +1614,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
|
||||||
@@ -1973,6 +2027,45 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sync a player to a leader/group (temporary group)
|
||||||
|
/// The player will join the leader's sync group and play the same audio
|
||||||
|
Future<void> syncPlayerToLeader(String playerId, String leaderPlayerId) async {
|
||||||
|
try {
|
||||||
|
_logger.log('🔗 API syncPlayerToLeader called');
|
||||||
|
_logger.log('🔗 player_id: $playerId');
|
||||||
|
_logger.log('🔗 target_player: $leaderPlayerId');
|
||||||
|
|
||||||
|
final response = await _sendCommand(
|
||||||
|
'players/cmd/group',
|
||||||
|
args: {
|
||||||
|
'player_id': playerId,
|
||||||
|
'target_player': leaderPlayerId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
_logger.log('✅ Player synced successfully, response: $response');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Error syncing player: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a player from its sync group
|
||||||
|
Future<void> unsyncPlayer(String playerId) async {
|
||||||
|
try {
|
||||||
|
_logger.log('🔓 Unsyncing player: $playerId');
|
||||||
|
await _sendCommand(
|
||||||
|
'players/cmd/ungroup',
|
||||||
|
args: {
|
||||||
|
'player_id': playerId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
_logger.log('✅ Player unsynced successfully');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error unsyncing player: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Toggle shuffle mode for queue
|
/// Toggle shuffle mode for queue
|
||||||
Future<void> toggleShuffle(String queueId) async {
|
Future<void> toggleShuffle(String queueId) async {
|
||||||
try {
|
try {
|
||||||
@@ -2302,89 +2395,15 @@ class MusicAssistantAPI {
|
|||||||
return _eventStreams['sendspin_player']!.stream;
|
return _eventStreams['sendspin_player']!.stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get Sendspin connection info for a player
|
// NOTE: The following Sendspin API methods were removed because they don't exist in MA:
|
||||||
/// Returns connection details including:
|
// - getSendspinConnectionInfo (sendspin/connection_info)
|
||||||
/// - local_ws_url: Direct WebSocket URL (e.g., ws://192.168.4.120:8927/sendspin)
|
// - sendspinOffer (sendspin/webrtc_offer)
|
||||||
/// - ice_servers: List of ICE/TURN servers for WebRTC fallback
|
// - sendspinAnswer (sendspin/webrtc_answer)
|
||||||
/// - player_id: The player's ID
|
// - sendspinIceCandidate (sendspin/ice_candidate)
|
||||||
Future<Map<String, dynamic>?> getSendspinConnectionInfo(String playerId) async {
|
//
|
||||||
try {
|
// Sendspin connection is handled directly via WebSocket to:
|
||||||
_logger.log('Sendspin: Getting connection info for player $playerId');
|
// - Local: ws://{server-ip}:8927/sendspin
|
||||||
|
// - External: wss://{server}/sendspin (via MA's proxy with auth)
|
||||||
final response = await _sendCommand(
|
|
||||||
'sendspin/connection_info',
|
|
||||||
args: {
|
|
||||||
'player_id': playerId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
final result = response['result'] as Map<String, dynamic>?;
|
|
||||||
if (result != null) {
|
|
||||||
_logger.log('Sendspin: Got connection info - local_ws_url: ${result['local_ws_url']}');
|
|
||||||
_logger.log('Sendspin: ICE servers count: ${(result['ice_servers'] as List?)?.length ?? 0}');
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Sendspin: Error getting connection info: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send WebRTC offer to Sendspin server
|
|
||||||
/// Used when establishing WebRTC connection as fallback
|
|
||||||
Future<Map<String, dynamic>?> sendspinOffer(String playerId, String sdp) async {
|
|
||||||
try {
|
|
||||||
_logger.log('Sendspin: Sending WebRTC offer for player $playerId');
|
|
||||||
|
|
||||||
final response = await _sendCommand(
|
|
||||||
'sendspin/webrtc_offer',
|
|
||||||
args: {
|
|
||||||
'player_id': playerId,
|
|
||||||
'sdp': sdp,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return response['result'] as Map<String, dynamic>?;
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Sendspin: Error sending WebRTC offer: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send WebRTC answer to Sendspin server
|
|
||||||
Future<void> sendspinAnswer(String playerId, String sdp) async {
|
|
||||||
try {
|
|
||||||
_logger.log('Sendspin: Sending WebRTC answer for player $playerId');
|
|
||||||
|
|
||||||
await _sendCommand(
|
|
||||||
'sendspin/webrtc_answer',
|
|
||||||
args: {
|
|
||||||
'player_id': playerId,
|
|
||||||
'sdp': sdp,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Sendspin: Error sending WebRTC answer: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send ICE candidate to Sendspin server
|
|
||||||
Future<void> sendspinIceCandidate(String playerId, Map<String, dynamic> candidate) async {
|
|
||||||
try {
|
|
||||||
await _sendCommand(
|
|
||||||
'sendspin/ice_candidate',
|
|
||||||
args: {
|
|
||||||
'player_id': playerId,
|
|
||||||
'candidate': candidate,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Sendspin: Error sending ICE candidate: $e');
|
|
||||||
// Don't rethrow - ICE candidate failures are common and non-fatal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update Sendspin player state
|
/// Update Sendspin player state
|
||||||
/// Similar to updateBuiltinPlayerState but for Sendspin protocol
|
/// Similar to updateBuiltinPlayerState but for Sendspin protocol
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'debug_logger.dart';
|
||||||
|
|
||||||
|
/// Represents a queued action that will be executed when connection is restored
|
||||||
|
class QueuedAction {
|
||||||
|
final String type;
|
||||||
|
final Map<String, dynamic> params;
|
||||||
|
final DateTime queuedAt;
|
||||||
|
|
||||||
|
QueuedAction({
|
||||||
|
required this.type,
|
||||||
|
required this.params,
|
||||||
|
required this.queuedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'type': type,
|
||||||
|
'params': params,
|
||||||
|
'queuedAt': queuedAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory QueuedAction.fromJson(Map<String, dynamic> json) => QueuedAction(
|
||||||
|
type: json['type'] as String,
|
||||||
|
params: json['params'] as Map<String, dynamic>,
|
||||||
|
queuedAt: DateTime.parse(json['queuedAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service for queuing actions when offline and executing them when back online
|
||||||
|
class OfflineActionQueue {
|
||||||
|
static OfflineActionQueue? _instance;
|
||||||
|
static OfflineActionQueue get instance => _instance ??= OfflineActionQueue._();
|
||||||
|
|
||||||
|
OfflineActionQueue._();
|
||||||
|
|
||||||
|
final _logger = DebugLogger();
|
||||||
|
static const _prefsKey = 'offline_action_queue';
|
||||||
|
|
||||||
|
List<QueuedAction> _queue = [];
|
||||||
|
bool _isProcessing = false;
|
||||||
|
|
||||||
|
/// Get the current queue (for display)
|
||||||
|
List<QueuedAction> get queue => List.unmodifiable(_queue);
|
||||||
|
|
||||||
|
/// Check if there are pending actions
|
||||||
|
bool get hasPendingActions => _queue.isNotEmpty;
|
||||||
|
|
||||||
|
/// Number of pending actions
|
||||||
|
int get pendingCount => _queue.length;
|
||||||
|
|
||||||
|
/// Initialize and load persisted queue
|
||||||
|
Future<void> initialize() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final queueJson = prefs.getString(_prefsKey);
|
||||||
|
if (queueJson != null) {
|
||||||
|
final List<dynamic> decoded = jsonDecode(queueJson);
|
||||||
|
_queue = decoded.map((j) => QueuedAction.fromJson(j as Map<String, dynamic>)).toList();
|
||||||
|
_logger.log('📋 Loaded ${_queue.length} offline actions from storage');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Error loading offline action queue: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue an action for later execution
|
||||||
|
Future<void> queueAction(String type, Map<String, dynamic> params) async {
|
||||||
|
final action = QueuedAction(
|
||||||
|
type: type,
|
||||||
|
params: params,
|
||||||
|
queuedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
_queue.add(action);
|
||||||
|
await _persistQueue();
|
||||||
|
_logger.log('📋 Queued offline action: $type');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process all queued actions (call when connection is restored)
|
||||||
|
Future<void> processQueue(Future<bool> Function(QueuedAction action) executor) async {
|
||||||
|
if (_isProcessing || _queue.isEmpty) return;
|
||||||
|
|
||||||
|
_isProcessing = true;
|
||||||
|
_logger.log('🔄 Processing ${_queue.length} offline actions...');
|
||||||
|
|
||||||
|
final processed = <QueuedAction>[];
|
||||||
|
|
||||||
|
for (final action in _queue) {
|
||||||
|
try {
|
||||||
|
final success = await executor(action);
|
||||||
|
if (success) {
|
||||||
|
processed.add(action);
|
||||||
|
_logger.log('✅ Processed offline action: ${action.type}');
|
||||||
|
} else {
|
||||||
|
_logger.log('⚠️ Failed to process offline action: ${action.type}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Error processing offline action: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove processed actions
|
||||||
|
_queue.removeWhere((a) => processed.contains(a));
|
||||||
|
await _persistQueue();
|
||||||
|
|
||||||
|
_isProcessing = false;
|
||||||
|
_logger.log('📋 ${processed.length} actions processed, ${_queue.length} remaining');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all queued actions
|
||||||
|
Future<void> clearQueue() async {
|
||||||
|
_queue.clear();
|
||||||
|
await _persistQueue();
|
||||||
|
_logger.log('🗑️ Offline action queue cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist queue to storage
|
||||||
|
Future<void> _persistQueue() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (_queue.isEmpty) {
|
||||||
|
await prefs.remove(_prefsKey);
|
||||||
|
} else {
|
||||||
|
final queueJson = jsonEncode(_queue.map((a) => a.toJson()).toList());
|
||||||
|
await prefs.setString(_prefsKey, queueJson);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Error persisting offline action queue: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Action types for offline queuing
|
||||||
|
class OfflineActionTypes {
|
||||||
|
static const String toggleFavorite = 'toggle_favorite';
|
||||||
|
static const String addToPlaylist = 'add_to_playlist';
|
||||||
|
static const String removeFromPlaylist = 'remove_from_playlist';
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ class RecentlyPlayedService {
|
|||||||
if (!_db.isInitialized) return;
|
if (!_db.isInitialized) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Store images metadata for instant artwork display
|
||||||
|
final images = album.metadata?['images'];
|
||||||
await _db.addRecentlyPlayed(
|
await _db.addRecentlyPlayed(
|
||||||
mediaId: album.itemId,
|
mediaId: album.itemId,
|
||||||
mediaType: 'album',
|
mediaType: 'album',
|
||||||
@@ -33,6 +35,7 @@ class RecentlyPlayedService {
|
|||||||
metadata: {
|
metadata: {
|
||||||
'provider': album.provider,
|
'provider': album.provider,
|
||||||
'uri': album.uri,
|
'uri': album.uri,
|
||||||
|
if (images != null) 'images': images,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
_logger.log('📝 Recorded album play: ${album.name}');
|
_logger.log('📝 Recorded album play: ${album.name}');
|
||||||
@@ -151,6 +154,12 @@ class RecentlyPlayedService {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build metadata with images if available (for instant artwork display)
|
||||||
|
Map<String, dynamic>? albumMetadata;
|
||||||
|
if (metadata?['images'] != null) {
|
||||||
|
albumMetadata = {'images': metadata!['images']};
|
||||||
|
}
|
||||||
|
|
||||||
albums.add(Album(
|
albums.add(Album(
|
||||||
itemId: item.mediaId,
|
itemId: item.mediaId,
|
||||||
provider: metadata?['provider'] ?? 'library',
|
provider: metadata?['provider'] ?? 'library',
|
||||||
@@ -160,6 +169,7 @@ class RecentlyPlayedService {
|
|||||||
artists: item.artistName != null
|
artists: item.artistName != null
|
||||||
? [Artist(itemId: '', provider: '', name: item.artistName!)]
|
? [Artist(itemId: '', provider: '', name: item.artistName!)]
|
||||||
: null,
|
: null,
|
||||||
|
metadata: albumMetadata,
|
||||||
));
|
));
|
||||||
|
|
||||||
if (albums.length >= limit) break;
|
if (albums.length >= limit) break;
|
||||||
|
|||||||
@@ -120,12 +120,11 @@ class SendspinService {
|
|||||||
connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 5), useProxyAuth: true);
|
connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 5), useProxyAuth: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: If external failed or server is HTTP, try local connection
|
// Note: Local network connections (ws://ip:8927/sendspin) are handled
|
||||||
// Note: We would need local_ws_url from getSendspinConnectionInfo API
|
// by the provider calling connectWithUrl() directly, not by this method.
|
||||||
// For now, skip this step and fall back to WebRTC (handled by provider)
|
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
_logger.log('Sendspin: External connection failed, WebRTC fallback needed');
|
_logger.log('Sendspin: External proxy connection failed');
|
||||||
_updateState(SendspinConnectionState.error);
|
_updateState(SendspinConnectionState.error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -519,8 +518,9 @@ class SendspinService {
|
|||||||
if (muted != null) _isMuted = muted;
|
if (muted != null) _isMuted = muted;
|
||||||
|
|
||||||
// Determine player state for Sendspin protocol
|
// Determine player state for Sendspin protocol
|
||||||
// 'synchronized' = ready/playing, 'idle' = not playing
|
// Valid values: 'synchronized', 'error', 'external_source'
|
||||||
final playerState = _isPlaying || _isPaused ? 'synchronized' : 'idle';
|
// When connected, we're always synchronized (ready to receive audio)
|
||||||
|
const playerState = 'synchronized';
|
||||||
|
|
||||||
_sendMessage({
|
_sendMessage({
|
||||||
'type': 'client/state',
|
'type': 'client/state',
|
||||||
@@ -581,7 +581,7 @@ class SendspinService {
|
|||||||
_sendMessage({
|
_sendMessage({
|
||||||
'type': 'client/time',
|
'type': 'client/time',
|
||||||
'payload': {
|
'payload': {
|
||||||
'timestamp': timestampMicroseconds,
|
'client_transmitted': timestampMicroseconds,
|
||||||
},
|
},
|
||||||
}, allowDuringHandshake: allowDuringHandshake);
|
}, allowDuringHandshake: allowDuringHandshake);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class SettingsService {
|
|||||||
static const String _keyLocalPlayerName = 'local_player_name';
|
static const String _keyLocalPlayerName = 'local_player_name';
|
||||||
static const String _keyOwnerName = 'owner_name';
|
static const String _keyOwnerName = 'owner_name';
|
||||||
static const String _keyLastSelectedPlayerId = 'last_selected_player_id';
|
static const String _keyLastSelectedPlayerId = 'last_selected_player_id';
|
||||||
|
static const String _keyPreferLocalPlayer = 'prefer_local_player';
|
||||||
|
static const String _keySmartSortPlayers = 'smart_sort_players';
|
||||||
static const String _keyShowRecentAlbums = 'show_recent_albums';
|
static const String _keyShowRecentAlbums = 'show_recent_albums';
|
||||||
static const String _keyShowDiscoverArtists = 'show_discover_artists';
|
static const String _keyShowDiscoverArtists = 'show_discover_artists';
|
||||||
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
|
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
|
||||||
@@ -72,6 +74,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);
|
||||||
@@ -364,6 +371,28 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer Local Player - always select local player first when available
|
||||||
|
static Future<bool> getPreferLocalPlayer() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyPreferLocalPlayer) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setPreferLocalPlayer(bool prefer) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyPreferLocalPlayer, prefer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smart Sort Players - sort by status (playing > on > off) instead of alphabetically
|
||||||
|
static Future<bool> getSmartSortPlayers() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keySmartSortPlayers) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setSmartSortPlayers(bool smartSort) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keySmartSortPlayers, smartSort);
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to create player name with possessive apostrophe
|
// Helper to create player name with possessive apostrophe
|
||||||
static String _makePlayerName(String ownerName) {
|
static String _makePlayerName(String ownerName) {
|
||||||
// Handle possessive: "Chris" -> "Chris' Phone", "Mom" -> "Mom's Phone"
|
// Handle possessive: "Chris" -> "Chris' Phone", "Mom" -> "Mom's Phone"
|
||||||
@@ -738,4 +767,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,17 @@ import 'package:flutter/material.dart';
|
|||||||
const Color brandPrimaryColor = Color(0xFF1a1a1a);
|
const Color brandPrimaryColor = Color(0xFF1a1a1a);
|
||||||
const Color brandAccentColor = Color(0xFF604CEC);
|
const Color brandAccentColor = Color(0xFF604CEC);
|
||||||
|
|
||||||
// Brand color schemes
|
// Predefined accent colors for custom theme selection
|
||||||
|
const List<Color> accentColorOptions = [
|
||||||
|
Color(0xFF604CEC), // Purple (default)
|
||||||
|
Color(0xFF2196F3), // Blue
|
||||||
|
Color(0xFF009688), // Teal
|
||||||
|
Color(0xFFFF5722), // Deep Orange
|
||||||
|
Color(0xFFE91E63), // Pink
|
||||||
|
Color(0xFF4CAF50), // Green
|
||||||
|
];
|
||||||
|
|
||||||
|
// Brand color schemes (default)
|
||||||
final ColorScheme brandLightColorScheme = ColorScheme.fromSeed(
|
final ColorScheme brandLightColorScheme = ColorScheme.fromSeed(
|
||||||
seedColor: brandAccentColor,
|
seedColor: brandAccentColor,
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
@@ -21,6 +31,27 @@ final ColorScheme brandDarkColorScheme = ColorScheme.fromSeed(
|
|||||||
background: const Color(0xFF1a1a1a),
|
background: const Color(0xFF1a1a1a),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Generate color schemes from a custom seed color
|
||||||
|
ColorScheme generateLightColorScheme(Color seedColor) {
|
||||||
|
return ColorScheme.fromSeed(
|
||||||
|
seedColor: seedColor,
|
||||||
|
brightness: Brightness.light,
|
||||||
|
background: const Color(0xFFFAFAFA),
|
||||||
|
onBackground: const Color(0xFF1a1a1a),
|
||||||
|
surface: const Color(0xFFFFFFFF),
|
||||||
|
onSurface: const Color(0xFF1a1a1a),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ColorScheme generateDarkColorScheme(Color seedColor) {
|
||||||
|
return ColorScheme.fromSeed(
|
||||||
|
seedColor: seedColor,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
surface: const Color(0xFF2a2a2a),
|
||||||
|
background: const Color(0xFF1a1a1a),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
// Generate light theme
|
// Generate light theme
|
||||||
static ThemeData lightTheme({ColorScheme? colorScheme}) {
|
static ThemeData lightTheme({ColorScheme? colorScheme}) {
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
child: AlbumDetailsScreen(
|
child: AlbumDetailsScreen(
|
||||||
album: widget.album,
|
album: widget.album,
|
||||||
heroTagSuffix: widget.heroTagSuffix,
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
|
initialImageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -119,7 +120,8 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
memCacheWidth: 256,
|
memCacheWidth: 256,
|
||||||
memCacheHeight: 256,
|
memCacheHeight: 256,
|
||||||
fadeInDuration: const Duration(milliseconds: 150),
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (context, url) => const SizedBox(),
|
placeholder: (context, url) => const SizedBox(),
|
||||||
errorWidget: (context, url, error) {
|
errorWidget: (context, url, error) {
|
||||||
// Try fallback on error (only for MA URLs, not fallback URLs)
|
// Try fallback on error (only for MA URLs, not fallback URLs)
|
||||||
|
|||||||
+111
-69
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
@@ -10,6 +11,8 @@ class AlbumRow extends StatefulWidget {
|
|||||||
final Future<List<Album>> Function() loadAlbums;
|
final Future<List<Album>> Function() loadAlbums;
|
||||||
final String? heroTagSuffix;
|
final String? heroTagSuffix;
|
||||||
final double? rowHeight;
|
final double? rowHeight;
|
||||||
|
/// Optional: synchronous getter for cached data (for instant display)
|
||||||
|
final List<Album>? Function()? getCachedAlbums;
|
||||||
|
|
||||||
const AlbumRow({
|
const AlbumRow({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -17,6 +20,7 @@ class AlbumRow extends StatefulWidget {
|
|||||||
required this.loadAlbums,
|
required this.loadAlbums,
|
||||||
this.heroTagSuffix,
|
this.heroTagSuffix,
|
||||||
this.rowHeight,
|
this.rowHeight,
|
||||||
|
this.getCachedAlbums,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -24,8 +28,8 @@ class AlbumRow extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin {
|
class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin {
|
||||||
late Future<List<Album>> _albumsFuture;
|
List<Album> _albums = [];
|
||||||
List<Album>? _cachedAlbums; // Keep last loaded data to prevent flash
|
bool _isLoading = true;
|
||||||
bool _hasLoaded = false;
|
bool _hasLoaded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -34,21 +38,117 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadAlbumsOnce();
|
_loadAlbums();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _loadAlbumsOnce() {
|
Future<void> _loadAlbums() async {
|
||||||
if (!_hasLoaded) {
|
if (_hasLoaded) return;
|
||||||
_albumsFuture = widget.loadAlbums().then((albums) {
|
_hasLoaded = true;
|
||||||
_cachedAlbums = albums;
|
|
||||||
return albums;
|
// 1. Try to get cached data synchronously for instant display
|
||||||
});
|
final cachedAlbums = widget.getCachedAlbums?.call();
|
||||||
_hasLoaded = true;
|
if (cachedAlbums != null && cachedAlbums.isNotEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_albums = cachedAlbums;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Load fresh data (always update - fresh data may have images that cached data lacks)
|
||||||
|
try {
|
||||||
|
final freshAlbums = await widget.loadAlbums();
|
||||||
|
if (mounted && freshAlbums.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_albums = freshAlbums;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
// Pre-cache images for smooth hero animations
|
||||||
|
_precacheAlbumImages(freshAlbums);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent failure - keep showing cached data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted && _isLoading) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-cache album images so hero animations are smooth on first tap
|
||||||
|
void _precacheAlbumImages(List<Album> albums) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
// Only precache first ~10 visible items to avoid excessive network/memory use
|
||||||
|
final albumsToCache = albums.take(10);
|
||||||
|
|
||||||
|
for (final album in albumsToCache) {
|
||||||
|
final imageUrl = maProvider.api?.getImageUrl(album, size: 256);
|
||||||
|
if (imageUrl != null) {
|
||||||
|
// Use CachedNetworkImageProvider to warm the cache
|
||||||
|
precacheImage(
|
||||||
|
CachedNetworkImageProvider(imageUrl),
|
||||||
|
context,
|
||||||
|
).catchError((_) {
|
||||||
|
// Silently ignore precache errors
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static final _logger = DebugLogger();
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
|
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
|
||||||
|
// Only show loading if we have no data at all
|
||||||
|
if (_albums.isEmpty && _isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_albums.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No albums found',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card layout: square artwork + text below
|
||||||
|
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
|
||||||
|
const textAreaHeight = 44.0;
|
||||||
|
final artworkSize = contentHeight - textAreaHeight;
|
||||||
|
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||||
|
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||||
|
|
||||||
|
return ScrollConfiguration(
|
||||||
|
behavior: const _StretchScrollBehavior(),
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
itemCount: _albums.length,
|
||||||
|
itemExtent: itemExtent,
|
||||||
|
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||||
|
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final album = _albums[index];
|
||||||
|
return Container(
|
||||||
|
key: ValueKey(album.uri ?? album.itemId),
|
||||||
|
width: cardWidth,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||||
|
child: AlbumCard(
|
||||||
|
album: album,
|
||||||
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
_logger.startBuild('AlbumRow:${widget.title}');
|
_logger.startBuild('AlbumRow:${widget.title}');
|
||||||
@@ -78,65 +178,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FutureBuilder<List<Album>>(
|
child: _buildContent(contentHeight, colorScheme),
|
||||||
future: _albumsFuture,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
// Use cached data immediately if available (prevents flash on rebuild)
|
|
||||||
final albums = snapshot.data ?? _cachedAlbums;
|
|
||||||
|
|
||||||
// Only show loading if we have no data at all
|
|
||||||
if (albums == null && snapshot.connectionState == ConnectionState.waiting) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot.hasError && albums == null) {
|
|
||||||
return Center(
|
|
||||||
child: Text('Error: ${snapshot.error}'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (albums == null || albums.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Text(
|
|
||||||
'No albums found',
|
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Card layout: square artwork + text below
|
|
||||||
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
|
|
||||||
const textAreaHeight = 44.0;
|
|
||||||
final artworkSize = contentHeight - textAreaHeight;
|
|
||||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
|
||||||
final itemExtent = cardWidth + 12; // width + horizontal margins
|
|
||||||
|
|
||||||
return ScrollConfiguration(
|
|
||||||
behavior: const _StretchScrollBehavior(),
|
|
||||||
child: ListView.builder(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
||||||
itemCount: albums.length,
|
|
||||||
itemExtent: itemExtent,
|
|
||||||
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
|
||||||
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
|
||||||
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final album = albums[index];
|
|
||||||
return Container(
|
|
||||||
key: ValueKey(album.uri ?? album.itemId),
|
|
||||||
width: cardWidth,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
|
||||||
child: AlbumCard(
|
|
||||||
album: album,
|
|
||||||
heroTagSuffix: widget.heroTagSuffix,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
|
|||||||
width: widget.radius * 2,
|
width: widget.radius * 2,
|
||||||
height: widget.radius * 2,
|
height: widget.radius * 2,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (context, url) => Container(
|
placeholder: (context, url) => Container(
|
||||||
color: colorScheme.surfaceVariant,
|
color: colorScheme.surfaceVariant,
|
||||||
child: Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant),
|
child: Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant),
|
||||||
|
|||||||
@@ -100,49 +100,50 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Artist image - circular, constrained to be a proper circle
|
// Artist image - circular
|
||||||
Expanded(
|
// PERF: Use AspectRatio instead of LayoutBuilder to provide fixed geometry
|
||||||
child: Center(
|
// before Hero animation starts. This prevents grey icon flash caused by
|
||||||
child: LayoutBuilder(
|
// dynamic sizing inside the Hero widget.
|
||||||
builder: (context, constraints) {
|
AspectRatio(
|
||||||
// Use the smaller dimension to ensure a circle
|
aspectRatio: 1.0,
|
||||||
final size = constraints.maxWidth < constraints.maxHeight
|
child: Hero(
|
||||||
? constraints.maxWidth
|
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
|
||||||
: constraints.maxHeight;
|
child: ClipOval(
|
||||||
return Hero(
|
child: Container(
|
||||||
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
|
color: colorScheme.surfaceVariant,
|
||||||
child: ClipOval(
|
child: imageUrl != null
|
||||||
child: Container(
|
? CachedNetworkImage(
|
||||||
width: size,
|
imageUrl: imageUrl,
|
||||||
height: size,
|
fit: BoxFit.cover,
|
||||||
color: colorScheme.surfaceVariant,
|
memCacheWidth: 256,
|
||||||
child: imageUrl != null
|
memCacheHeight: 256,
|
||||||
? CachedNetworkImage(
|
fadeInDuration: Duration.zero,
|
||||||
imageUrl: imageUrl,
|
fadeOutDuration: Duration.zero,
|
||||||
fit: BoxFit.cover,
|
placeholder: (context, url) => const SizedBox(),
|
||||||
memCacheWidth: 256,
|
errorWidget: (context, url, error) {
|
||||||
memCacheHeight: 256,
|
// Try fallback on error (only for MA URLs, not fallback URLs)
|
||||||
fadeInDuration: const Duration(milliseconds: 150),
|
if (!_maImageFailed && url == maImageUrl) {
|
||||||
placeholder: (context, url) => const SizedBox(),
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
errorWidget: (context, url, error) {
|
_onImageError();
|
||||||
// Try fallback on error (only for MA URLs, not fallback URLs)
|
});
|
||||||
if (!_maImageFailed && url == maImageUrl) {
|
}
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
return Center(
|
||||||
_onImageError();
|
child: Icon(
|
||||||
});
|
Icons.person_rounded,
|
||||||
}
|
size: 64,
|
||||||
return Icon(
|
color: colorScheme.onSurfaceVariant,
|
||||||
Icons.person_rounded,
|
),
|
||||||
size: size * 0.55,
|
);
|
||||||
color: colorScheme.onSurfaceVariant,
|
},
|
||||||
);
|
)
|
||||||
},
|
: Center(
|
||||||
)
|
child: Icon(
|
||||||
: Icon(Icons.person_rounded, size: size * 0.55, color: colorScheme.onSurfaceVariant),
|
Icons.person_rounded,
|
||||||
),
|
size: 64,
|
||||||
),
|
color: colorScheme.onSurfaceVariant,
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+111
-69
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
@@ -10,6 +11,8 @@ class ArtistRow extends StatefulWidget {
|
|||||||
final Future<List<Artist>> Function() loadArtists;
|
final Future<List<Artist>> Function() loadArtists;
|
||||||
final String? heroTagSuffix;
|
final String? heroTagSuffix;
|
||||||
final double? rowHeight;
|
final double? rowHeight;
|
||||||
|
/// Optional: synchronous getter for cached data (for instant display)
|
||||||
|
final List<Artist>? Function()? getCachedArtists;
|
||||||
|
|
||||||
const ArtistRow({
|
const ArtistRow({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -17,6 +20,7 @@ class ArtistRow extends StatefulWidget {
|
|||||||
required this.loadArtists,
|
required this.loadArtists,
|
||||||
this.heroTagSuffix,
|
this.heroTagSuffix,
|
||||||
this.rowHeight,
|
this.rowHeight,
|
||||||
|
this.getCachedArtists,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -24,8 +28,8 @@ class ArtistRow extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixin {
|
class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixin {
|
||||||
late Future<List<Artist>> _artistsFuture;
|
List<Artist> _artists = [];
|
||||||
List<Artist>? _cachedArtists; // Keep last loaded data to prevent flash
|
bool _isLoading = true;
|
||||||
bool _hasLoaded = false;
|
bool _hasLoaded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -34,21 +38,117 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadArtistsOnce();
|
_loadArtists();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _loadArtistsOnce() {
|
Future<void> _loadArtists() async {
|
||||||
if (!_hasLoaded) {
|
if (_hasLoaded) return;
|
||||||
_artistsFuture = widget.loadArtists().then((artists) {
|
_hasLoaded = true;
|
||||||
_cachedArtists = artists;
|
|
||||||
return artists;
|
// 1. Try to get cached data synchronously for instant display
|
||||||
});
|
final cachedArtists = widget.getCachedArtists?.call();
|
||||||
_hasLoaded = true;
|
if (cachedArtists != null && cachedArtists.isNotEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_artists = cachedArtists;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Load fresh data (always update - fresh data may have images that cached data lacks)
|
||||||
|
try {
|
||||||
|
final freshArtists = await widget.loadArtists();
|
||||||
|
if (mounted && freshArtists.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_artists = freshArtists;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
// Pre-cache images for smooth hero animations
|
||||||
|
_precacheArtistImages(freshArtists);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent failure - keep showing cached data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted && _isLoading) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-cache artist images so hero animations are smooth on first tap
|
||||||
|
void _precacheArtistImages(List<Artist> artists) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
// Only precache first ~10 visible items to avoid excessive network/memory use
|
||||||
|
final artistsToCache = artists.take(10);
|
||||||
|
|
||||||
|
for (final artist in artistsToCache) {
|
||||||
|
final imageUrl = maProvider.api?.getImageUrl(artist, size: 256);
|
||||||
|
if (imageUrl != null) {
|
||||||
|
// Use CachedNetworkImageProvider to warm the cache
|
||||||
|
precacheImage(
|
||||||
|
CachedNetworkImageProvider(imageUrl),
|
||||||
|
context,
|
||||||
|
).catchError((_) {
|
||||||
|
// Silently ignore precache errors
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static final _logger = DebugLogger();
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
|
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
|
||||||
|
// Only show loading if we have no data at all
|
||||||
|
if (_artists.isEmpty && _isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_artists.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No artists found',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card layout: circle image + name below
|
||||||
|
// Text area: 8px gap + ~36px for 2-line name = ~44px
|
||||||
|
const textAreaHeight = 44.0;
|
||||||
|
final imageSize = contentHeight - textAreaHeight;
|
||||||
|
final cardWidth = imageSize; // Card width = image width (circle)
|
||||||
|
final itemExtent = cardWidth + 16; // width + horizontal margins
|
||||||
|
|
||||||
|
return ScrollConfiguration(
|
||||||
|
behavior: const _StretchScrollBehavior(),
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
itemCount: _artists.length,
|
||||||
|
itemExtent: itemExtent,
|
||||||
|
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||||
|
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final artist = _artists[index];
|
||||||
|
return Container(
|
||||||
|
key: ValueKey(artist.uri ?? artist.itemId),
|
||||||
|
width: cardWidth,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
|
child: ArtistCard(
|
||||||
|
artist: artist,
|
||||||
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
_logger.startBuild('ArtistRow:${widget.title}');
|
_logger.startBuild('ArtistRow:${widget.title}');
|
||||||
@@ -78,65 +178,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FutureBuilder<List<Artist>>(
|
child: _buildContent(contentHeight, colorScheme),
|
||||||
future: _artistsFuture,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
// Use cached data immediately if available (prevents flash on rebuild)
|
|
||||||
final artists = snapshot.data ?? _cachedArtists;
|
|
||||||
|
|
||||||
// Only show loading if we have no data at all
|
|
||||||
if (artists == null && snapshot.connectionState == ConnectionState.waiting) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot.hasError && artists == null) {
|
|
||||||
return Center(
|
|
||||||
child: Text('Error: ${snapshot.error}'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (artists == null || artists.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Text(
|
|
||||||
'No artists found',
|
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Card layout: circle image + name below
|
|
||||||
// Text area: 8px gap + ~36px for 2-line name = ~44px
|
|
||||||
const textAreaHeight = 44.0;
|
|
||||||
final imageSize = contentHeight - textAreaHeight;
|
|
||||||
final cardWidth = imageSize; // Card width = image width (circle)
|
|
||||||
final itemExtent = cardWidth + 16; // width + horizontal margins
|
|
||||||
|
|
||||||
return ScrollConfiguration(
|
|
||||||
behavior: const _StretchScrollBehavior(),
|
|
||||||
child: ListView.builder(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
||||||
itemCount: artists.length,
|
|
||||||
itemExtent: itemExtent,
|
|
||||||
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
|
||||||
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
|
||||||
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final artist = artists[index];
|
|
||||||
return Container(
|
|
||||||
key: ValueKey(artist.uri ?? artist.itemId),
|
|
||||||
width: cardWidth,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8.0),
|
|
||||||
child: ArtistCard(
|
|
||||||
artist: artist,
|
|
||||||
heroTagSuffix: widget.heroTagSuffix,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ class _AudiobookCard extends StatelessWidget {
|
|||||||
child: AudiobookDetailScreen(
|
child: AudiobookDetailScreen(
|
||||||
audiobook: audiobook,
|
audiobook: audiobook,
|
||||||
heroTagSuffix: heroTagSuffix,
|
heroTagSuffix: heroTagSuffix,
|
||||||
|
initialImageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -195,6 +196,11 @@ class _AudiobookCard extends StatelessWidget {
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
// PERF: Duration.zero for hero-wrapped images
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
memCacheWidth: 256,
|
||||||
|
memCacheHeight: 256,
|
||||||
placeholder: (_, __) => Center(
|
placeholder: (_, __) => Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
MdiIcons.bookOutline,
|
MdiIcons.bookOutline,
|
||||||
|
|||||||
+351
-216
@@ -15,6 +15,7 @@ import 'animated_icon_button.dart';
|
|||||||
import 'global_player_overlay.dart';
|
import 'global_player_overlay.dart';
|
||||||
import 'volume_control.dart';
|
import 'volume_control.dart';
|
||||||
import 'player/player_widgets.dart';
|
import 'player/player_widgets.dart';
|
||||||
|
import 'player/mini_player_content.dart';
|
||||||
|
|
||||||
/// A unified player widget that seamlessly expands from mini to full-screen.
|
/// A unified player widget that seamlessly expands from mini to full-screen.
|
||||||
///
|
///
|
||||||
@@ -25,9 +26,27 @@ class ExpandablePlayer extends StatefulWidget {
|
|||||||
/// Slide offset for hiding the mini player (0.0 = visible, 1.0 = hidden below screen)
|
/// Slide offset for hiding the mini player (0.0 = visible, 1.0 = hidden below screen)
|
||||||
final double slideOffset;
|
final double slideOffset;
|
||||||
|
|
||||||
|
/// Bounce offset for the player reveal animation (moves mini player down slightly)
|
||||||
|
final double bounceOffset;
|
||||||
|
|
||||||
|
/// Callback when swipe-down gesture triggers player reveal
|
||||||
|
final VoidCallback? onRevealPlayers;
|
||||||
|
|
||||||
|
/// Whether the device reveal overlay is currently visible
|
||||||
|
/// When true, shows player name instead of track name in collapsed state
|
||||||
|
final bool isDeviceRevealVisible;
|
||||||
|
|
||||||
|
/// Whether the hint text should be shown
|
||||||
|
/// When true, shows "Pull to select players" instead of track info
|
||||||
|
final bool isHintVisible;
|
||||||
|
|
||||||
const ExpandablePlayer({
|
const ExpandablePlayer({
|
||||||
super.key,
|
super.key,
|
||||||
this.slideOffset = 0.0,
|
this.slideOffset = 0.0,
|
||||||
|
this.bounceOffset = 0.0,
|
||||||
|
this.onRevealPlayers,
|
||||||
|
this.isDeviceRevealVisible = false,
|
||||||
|
this.isHintVisible = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -65,6 +84,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
static const double _bottomNavHeight = 56.0;
|
static const double _bottomNavHeight = 56.0;
|
||||||
static const double _edgeDeadZone = 40.0; // Dead zone for Android back gesture
|
static const double _edgeDeadZone = 40.0; // Dead zone for Android back gesture
|
||||||
|
|
||||||
|
// Pastel yellow for grouped players (matches PlayerCard.groupBorderColor)
|
||||||
|
static const Color _groupBorderColor = Color(0xFFFFF59D);
|
||||||
|
|
||||||
// Track horizontal drag start position
|
// Track horizontal drag start position
|
||||||
double? _horizontalDragStartX;
|
double? _horizontalDragStartX;
|
||||||
|
|
||||||
@@ -91,6 +113,18 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
String? _lastMeasuredTrackName;
|
String? _lastMeasuredTrackName;
|
||||||
double? _lastMeasuredTitleWidth;
|
double? _lastMeasuredTitleWidth;
|
||||||
|
|
||||||
|
// Gesture-driven expansion state
|
||||||
|
bool _isVerticalDragging = false;
|
||||||
|
double _dragStartY = 0;
|
||||||
|
double _dragStartValue = 0;
|
||||||
|
|
||||||
|
// Maximum drag distance for full expand/collapse
|
||||||
|
static const double _maxDragDistance = 300.0;
|
||||||
|
|
||||||
|
// Thresholds for committing expand/collapse
|
||||||
|
static const double _commitPositionThreshold = 0.3; // 30% progress
|
||||||
|
static const double _commitVelocityThreshold = 800.0; // px/s
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -98,6 +132,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Simple curved animation - performant and smooth
|
||||||
_expandAnimation = CurvedAnimation(
|
_expandAnimation = CurvedAnimation(
|
||||||
parent: _controller,
|
parent: _controller,
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
@@ -128,7 +164,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
);
|
);
|
||||||
|
|
||||||
_controller.addStatusListener((status) {
|
_controller.addStatusListener((status) {
|
||||||
if (status == AnimationStatus.forward) {
|
// PERF: Load queue after animation completes, not during
|
||||||
|
// This prevents network I/O from competing with animation frames
|
||||||
|
if (status == AnimationStatus.completed) {
|
||||||
_loadQueue();
|
_loadQueue();
|
||||||
} else if (status == AnimationStatus.dismissed) {
|
} else if (status == AnimationStatus.dismissed) {
|
||||||
// Close queue panel when player collapses
|
// Close queue panel when player collapses
|
||||||
@@ -182,23 +220,94 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
AnimationDebugger.recordFrame(_controller.value);
|
AnimationDebugger.recordFrame(_controller.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Animation durations - asymmetric for snappier collapse
|
||||||
|
static const Duration _expandDuration = Duration(milliseconds: 280);
|
||||||
|
static const Duration _collapseDuration = Duration(milliseconds: 200);
|
||||||
|
|
||||||
void expand() {
|
void expand() {
|
||||||
|
if (_isVerticalDragging) return;
|
||||||
AnimationDebugger.startSession('playerExpand');
|
AnimationDebugger.startSession('playerExpand');
|
||||||
|
_controller.duration = _expandDuration;
|
||||||
_controller.forward().then((_) {
|
_controller.forward().then((_) {
|
||||||
AnimationDebugger.endSession();
|
AnimationDebugger.endSession();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void collapse() {
|
void collapse() {
|
||||||
|
if (_isVerticalDragging) return;
|
||||||
AnimationDebugger.startSession('playerCollapse');
|
AnimationDebugger.startSession('playerCollapse');
|
||||||
// Instantly hide queue panel when collapsing to avoid visual glitches
|
// Instantly hide queue panel when collapsing to avoid visual glitches
|
||||||
// during Android's predictive back gesture
|
// during Android's predictive back gesture
|
||||||
_queuePanelController.value = 0;
|
_queuePanelController.value = 0;
|
||||||
|
_controller.duration = _collapseDuration;
|
||||||
_controller.reverse().then((_) {
|
_controller.reverse().then((_) {
|
||||||
AnimationDebugger.endSession();
|
AnimationDebugger.endSession();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle vertical drag start - begin gesture-driven expansion
|
||||||
|
void _handleVerticalDragStart(DragStartDetails details) {
|
||||||
|
_isVerticalDragging = true;
|
||||||
|
_dragStartY = details.globalPosition.dy;
|
||||||
|
_dragStartValue = _controller.value;
|
||||||
|
_controller.stop(); // Stop any running animation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle vertical drag update - finger tracking
|
||||||
|
void _handleVerticalDragUpdate(DragUpdateDetails details) {
|
||||||
|
if (!_isVerticalDragging) return;
|
||||||
|
|
||||||
|
final dragDelta = _dragStartY - details.globalPosition.dy;
|
||||||
|
// Swipe up = positive delta = expand (increase value)
|
||||||
|
// Swipe down = negative delta = collapse (decrease value)
|
||||||
|
final normalizedDelta = dragDelta / _maxDragDistance;
|
||||||
|
final newValue = (_dragStartValue + normalizedDelta).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
_controller.value = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle vertical drag end - decide to commit or snap back
|
||||||
|
void _handleVerticalDragEnd(DragEndDetails details) {
|
||||||
|
if (!_isVerticalDragging) return;
|
||||||
|
_isVerticalDragging = false;
|
||||||
|
|
||||||
|
final velocity = details.primaryVelocity ?? 0;
|
||||||
|
final currentValue = _controller.value;
|
||||||
|
|
||||||
|
// Determine direction based on velocity first, then position
|
||||||
|
bool shouldExpand;
|
||||||
|
if (velocity.abs() > _commitVelocityThreshold) {
|
||||||
|
// High velocity: use velocity direction (negative = swipe up = expand)
|
||||||
|
shouldExpand = velocity < 0;
|
||||||
|
} else if (currentValue > _commitPositionThreshold && currentValue < (1 - _commitPositionThreshold)) {
|
||||||
|
// In the middle zone: use velocity direction if any, otherwise snap to nearest
|
||||||
|
shouldExpand = velocity < 0 || (velocity == 0 && currentValue > 0.5);
|
||||||
|
} else {
|
||||||
|
// Near edges: commit to nearest endpoint
|
||||||
|
shouldExpand = currentValue > 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate to target with appropriate duration
|
||||||
|
if (shouldExpand) {
|
||||||
|
if (currentValue < 1.0) {
|
||||||
|
AnimationDebugger.startSession('playerExpand');
|
||||||
|
_controller.duration = _expandDuration;
|
||||||
|
_controller.forward().then((_) {
|
||||||
|
AnimationDebugger.endSession();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (currentValue > 0.0) {
|
||||||
|
AnimationDebugger.startSession('playerCollapse');
|
||||||
|
_queuePanelController.value = 0;
|
||||||
|
_controller.duration = _collapseDuration;
|
||||||
|
_controller.reverse().then((_) {
|
||||||
|
AnimationDebugger.endSession();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool get isExpanded => _controller.value > 0.5;
|
bool get isExpanded => _controller.value > 0.5;
|
||||||
|
|
||||||
double get expansionProgress => _controller.value;
|
double get expansionProgress => _controller.value;
|
||||||
@@ -227,24 +336,49 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
Future<void> _loadQueue() async {
|
Future<void> _loadQueue() async {
|
||||||
if (_isLoadingQueue) return;
|
if (_isLoadingQueue) return;
|
||||||
|
|
||||||
setState(() => _isLoadingQueue = true);
|
|
||||||
|
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final player = maProvider.selectedPlayer;
|
final player = maProvider.selectedPlayer;
|
||||||
|
|
||||||
if (player != null && maProvider.api != null) {
|
if (player == null) {
|
||||||
final queue = await maProvider.api!.getQueue(player.playerId);
|
if (mounted) setState(() => _isLoadingQueue = false);
|
||||||
if (mounted) {
|
return;
|
||||||
setState(() {
|
}
|
||||||
_queue = queue;
|
|
||||||
_isLoadingQueue = false;
|
// 1. Show cached queue immediately (if available)
|
||||||
});
|
final cachedQueue = await maProvider.getCachedQueue(player.playerId);
|
||||||
}
|
if (cachedQueue != null && cachedQueue.items.isNotEmpty && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_queue = cachedQueue;
|
||||||
|
_isLoadingQueue = false;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
if (mounted) {
|
setState(() => _isLoadingQueue = true);
|
||||||
setState(() => _isLoadingQueue = false);
|
}
|
||||||
|
|
||||||
|
// 2. Fetch fresh queue in background
|
||||||
|
if (maProvider.api != null) {
|
||||||
|
try {
|
||||||
|
final freshQueue = await maProvider.getQueue(player.playerId);
|
||||||
|
if (mounted && freshQueue != null) {
|
||||||
|
// Only update if queue changed
|
||||||
|
final queueChanged = _queue == null ||
|
||||||
|
_queue!.items.length != freshQueue.items.length ||
|
||||||
|
_queue!.currentIndex != freshQueue.currentIndex;
|
||||||
|
if (queueChanged) {
|
||||||
|
setState(() {
|
||||||
|
_queue = freshQueue;
|
||||||
|
_isLoadingQueue = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent failure - keep showing cached queue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mounted && _isLoadingQueue) {
|
||||||
|
setState(() => _isLoadingQueue = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _extractColors(String imageUrl) async {
|
Future<void> _extractColors(String imageUrl) async {
|
||||||
@@ -324,14 +458,15 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle favorite status for current track
|
/// Toggle favorite status for current track (with offline queuing support)
|
||||||
Future<void> _toggleCurrentTrackFavorite(dynamic currentTrack) async {
|
Future<void> _toggleCurrentTrackFavorite(dynamic currentTrack) async {
|
||||||
if (currentTrack == null) return;
|
if (currentTrack == null) return;
|
||||||
|
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
if (maProvider.api == null) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
bool success;
|
||||||
|
|
||||||
if (_isCurrentTrackFavorite) {
|
if (_isCurrentTrackFavorite) {
|
||||||
// Remove from favorites - need library_item_id (numeric)
|
// Remove from favorites - need library_item_id (numeric)
|
||||||
int? libraryItemId;
|
int? libraryItemId;
|
||||||
@@ -353,7 +488,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (libraryItemId != null) {
|
if (libraryItemId != null) {
|
||||||
await maProvider.api!.removeFromFavorites('track', libraryItemId);
|
success = await maProvider.removeFromFavorites(
|
||||||
|
mediaType: 'track',
|
||||||
|
libraryItemId: libraryItemId,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
throw Exception('Could not determine library ID for this track');
|
throw Exception('Could not determine library ID for this track');
|
||||||
}
|
}
|
||||||
@@ -386,16 +524,37 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await maProvider.api!.addToFavorites('track', actualItemId, actualProvider);
|
success = await maProvider.addToFavorites(
|
||||||
|
mediaType: 'track',
|
||||||
|
itemId: actualItemId,
|
||||||
|
provider: actualProvider,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle local state
|
if (success) {
|
||||||
setState(() {
|
// Toggle local state
|
||||||
_isCurrentTrackFavorite = !_isCurrentTrackFavorite;
|
setState(() {
|
||||||
});
|
_isCurrentTrackFavorite = !_isCurrentTrackFavorite;
|
||||||
|
});
|
||||||
|
|
||||||
// Invalidate home cache so favorites are updated
|
// Invalidate home cache so favorites are updated
|
||||||
maProvider.invalidateHomeCache();
|
maProvider.invalidateHomeCache();
|
||||||
|
|
||||||
|
// Show feedback (different message if offline)
|
||||||
|
if (mounted) {
|
||||||
|
final isOffline = !maProvider.isConnected;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
isOffline
|
||||||
|
? S.of(context)!.actionQueuedForSync
|
||||||
|
: (_isCurrentTrackFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Show error feedback
|
// Show error feedback
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -850,9 +1009,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
final bottomOffset = bottomNavSpace + _collapsedMargin;
|
final bottomOffset = bottomNavSpace + _collapsedMargin;
|
||||||
final width = screenSize.width - (_collapsedMargin * 2);
|
final width = screenSize.width - (_collapsedMargin * 2);
|
||||||
|
|
||||||
// Apply slide offset for hiding
|
// Apply slide offset for hiding and bounce offset for reveal animation
|
||||||
final slideDownAmount = widget.slideOffset * (_collapsedHeight + bottomOffset + 20);
|
final slideDownAmount = widget.slideOffset * (_collapsedHeight + bottomOffset + 20);
|
||||||
final adjustedBottomOffset = bottomOffset - slideDownAmount;
|
final bounceDownAmount = widget.bounceOffset;
|
||||||
|
final adjustedBottomOffset = bottomOffset - slideDownAmount - bounceDownAmount;
|
||||||
|
|
||||||
final availablePlayers = _getAvailablePlayersSorted(maProvider);
|
final availablePlayers = _getAvailablePlayersSorted(maProvider);
|
||||||
final hasMultiplePlayers = availablePlayers.length > 1;
|
final hasMultiplePlayers = availablePlayers.length > 1;
|
||||||
@@ -861,7 +1021,13 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
left: _collapsedMargin,
|
left: _collapsedMargin,
|
||||||
right: _collapsedMargin,
|
right: _collapsedMargin,
|
||||||
bottom: adjustedBottomOffset,
|
bottom: adjustedBottomOffset,
|
||||||
child: DeviceSelectorBar(
|
child: GestureDetector(
|
||||||
|
onVerticalDragUpdate: (details) {
|
||||||
|
if (details.primaryDelta! > 5 && widget.onRevealPlayers != null) {
|
||||||
|
widget.onRevealPlayers!();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: DeviceSelectorBar(
|
||||||
selectedPlayer: selectedPlayer,
|
selectedPlayer: selectedPlayer,
|
||||||
peekPlayer: _peekPlayer,
|
peekPlayer: _peekPlayer,
|
||||||
hasMultiplePlayers: hasMultiplePlayers,
|
hasMultiplePlayers: hasMultiplePlayers,
|
||||||
@@ -897,6 +1063,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
_handleHorizontalDragEnd(details, maProvider);
|
_handleHorizontalDragEnd(details, maProvider);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -954,11 +1121,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
final expandedHeight = screenSize.height - bottomNavSpace;
|
final expandedHeight = screenSize.height - bottomNavSpace;
|
||||||
|
|
||||||
// Apply slide offset to hide mini player (slides down off-screen)
|
// Apply slide offset to hide mini player (slides down off-screen)
|
||||||
// Only apply when collapsed (t == 0), don't affect expanded state
|
// Apply bounce offset for reveal animation (small downward movement)
|
||||||
|
// Smoothly fade out offsets as animation progresses (continuous, no discontinuity)
|
||||||
final slideDownAmount = widget.slideOffset * (_collapsedHeight + collapsedBottomOffset + 20);
|
final slideDownAmount = widget.slideOffset * (_collapsedHeight + collapsedBottomOffset + 20);
|
||||||
final slideAdjustedBottomOffset = t < 0.1
|
final bounceDownAmount = widget.bounceOffset;
|
||||||
? collapsedBottomOffset - slideDownAmount
|
// Fade out slide/bounce effects smoothly over first 15% of animation
|
||||||
: _lerpDouble(collapsedBottomOffset, expandedBottomOffset, t);
|
final offsetFade = (1.0 - (t / 0.15)).clamp(0.0, 1.0);
|
||||||
|
final baseBottomOffset = _lerpDouble(collapsedBottomOffset, expandedBottomOffset, t);
|
||||||
|
final slideAdjustedBottomOffset = baseBottomOffset - (slideDownAmount + bounceDownAmount) * offsetFade;
|
||||||
|
|
||||||
final collapsedWidth = screenSize.width - (_collapsedMargin * 2);
|
final collapsedWidth = screenSize.width - (_collapsedMargin * 2);
|
||||||
final width = _lerpDouble(collapsedWidth, screenSize.width, t);
|
final width = _lerpDouble(collapsedWidth, screenSize.width, t);
|
||||||
@@ -997,23 +1167,18 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
final expandedArtTop = topPadding + headerHeight + 16;
|
final expandedArtTop = topPadding + headerHeight + 16;
|
||||||
final artTop = _lerpDouble(collapsedArtTop, expandedArtTop, t);
|
final artTop = _lerpDouble(collapsedArtTop, expandedArtTop, t);
|
||||||
|
|
||||||
// Typography - clear hierarchy
|
// Typography - uses shared MiniPlayerLayout constants for collapsed state
|
||||||
// Title: bold, prominent (24px)
|
final titleFontSize = _lerpDouble(MiniPlayerLayout.primaryFontSize, 24.0, t);
|
||||||
// Artist: medium weight, secondary (18px expanded, was 16px)
|
final artistFontSize = _lerpDouble(MiniPlayerLayout.secondaryFontSize, 18.0, t);
|
||||||
// Album: light, tertiary (15px expanded, was 13px)
|
|
||||||
final titleFontSize = _lerpDouble(16.0, 24.0, t);
|
|
||||||
final artistFontSize = _lerpDouble(14.0, 18.0, t); // 14px collapsed, 18px expanded
|
|
||||||
|
|
||||||
final collapsedTitleLeft = _collapsedArtSize + 8; // Reduced from 12 to 8 (4px less)
|
|
||||||
final expandedTitleLeft = contentPadding;
|
final expandedTitleLeft = contentPadding;
|
||||||
final titleLeft = _lerpDouble(collapsedTitleLeft, expandedTitleLeft, t);
|
final titleLeft = _lerpDouble(MiniPlayerLayout.textLeft, expandedTitleLeft, t);
|
||||||
|
|
||||||
final collapsedTitleTop = (_collapsedHeight - 36) / 2; // Centered vertically (adjusted for increased track/artist gap)
|
final collapsedTitleTop = MiniPlayerLayout.primaryTop;
|
||||||
|
|
||||||
// Controls: 36 (prev) + 34 (play) + 36 (next) + 8 (right margin) = 114px from widget right
|
// Controls: 36 (prev) + 34 (play) + 36 (next) + 8 (right margin) = 114px from widget right
|
||||||
// For 8px gap: text ends at widgetWidth - 114 - 8 = widgetWidth - 122
|
// For 8px gap: text ends at widgetWidth - 114 - 8 = widgetWidth - 122
|
||||||
// Note: collapsedWidth = screenSize.width - 24 (12px margin each side)
|
final collapsedTitleWidth = collapsedWidth - MiniPlayerLayout.textLeft - 122;
|
||||||
final collapsedTitleWidth = collapsedWidth - collapsedTitleLeft - 122;
|
|
||||||
final expandedTitleWidth = screenSize.width - (contentPadding * 2);
|
final expandedTitleWidth = screenSize.width - (contentPadding * 2);
|
||||||
final titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t);
|
final titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t);
|
||||||
|
|
||||||
@@ -1070,7 +1235,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
final titleTop = _lerpDouble(collapsedTitleTop, expandedTitleTop, t);
|
final titleTop = _lerpDouble(collapsedTitleTop, expandedTitleTop, t);
|
||||||
|
|
||||||
// Artist positioned dynamically based on actual title height
|
// Artist positioned dynamically based on actual title height
|
||||||
final collapsedArtistTop = collapsedTitleTop + 22; // Increased gap from 18 to 22
|
final collapsedArtistTop = MiniPlayerLayout.secondaryTop;
|
||||||
final expandedArtistTop = expandedTitleTop + expandedTitleHeight + titleToArtistGap;
|
final expandedArtistTop = expandedTitleTop + expandedTitleHeight + titleToArtistGap;
|
||||||
final artistTop = _lerpDouble(collapsedArtistTop, expandedArtistTop, t);
|
final artistTop = _lerpDouble(collapsedArtistTop, expandedArtistTop, t);
|
||||||
|
|
||||||
@@ -1102,7 +1267,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
final selectedPlayerId = selectedPlayer.playerId;
|
final selectedPlayerId = selectedPlayer.playerId;
|
||||||
|
|
||||||
// Calculate slide offset for mini player content (only when collapsed)
|
// Calculate slide offset for mini player content (only when collapsed)
|
||||||
final miniPlayerSlideOffset = t < 0.1 ? _slideOffset * collapsedWidth : 0.0;
|
// Smoothly fade out horizontal slide offset (for device switching) as animation progresses
|
||||||
|
final miniPlayerSlideOffset = _slideOffset * collapsedWidth * offsetFade;
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: horizontalMargin,
|
left: horizontalMargin,
|
||||||
@@ -1113,13 +1279,47 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
// Only handle tap when collapsed - when expanded, let children handle their own taps
|
// Only handle tap when collapsed - when expanded, let children handle their own taps
|
||||||
onTap: isExpanded ? null : expand,
|
onTap: isExpanded ? null : expand,
|
||||||
onVerticalDragUpdate: (details) {
|
onVerticalDragStart: (details) {
|
||||||
if (details.primaryDelta! < -10 && !isExpanded) {
|
// For expanded player or queue panel: start tracking immediately
|
||||||
expand();
|
// For collapsed player: defer decision until we know swipe direction
|
||||||
} else if (details.primaryDelta! > 10 && isExpanded && !isQueuePanelOpen) {
|
if (isExpanded || isQueuePanelOpen) {
|
||||||
collapse();
|
_handleVerticalDragStart(details);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onVerticalDragUpdate: (details) {
|
||||||
|
final delta = details.primaryDelta ?? 0;
|
||||||
|
|
||||||
|
// Handle queue panel close
|
||||||
|
if (isQueuePanelOpen && delta > 0) {
|
||||||
|
_toggleQueuePanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapsed + not yet tracking: decide based on direction
|
||||||
|
if (!isExpanded && !_isVerticalDragging) {
|
||||||
|
if (delta > 5 && widget.onRevealPlayers != null) {
|
||||||
|
// Swipe down → show player reveal
|
||||||
|
widget.onRevealPlayers!();
|
||||||
|
return;
|
||||||
|
} else if (delta < -5) {
|
||||||
|
// Swipe up → start expand tracking
|
||||||
|
_handleVerticalDragStart(DragStartDetails(
|
||||||
|
globalPosition: details.globalPosition,
|
||||||
|
localPosition: details.localPosition,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
// Not enough movement yet, wait for more
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gesture-driven expand/collapse
|
||||||
|
_handleVerticalDragUpdate(details);
|
||||||
|
},
|
||||||
|
onVerticalDragEnd: (details) {
|
||||||
|
// Finish gesture-driven expansion
|
||||||
|
_handleVerticalDragEnd(details);
|
||||||
|
},
|
||||||
onHorizontalDragStart: (details) {
|
onHorizontalDragStart: (details) {
|
||||||
_horizontalDragStartX = details.globalPosition.dx;
|
_horizontalDragStartX = details.globalPosition.dx;
|
||||||
},
|
},
|
||||||
@@ -1160,13 +1360,20 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
_handleHorizontalDragEnd(details, maProvider);
|
_handleHorizontalDragEnd(details, maProvider);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Material(
|
child: Container(
|
||||||
color: backgroundColor,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
elevation: _lerpDouble(4, 0, t),
|
border: selectedPlayer.isGrouped && t < 0.5
|
||||||
shadowColor: Colors.black.withOpacity(0.3),
|
? Border.all(color: _groupBorderColor, width: 1.5)
|
||||||
clipBehavior: Clip.antiAlias,
|
: null,
|
||||||
child: SizedBox(
|
),
|
||||||
|
child: Material(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
elevation: _lerpDouble(4, 0, t),
|
||||||
|
shadowColor: Colors.black.withOpacity(0.3),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: SizedBox(
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -1176,41 +1383,41 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
// Shows played portion with brighter color, unplayed with darker
|
// Shows played portion with brighter color, unplayed with darker
|
||||||
// Starts after album art so progress is visible
|
// Starts after album art so progress is visible
|
||||||
// Slides with content during swipe, hidden during transition if peek available
|
// Slides with content during swipe, hidden during transition if peek available
|
||||||
|
// GPU PERF: Use conditional rendering + color alpha instead of Opacity
|
||||||
|
// to avoid double saveLayer (Opacity + ClipRRect)
|
||||||
if (t < 0.5 && currentTrack?.duration != null && !(_inTransition && t < 0.1 && _peekPlayer != null))
|
if (t < 0.5 && currentTrack?.duration != null && !(_inTransition && t < 0.1 && _peekPlayer != null))
|
||||||
Positioned(
|
Positioned(
|
||||||
left: _collapsedArtSize + miniPlayerSlideOffset,
|
left: _collapsedArtSize + miniPlayerSlideOffset,
|
||||||
top: 0,
|
top: 0,
|
||||||
width: width - _collapsedArtSize,
|
width: width - _collapsedArtSize,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
child: ValueListenableBuilder<int>(
|
child: ValueListenableBuilder<int>(
|
||||||
valueListenable: _progressNotifier,
|
valueListenable: _progressNotifier,
|
||||||
builder: (context, elapsedSeconds, _) {
|
builder: (context, elapsedSeconds, _) {
|
||||||
final totalSeconds = currentTrack!.duration!.inSeconds;
|
final totalSeconds = currentTrack!.duration!.inSeconds;
|
||||||
if (totalSeconds <= 0) return const SizedBox.shrink();
|
if (totalSeconds <= 0) return const SizedBox.shrink();
|
||||||
final progress = (elapsedSeconds / totalSeconds).clamp(0.0, 1.0);
|
final progress = (elapsedSeconds / totalSeconds).clamp(0.0, 1.0);
|
||||||
// Fade out as we expand
|
// Fade out as we expand - use color alpha instead of Opacity widget
|
||||||
final progressOpacity = (1.0 - (t / 0.5)).clamp(0.0, 1.0);
|
final progressOpacity = (1.0 - (t / 0.5)).clamp(0.0, 1.0);
|
||||||
final progressAreaWidth = width - _collapsedArtSize;
|
final progressAreaWidth = width - _collapsedArtSize;
|
||||||
return Opacity(
|
return ClipRRect(
|
||||||
opacity: progressOpacity,
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.only(
|
borderRadius: BorderRadius.only(
|
||||||
topRight: Radius.circular(borderRadius),
|
topRight: Radius.circular(borderRadius),
|
||||||
bottomRight: Radius.circular(borderRadius),
|
bottomRight: Radius.circular(borderRadius),
|
||||||
),
|
),
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: progressAreaWidth * progress,
|
width: progressAreaWidth * progress,
|
||||||
height: height,
|
height: height,
|
||||||
color: collapsedBg,
|
color: collapsedBg.withOpacity(progressOpacity),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Peek player content (shows when dragging OR during transition)
|
// Peek player content (shows when dragging OR during transition)
|
||||||
// Show when: actively dragging (slideOffset != 0) OR in transition state
|
// Show when: actively dragging (slideOffset != 0) OR in transition state
|
||||||
@@ -1218,20 +1425,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
if (t < 0.1 && _peekPlayer != null && (_slideOffset.abs() > 0.01 || _inTransition))
|
if (t < 0.1 && _peekPlayer != null && (_slideOffset.abs() > 0.01 || _inTransition))
|
||||||
_buildPeekContent(
|
_buildPeekContent(
|
||||||
context: context,
|
context: context,
|
||||||
maProvider: maProvider,
|
|
||||||
peekPlayer: _peekPlayer,
|
peekPlayer: _peekPlayer,
|
||||||
peekImageUrl: _peekImageUrl,
|
peekImageUrl: _peekImageUrl,
|
||||||
slideOffset: _slideOffset,
|
slideOffset: _slideOffset,
|
||||||
containerWidth: collapsedWidth,
|
containerWidth: collapsedWidth,
|
||||||
artSize: _collapsedArtSize,
|
backgroundColor: collapsedBg,
|
||||||
titleLeft: collapsedTitleLeft,
|
|
||||||
titleTop: collapsedTitleTop,
|
|
||||||
titleWidth: collapsedTitleWidth,
|
|
||||||
artistTop: collapsedArtistTop,
|
|
||||||
titleFontSize: 16.0,
|
|
||||||
artistFontSize: 14.0,
|
|
||||||
textColor: textColor,
|
textColor: textColor,
|
||||||
colorScheme: colorScheme,
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// Album art - with slide animation when collapsed
|
// Album art - with slide animation when collapsed
|
||||||
@@ -1289,23 +1488,64 @@ 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
|
||||||
// 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,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: titleWidth,
|
width: titleWidth,
|
||||||
child: Text(
|
child: Text(
|
||||||
currentTrack.name,
|
// Show player name when device reveal visible and collapsed
|
||||||
|
(widget.isDeviceRevealVisible && t < 0.5)
|
||||||
|
? selectedPlayer.name
|
||||||
|
: currentTrack.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: textColor,
|
color: textColor,
|
||||||
fontSize: titleFontSize,
|
fontSize: titleFontSize,
|
||||||
fontWeight: t > 0.5 ? FontWeight.w600 : FontWeight.w500,
|
fontWeight: t > 0.5 ? FontWeight.w600 : MiniPlayerLayout.primaryFontWeight,
|
||||||
letterSpacing: t > 0.5 ? -0.5 : 0,
|
letterSpacing: t > 0.5 ? -0.5 : 0,
|
||||||
height: 1.2,
|
height: t > 0.5 ? 1.2 : null, // Only use line height when expanded
|
||||||
),
|
),
|
||||||
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
|
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
|
||||||
maxLines: t > 0.5 ? 2 : 1,
|
maxLines: t > 0.5 ? 2 : 1,
|
||||||
@@ -1316,22 +1556,25 @@ 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
|
||||||
// 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(
|
||||||
|
// Always show artist/author (was showing "Now Playing" when device reveal visible)
|
||||||
maProvider.isPlayingAudiobook
|
maProvider.isPlayingAudiobook
|
||||||
? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor)
|
? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor)
|
||||||
: currentTrack.artistsString,
|
: currentTrack.artistsString,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: textColor.withOpacity(t > 0.5 ? 0.7 : 0.6),
|
color: textColor.withOpacity(t > 0.5 ? 0.7 : MiniPlayerLayout.secondaryTextOpacity),
|
||||||
fontSize: artistFontSize,
|
fontSize: artistFontSize,
|
||||||
fontWeight: t > 0.5 ? FontWeight.w400 : FontWeight.normal,
|
fontWeight: t > 0.5 ? FontWeight.w400 : FontWeight.normal,
|
||||||
),
|
),
|
||||||
@@ -1707,163 +1950,55 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the peek player content that slides in from the edge during drag
|
/// Build the peek player content that slides in from the edge during drag
|
||||||
|
/// Uses shared MiniPlayerContent for consistency
|
||||||
Widget _buildPeekContent({
|
Widget _buildPeekContent({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required MusicAssistantProvider maProvider,
|
|
||||||
required dynamic peekPlayer,
|
required dynamic peekPlayer,
|
||||||
required String? peekImageUrl,
|
required String? peekImageUrl,
|
||||||
required double slideOffset,
|
required double slideOffset,
|
||||||
required double containerWidth,
|
required double containerWidth,
|
||||||
required double artSize,
|
required Color backgroundColor,
|
||||||
required double titleLeft,
|
|
||||||
required double titleTop,
|
|
||||||
required double titleWidth,
|
|
||||||
required double artistTop,
|
|
||||||
required double titleFontSize,
|
|
||||||
required double artistFontSize,
|
|
||||||
required Color textColor,
|
required Color textColor,
|
||||||
required ColorScheme colorScheme,
|
|
||||||
}) {
|
}) {
|
||||||
// Calculate sliding position
|
// Calculate sliding position
|
||||||
// During transition (_inTransition = true), peek should be at center (offset 0)
|
|
||||||
// When sliding left (negative offset), peek comes from right
|
|
||||||
// When sliding right (positive offset), peek comes from left
|
|
||||||
|
|
||||||
double peekBaseOffset;
|
double peekBaseOffset;
|
||||||
|
|
||||||
if (_inTransition && slideOffset.abs() < 0.01) {
|
if (_inTransition && slideOffset.abs() < 0.01) {
|
||||||
// During transition with slideOffset at 0, show peek at center
|
|
||||||
peekBaseOffset = 0.0;
|
peekBaseOffset = 0.0;
|
||||||
} else {
|
} else {
|
||||||
final isFromRight = slideOffset < 0;
|
final isFromRight = slideOffset < 0;
|
||||||
final peekProgress = slideOffset.abs();
|
final peekProgress = slideOffset.abs();
|
||||||
|
|
||||||
// Position calculation:
|
|
||||||
// From right: starts at containerWidth (off right edge), moves to 0 as progress increases
|
|
||||||
// From left: starts at -containerWidth (off left edge), moves to 0 as progress increases
|
|
||||||
peekBaseOffset = isFromRight
|
peekBaseOffset = isFromRight
|
||||||
? containerWidth * (1 - peekProgress) // Slides in from right
|
? containerWidth * (1 - peekProgress)
|
||||||
: -containerWidth * (1 - peekProgress); // Slides in from left
|
: -containerWidth * (1 - peekProgress);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if peek player has a track - if not, show device info instead
|
// Check if peek player has a track
|
||||||
final hasTrack = _peekTrack != null && peekImageUrl != null;
|
final hasTrack = _peekTrack != null && peekImageUrl != null;
|
||||||
final peekTrackName = hasTrack ? _peekTrack!.name : (peekPlayer?.name ?? S.of(context)!.unknown);
|
final peekTrackName = hasTrack ? _peekTrack!.name : (peekPlayer?.name ?? S.of(context)!.unknown);
|
||||||
final peekArtistName = hasTrack ? (_peekTrack!.artistsString ?? '') : S.of(context)!.swipeToSwitchDevice;
|
final peekArtistName = hasTrack ? (_peekTrack!.artistsString ?? '') : S.of(context)!.swipeToSwitchDevice;
|
||||||
|
|
||||||
return Stack(
|
return Transform.translate(
|
||||||
children: [
|
offset: Offset(peekBaseOffset, 0),
|
||||||
// Peek album art / device icon
|
child: MiniPlayerContent(
|
||||||
Positioned(
|
primaryText: peekTrackName,
|
||||||
left: peekBaseOffset,
|
secondaryText: peekArtistName,
|
||||||
top: 0,
|
imageUrl: hasTrack ? peekImageUrl : null,
|
||||||
child: Container(
|
playerName: peekPlayer?.name ?? '',
|
||||||
width: artSize,
|
backgroundColor: backgroundColor,
|
||||||
height: artSize,
|
textColor: textColor,
|
||||||
decoration: BoxDecoration(
|
width: containerWidth,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
slideOffset: 0, // Transform handles positioning
|
||||||
),
|
|
||||||
child: hasTrack
|
|
||||||
? CachedNetworkImage(
|
|
||||||
imageUrl: peekImageUrl!,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
memCacheWidth: 256,
|
|
||||||
memCacheHeight: 256,
|
|
||||||
fadeInDuration: Duration.zero,
|
|
||||||
fadeOutDuration: Duration.zero,
|
|
||||||
placeholderFadeInDuration: Duration.zero,
|
|
||||||
placeholder: (_, __) => _buildMiniPlaceholderArt(colorScheme),
|
|
||||||
errorWidget: (_, __, ___) => _buildMiniPlaceholderArt(colorScheme),
|
|
||||||
)
|
|
||||||
: _buildDeviceIcon(peekPlayer?.name ?? '', artSize, colorScheme),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Peek track title / device name
|
|
||||||
Positioned(
|
|
||||||
left: titleLeft + peekBaseOffset,
|
|
||||||
top: titleTop,
|
|
||||||
child: SizedBox(
|
|
||||||
width: titleWidth,
|
|
||||||
child: Text(
|
|
||||||
peekTrackName,
|
|
||||||
style: TextStyle(
|
|
||||||
color: textColor,
|
|
||||||
fontSize: titleFontSize,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
height: 1.2,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Peek artist name / device hint
|
|
||||||
Positioned(
|
|
||||||
left: titleLeft + peekBaseOffset,
|
|
||||||
top: artistTop,
|
|
||||||
child: SizedBox(
|
|
||||||
width: titleWidth,
|
|
||||||
child: Text(
|
|
||||||
peekArtistName,
|
|
||||||
style: TextStyle(
|
|
||||||
color: textColor.withOpacity(0.6),
|
|
||||||
fontSize: artistFontSize,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build device icon for non-playing peek player
|
|
||||||
Widget _buildDeviceIcon(String playerName, double size, ColorScheme colorScheme) {
|
|
||||||
final nameLower = playerName.toLowerCase();
|
|
||||||
IconData icon;
|
|
||||||
if (nameLower.contains('phone') || nameLower.contains('ensemble')) {
|
|
||||||
icon = Icons.phone_android_rounded;
|
|
||||||
} else if (nameLower.contains('group') || nameLower.contains('all')) {
|
|
||||||
icon = Icons.speaker_group_rounded;
|
|
||||||
} else if (nameLower.contains('tv') || nameLower.contains('television')) {
|
|
||||||
icon = Icons.tv_rounded;
|
|
||||||
} else if (nameLower.contains('cast') || nameLower.contains('chromecast')) {
|
|
||||||
icon = Icons.cast_rounded;
|
|
||||||
} else {
|
|
||||||
icon = Icons.speaker_rounded;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
child: Center(
|
|
||||||
child: Icon(
|
|
||||||
icon,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
size: size * 0.5,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simplified placeholder for mini player peek
|
|
||||||
Widget _buildMiniPlaceholderArt(ColorScheme colorScheme) {
|
|
||||||
return Container(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
child: Icon(
|
|
||||||
Icons.music_note_rounded,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build peek player progress bar that slides in during swipe
|
/// Build peek player progress bar that slides in during swipe
|
||||||
Widget _buildPeekProgressBar({
|
Widget _buildPeekProgressBar({
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
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';
|
||||||
|
|
||||||
/// Cached color with contrast adjustment
|
/// Cached color with contrast adjustment
|
||||||
/// Avoids expensive HSL conversions during scroll
|
/// Avoids expensive HSL conversions during scroll
|
||||||
@@ -111,13 +116,74 @@ class GlobalPlayerOverlay extends StatefulWidget {
|
|||||||
static void showPlayer() {
|
static void showPlayer() {
|
||||||
_overlayStateKey.currentState?._setHidden(false);
|
_overlayStateKey.currentState?._setHidden(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show the player reveal overlay with bounce animation
|
||||||
|
static void showPlayerReveal() {
|
||||||
|
_overlayStateKey.currentState?._showPlayerReveal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hide the player reveal overlay (no animation - used as callback from overlay)
|
||||||
|
static void hidePlayerReveal() {
|
||||||
|
_overlayStateKey.currentState?._hidePlayerReveal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismiss the player reveal overlay with animation (for back gesture)
|
||||||
|
static void dismissPlayerReveal() {
|
||||||
|
_overlayStateKey.currentState?._dismissPlayerReveal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trigger single bounce on mini player (called when device selector collapses)
|
||||||
|
static void triggerBounce() {
|
||||||
|
_overlayStateKey.currentState?._triggerBounce();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the player reveal is currently visible
|
||||||
|
static bool get isPlayerRevealVisible =>
|
||||||
|
_overlayStateKey.currentState?._isRevealVisible ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||||
with SingleTickerProviderStateMixin {
|
with TickerProviderStateMixin {
|
||||||
late AnimationController _slideController;
|
late AnimationController _slideController;
|
||||||
late Animation<double> _slideAnimation;
|
late Animation<double> _slideAnimation;
|
||||||
|
|
||||||
|
// Single bounce controller for device selector expand/collapse
|
||||||
|
late AnimationController _singleBounceController;
|
||||||
|
|
||||||
|
// Double bounce controller for hint
|
||||||
|
late AnimationController _doubleBounceController;
|
||||||
|
|
||||||
|
// State for player reveal overlay
|
||||||
|
bool _isRevealVisible = false;
|
||||||
|
|
||||||
|
// State for interactive hint mode (blur backdrop + wait for user action)
|
||||||
|
bool _isHintModeActive = false;
|
||||||
|
Timer? _hintBounceTimer;
|
||||||
|
|
||||||
|
// Track if player reveal was triggered from onboarding (for showing extra hints)
|
||||||
|
bool _isOnboardingReveal = false;
|
||||||
|
|
||||||
|
// Bounce offset for mini player (used by both single and double bounce)
|
||||||
|
final _bounceOffsetNotifier = ValueNotifier<double>(0.0);
|
||||||
|
|
||||||
|
// Hint system state
|
||||||
|
// NOTE: AppStartup now handles the settings loading gate. It waits for:
|
||||||
|
// - First-time users: connected + player selected before showing HomeScreen
|
||||||
|
// - Returning users: connected before showing HomeScreen
|
||||||
|
// This eliminates the race condition that caused home screen flash.
|
||||||
|
bool _showHints = true;
|
||||||
|
bool _hasCompletedOnboarding = false; // First-use welcome screen
|
||||||
|
bool _hintTriggered = false; // Prevent multiple triggers per session
|
||||||
|
bool _settingsLoaded = false; // True once settings have been loaded from storage
|
||||||
|
bool _miniPlayerHintsReady = false; // True once connected with player (can show hints)
|
||||||
|
final _hintOpacityNotifier = ValueNotifier<double>(0.0);
|
||||||
|
|
||||||
|
// Welcome content fade-in animation
|
||||||
|
late AnimationController _welcomeFadeController;
|
||||||
|
|
||||||
|
// Key for the reveal overlay
|
||||||
|
final _revealKey = GlobalKey<PlayerRevealOverlayState>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -130,11 +196,135 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
reverseCurve: Curves.easeInCubic,
|
reverseCurve: Curves.easeInCubic,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Single bounce for device selector expand/collapse
|
||||||
|
_singleBounceController = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 400),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
_singleBounceController.addListener(() {
|
||||||
|
final t = Curves.easeOut.transform(_singleBounceController.value);
|
||||||
|
// Single bounce: up to 10px then back to 0
|
||||||
|
if (t < 0.5) {
|
||||||
|
_bounceOffsetNotifier.value = 10.0 * (t * 2); // 0 -> 10
|
||||||
|
} else {
|
||||||
|
_bounceOffsetNotifier.value = 10.0 * ((1.0 - t) * 2); // 10 -> 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hint bounce - single gentle bounce to draw attention
|
||||||
|
_doubleBounceController = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 600),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
_doubleBounceController.addListener(() {
|
||||||
|
final t = Curves.easeOut.transform(_doubleBounceController.value);
|
||||||
|
// Single bounce: up 20px then back down
|
||||||
|
if (t < 0.5) {
|
||||||
|
_bounceOffsetNotifier.value = 20.0 * (t * 2); // 0 -> 20
|
||||||
|
} else {
|
||||||
|
_bounceOffsetNotifier.value = 20.0 * ((1.0 - t) * 2); // 20 -> 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Welcome content fade-in
|
||||||
|
_welcomeFadeController = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 800),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load hint settings and start welcome immediately if needed
|
||||||
|
_loadHintSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadHintSettings() async {
|
||||||
|
_showHints = await SettingsService.getShowHints();
|
||||||
|
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_settingsLoaded = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If first-time user and already connected (AppStartup waited for us),
|
||||||
|
// start welcome screen immediately - no race condition possible
|
||||||
|
if (!_hasCompletedOnboarding && !_hintTriggered) {
|
||||||
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
if (provider.isConnected && provider.selectedPlayer != null) {
|
||||||
|
_miniPlayerHintsReady = true;
|
||||||
|
_startWelcomeScreen();
|
||||||
|
_startMiniPlayerBounce();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
// Check if we should enable mini player hints now that we have connection
|
||||||
|
_checkConnectionForMiniPlayerHints();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check connection state and start welcome screen if appropriate.
|
||||||
|
/// This is called from multiple places:
|
||||||
|
/// 1. didChangeDependencies() - when widget first builds
|
||||||
|
/// 2. _loadHintSettings() - when settings finish loading
|
||||||
|
/// 3. build() - when provider state changes (via post-frame callback)
|
||||||
|
void _checkConnectionForMiniPlayerHints() {
|
||||||
|
if (_hintTriggered || !mounted || !_settingsLoaded) return;
|
||||||
|
if (_hasCompletedOnboarding) return; // Not first-time user
|
||||||
|
|
||||||
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
if (provider.isConnected && provider.selectedPlayer != null) {
|
||||||
|
// Connected with a player - start welcome screen now
|
||||||
|
_miniPlayerHintsReady = true;
|
||||||
|
_startWelcomeScreen();
|
||||||
|
// Start the bounce animation now that mini player is visible
|
||||||
|
_startMiniPlayerBounce();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the bounce animation for mini player hints
|
||||||
|
void _startMiniPlayerBounce() {
|
||||||
|
if (!_isHintModeActive || !mounted) return;
|
||||||
|
_doubleBounceController.reset();
|
||||||
|
_doubleBounceController.forward();
|
||||||
|
|
||||||
|
// Repeat bounce every 2 seconds
|
||||||
|
_hintBounceTimer?.cancel();
|
||||||
|
_hintBounceTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
||||||
|
if (_isHintModeActive && mounted) {
|
||||||
|
_doubleBounceController.reset();
|
||||||
|
_doubleBounceController.forward();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the welcome screen with fade-in animation
|
||||||
|
void _startWelcomeScreen() {
|
||||||
|
if (_hintTriggered) return;
|
||||||
|
_hintTriggered = true;
|
||||||
|
|
||||||
|
// Activate hint mode immediately (blur backdrop visible)
|
||||||
|
setState(() {
|
||||||
|
_isHintModeActive = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fade in welcome content (logo + title)
|
||||||
|
_welcomeFadeController.forward();
|
||||||
|
|
||||||
|
// Mini player bounce is started separately once connected via _startMiniPlayerBounce()
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_hintBounceTimer?.cancel();
|
||||||
_slideController.dispose();
|
_slideController.dispose();
|
||||||
|
_singleBounceController.dispose();
|
||||||
|
_doubleBounceController.dispose();
|
||||||
|
_welcomeFadeController.dispose();
|
||||||
|
_bounceOffsetNotifier.dispose();
|
||||||
|
_hintOpacityNotifier.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,12 +336,121 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showPlayerReveal() {
|
||||||
|
if (_isRevealVisible) return;
|
||||||
|
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||||
|
GlobalPlayerOverlay.collapsePlayer();
|
||||||
|
}
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
|
||||||
|
// Track if coming from hint mode (for onboarding hints in player selector)
|
||||||
|
final wasInHintMode = _isHintModeActive;
|
||||||
|
|
||||||
|
// End hint mode if active (user learned the gesture!)
|
||||||
|
_hintBounceTimer?.cancel();
|
||||||
|
_hintBounceTimer = null;
|
||||||
|
_hintOpacityNotifier.value = 0.0;
|
||||||
|
_isHintModeActive = false;
|
||||||
|
|
||||||
|
// Mark onboarding as completed if coming from welcome screen
|
||||||
|
if (wasInHintMode) {
|
||||||
|
SettingsService.setHasCompletedOnboarding(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger single bounce on expand
|
||||||
|
_singleBounceController.reset();
|
||||||
|
_singleBounceController.forward();
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isRevealVisible = true;
|
||||||
|
_isOnboardingReveal = wasInHintMode;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _hidePlayerReveal() {
|
||||||
|
if (!_isRevealVisible) return;
|
||||||
|
_bounceOffsetNotifier.value = 0;
|
||||||
|
setState(() {
|
||||||
|
_isRevealVisible = false;
|
||||||
|
_isOnboardingReveal = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismiss with animation (for back gesture) - calls overlay's dismiss method
|
||||||
|
void _dismissPlayerReveal() {
|
||||||
|
if (!_isRevealVisible) return;
|
||||||
|
// Call the overlay's dismiss method which has the slide animation
|
||||||
|
_revealKey.currentState?.dismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trigger single bounce on mini player (called when device selector collapses)
|
||||||
|
void _triggerBounce() {
|
||||||
|
_singleBounceController.reset();
|
||||||
|
_singleBounceController.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End hint mode (called when user taps skip button)
|
||||||
|
void _endHintMode() {
|
||||||
|
if (!_isHintModeActive) return;
|
||||||
|
_hintBounceTimer?.cancel();
|
||||||
|
_hintBounceTimer = null;
|
||||||
|
_hintOpacityNotifier.value = 0.0;
|
||||||
|
_bounceOffsetNotifier.value = 0.0;
|
||||||
|
// Mark onboarding as completed (first use only)
|
||||||
|
SettingsService.setHasCompletedOnboarding(true);
|
||||||
|
setState(() {
|
||||||
|
_isHintModeActive = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
return Stack(
|
// Watch provider to trigger rebuilds when connection state changes
|
||||||
|
final provider = context.watch<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
// Compute whether this is a first-time user ready for welcome screen.
|
||||||
|
// This is SYNCHRONOUS - no setState needed - so it works in the same frame.
|
||||||
|
final isFirstTimeUserReady = _settingsLoaded &&
|
||||||
|
!_hasCompletedOnboarding &&
|
||||||
|
provider.isConnected &&
|
||||||
|
provider.selectedPlayer != null;
|
||||||
|
|
||||||
|
// Show welcome backdrop if:
|
||||||
|
// 1. Already in hint mode (_isHintModeActive), OR
|
||||||
|
// 2. First-time user ready but hint mode not started yet (!_hintTriggered)
|
||||||
|
// This ensures backdrop appears IMMEDIATELY when HomeScreen renders,
|
||||||
|
// preventing the flash. Welcome content fades in after via animation.
|
||||||
|
final shouldShowWelcomeBackdrop = _isHintModeActive ||
|
||||||
|
(isFirstTimeUserReady && !_hintTriggered);
|
||||||
|
|
||||||
|
// Trigger hint mode if conditions are met (for animations, bounce, etc)
|
||||||
|
if (isFirstTimeUserReady && !_hintTriggered) {
|
||||||
|
// Schedule for post-frame to avoid setState during build
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted && !_hintTriggered) {
|
||||||
|
_miniPlayerHintsReady = true;
|
||||||
|
_startWelcomeScreen();
|
||||||
|
_startMiniPlayerBounce();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle back gesture at top level - dismiss hint mode or device list if visible
|
||||||
|
return PopScope(
|
||||||
|
canPop: !_isRevealVisible && !_isHintModeActive,
|
||||||
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
|
if (!didPop) {
|
||||||
|
if (_isHintModeActive) {
|
||||||
|
_endHintMode();
|
||||||
|
} else if (_isRevealVisible) {
|
||||||
|
_dismissPlayerReveal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// The main app content (Navigator, screens, etc.)
|
// The main app content (Navigator, screens, etc.)
|
||||||
// Add bottom padding to account for bottom nav + mini player
|
// Add bottom padding to account for bottom nav + mini player
|
||||||
@@ -160,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
|
||||||
@@ -256,35 +559,178 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
// Blur backdrop for device selector (reveal mode) - static, no animation
|
||||||
// Global player overlay - slides down when hidden, renders ON TOP of bottom nav
|
if (_isRevealVisible && !_isHintModeActive)
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: _dismissPlayerReveal,
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
|
||||||
|
child: Container(
|
||||||
|
color: colorScheme.surface.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Welcome/hint backdrop - shows IMMEDIATELY when first-time user is ready.
|
||||||
|
// Uses computed `shouldShowWelcomeBackdrop` which is true BEFORE _isHintModeActive,
|
||||||
|
// preventing the 1-frame gap where home screen would be visible.
|
||||||
|
if (shouldShowWelcomeBackdrop)
|
||||||
|
Positioned.fill(
|
||||||
|
child: TweenAnimationBuilder<double>(
|
||||||
|
// Hold solid for 2s, then fade to 0.5 over 1s
|
||||||
|
tween: Tween<double>(begin: 1.0, end: 0.5),
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
curve: const Interval(0.67, 1.0, curve: Curves.easeOut),
|
||||||
|
builder: (context, opacity, child) {
|
||||||
|
return BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
|
||||||
|
child: Container(
|
||||||
|
// Use same dark color as connection screen for seamless transition
|
||||||
|
color: const Color(0xFF1a1a1a).withOpacity(opacity),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Welcome message during hint mode - two positioned sections with fade-in
|
||||||
|
// Top section: Logo and Welcome title (stays fixed at top area)
|
||||||
|
if (_isHintModeActive)
|
||||||
|
Positioned(
|
||||||
|
left: 24,
|
||||||
|
right: 24,
|
||||||
|
// Position logo section high up - about 1/3 from top
|
||||||
|
top: MediaQuery.of(context).size.height * 0.15,
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: CurvedAnimation(
|
||||||
|
parent: _welcomeFadeController,
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Ensemble logo - same as settings screen
|
||||||
|
Image.asset(
|
||||||
|
'assets/images/ensemble_icon_transparent.png',
|
||||||
|
width: MediaQuery.of(context).size.width * 0.5,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
// Welcome title
|
||||||
|
Text(
|
||||||
|
S.of(context)!.welcomeToEnsemble,
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bottom section: Hint text and Skip button (only show once mini player is visible)
|
||||||
|
if (_isHintModeActive && _miniPlayerHintsReady)
|
||||||
|
Positioned(
|
||||||
|
left: 24,
|
||||||
|
right: 24,
|
||||||
|
// Position so skip button is ~32px above mini player, matching skip-to-miniplayer gap
|
||||||
|
bottom: BottomSpacing.navBarHeight + BottomSpacing.miniPlayerHeight + MediaQuery.of(context).padding.bottom + 32,
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: CurvedAnimation(
|
||||||
|
parent: _welcomeFadeController,
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Hint text
|
||||||
|
Text(
|
||||||
|
S.of(context)!.welcomeMessage,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
// Skip button - same gap above as below to mini player
|
||||||
|
TextButton(
|
||||||
|
onPressed: _endHintMode,
|
||||||
|
child: Text(
|
||||||
|
S.of(context)!.skip,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withOpacity(0.7),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Player reveal overlay - renders BELOW mini player so cards slide behind it
|
||||||
|
if (_isRevealVisible)
|
||||||
|
PlayerRevealOverlay(
|
||||||
|
key: _revealKey,
|
||||||
|
onDismiss: _hidePlayerReveal,
|
||||||
|
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).padding.bottom + 12,
|
||||||
|
miniPlayerHeight: 64,
|
||||||
|
showOnboardingHints: _isOnboardingReveal,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Global player overlay - renders ON TOP so cards slide behind it
|
||||||
// Use Selector instead of Consumer to avoid rebuilds during animation
|
// 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();
|
||||||
}
|
}
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: _slideAnimation,
|
// Combine slide, bounce, and hint animations with ValueListenableBuilders
|
||||||
child: ExpandablePlayer(key: globalPlayerKey, slideOffset: 0),
|
// This prevents full widget tree rebuilds - only ExpandablePlayer updates
|
||||||
builder: (context, staticChild) {
|
return ValueListenableBuilder<double>(
|
||||||
// Only slideOffset changes during animation, player widget stays same
|
valueListenable: _bounceOffsetNotifier,
|
||||||
return ExpandablePlayer(
|
builder: (context, bounceOffset, _) {
|
||||||
key: globalPlayerKey,
|
return ValueListenableBuilder<double>(
|
||||||
slideOffset: _slideAnimation.value,
|
valueListenable: _hintOpacityNotifier,
|
||||||
|
builder: (context, hintOpacity, _) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _slideAnimation,
|
||||||
|
builder: (context, _) {
|
||||||
|
return ExpandablePlayer(
|
||||||
|
key: globalPlayerKey,
|
||||||
|
slideOffset: _slideAnimation.value,
|
||||||
|
bounceOffset: bounceOffset,
|
||||||
|
onRevealPlayers: _showPlayerReveal,
|
||||||
|
isDeviceRevealVisible: _isRevealVisible,
|
||||||
|
isHintVisible: hintOpacity > 0,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../theme/design_tokens.dart';
|
import 'mini_player_content.dart';
|
||||||
|
|
||||||
/// A compact device selector bar shown when no track is playing
|
/// A compact device selector bar shown when no track is playing
|
||||||
class DeviceSelectorBar extends StatelessWidget {
|
class DeviceSelectorBar extends StatelessWidget {
|
||||||
@@ -35,6 +35,8 @@ class DeviceSelectorBar extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final swipeHint = hasMultiplePlayers ? S.of(context)!.swipeToSwitchDevice : null;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onHorizontalDragStart: hasMultiplePlayers ? onHorizontalDragStart : null,
|
onHorizontalDragStart: hasMultiplePlayers ? onHorizontalDragStart : null,
|
||||||
onHorizontalDragUpdate: hasMultiplePlayers ? onHorizontalDragUpdate : null,
|
onHorizontalDragUpdate: hasMultiplePlayers ? onHorizontalDragUpdate : null,
|
||||||
@@ -53,50 +55,19 @@ class DeviceSelectorBar extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Peek player content (shows when dragging)
|
// Peek player content (shows when dragging)
|
||||||
if (slideOffset.abs() > 0.01 && peekPlayer != null)
|
if (slideOffset.abs() > 0.01 && peekPlayer != null)
|
||||||
_buildPeekContent(context),
|
_buildPeekContent(context, swipeHint),
|
||||||
|
|
||||||
// Current player content
|
// Current player content
|
||||||
Transform.translate(
|
MiniPlayerContent(
|
||||||
offset: Offset(slideOffset * width, 0),
|
primaryText: selectedPlayer.name,
|
||||||
child: Padding(
|
secondaryText: swipeHint,
|
||||||
padding: Spacing.paddingH16,
|
imageUrl: null,
|
||||||
child: Row(
|
playerName: selectedPlayer.name,
|
||||||
children: [
|
backgroundColor: backgroundColor,
|
||||||
Icon(
|
textColor: textColor,
|
||||||
_getPlayerIcon(selectedPlayer.name),
|
width: width,
|
||||||
color: textColor,
|
slideOffset: slideOffset,
|
||||||
size: IconSizes.md,
|
isHint: swipeHint != null,
|
||||||
),
|
|
||||||
Spacing.hGap12,
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
selectedPlayer.name,
|
|
||||||
style: TextStyle(
|
|
||||||
color: textColor,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
if (hasMultiplePlayers)
|
|
||||||
Text(
|
|
||||||
S.of(context)!.swipeToSwitchDevice,
|
|
||||||
style: TextStyle(
|
|
||||||
color: textColor.withOpacity(0.6),
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -107,7 +78,7 @@ class DeviceSelectorBar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build peek player content that slides in from the edge
|
/// Build peek player content that slides in from the edge
|
||||||
Widget _buildPeekContent(BuildContext context) {
|
Widget _buildPeekContent(BuildContext context, String? swipeHint) {
|
||||||
final isFromRight = slideOffset < 0;
|
final isFromRight = slideOffset < 0;
|
||||||
final peekProgress = slideOffset.abs();
|
final peekProgress = slideOffset.abs();
|
||||||
|
|
||||||
@@ -118,61 +89,17 @@ class DeviceSelectorBar extends StatelessWidget {
|
|||||||
|
|
||||||
return Transform.translate(
|
return Transform.translate(
|
||||||
offset: Offset(peekBaseOffset, 0),
|
offset: Offset(peekBaseOffset, 0),
|
||||||
child: Padding(
|
child: MiniPlayerContent(
|
||||||
padding: Spacing.paddingH16,
|
primaryText: peekPlayer.name,
|
||||||
child: Row(
|
secondaryText: swipeHint,
|
||||||
children: [
|
imageUrl: null,
|
||||||
Icon(
|
playerName: peekPlayer.name,
|
||||||
_getPlayerIcon(peekPlayer.name),
|
backgroundColor: backgroundColor,
|
||||||
color: textColor,
|
textColor: textColor,
|
||||||
size: IconSizes.md,
|
width: width,
|
||||||
),
|
slideOffset: 0, // No additional slide - Transform handles positioning
|
||||||
Spacing.hGap12,
|
isHint: swipeHint != null,
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
peekPlayer.name,
|
|
||||||
style: TextStyle(
|
|
||||||
color: textColor,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
if (hasMultiplePlayers)
|
|
||||||
Text(
|
|
||||||
S.of(context)!.swipeToSwitchDevice,
|
|
||||||
style: TextStyle(
|
|
||||||
color: textColor.withOpacity(0.6),
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get appropriate icon for player based on name
|
|
||||||
IconData _getPlayerIcon(String playerName) {
|
|
||||||
final nameLower = playerName.toLowerCase();
|
|
||||||
if (nameLower.contains('phone') || nameLower.contains('ensemble')) {
|
|
||||||
return Icons.phone_android_rounded;
|
|
||||||
} else if (nameLower.contains('group')) {
|
|
||||||
return Icons.speaker_group_rounded;
|
|
||||||
} else if (nameLower.contains('tv') || nameLower.contains('television')) {
|
|
||||||
return Icons.tv_rounded;
|
|
||||||
} else if (nameLower.contains('cast') || nameLower.contains('chromecast')) {
|
|
||||||
return Icons.cast_rounded;
|
|
||||||
} else {
|
|
||||||
return Icons.speaker_rounded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
|
||||||
|
/// Shared constants for mini player layout
|
||||||
|
class MiniPlayerLayout {
|
||||||
|
static const double height = 64.0;
|
||||||
|
static const double artSize = 64.0;
|
||||||
|
static const double textLeftOffset = 10.0; // Gap between art and text
|
||||||
|
static const double textLeft = artSize + textLeftOffset; // 74px
|
||||||
|
static const double primaryTop = 13.0;
|
||||||
|
static const double secondaryTop = 33.0;
|
||||||
|
static const double textRightPadding = 12.0;
|
||||||
|
static const double iconSize = 28.0;
|
||||||
|
static const double iconOpacity = 0.4;
|
||||||
|
static const double secondaryTextOpacity = 0.6;
|
||||||
|
static const double primaryFontSize = 16.0;
|
||||||
|
static const double secondaryFontSize = 14.0;
|
||||||
|
static const FontWeight primaryFontWeight = FontWeight.w500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified mini player content widget used by:
|
||||||
|
/// - DeviceSelectorBar (non-playing state)
|
||||||
|
/// - ExpandablePlayer collapsed state (playing state)
|
||||||
|
/// - Peek content during swipe gestures
|
||||||
|
class MiniPlayerContent extends StatelessWidget {
|
||||||
|
/// Primary text line (track name or device name)
|
||||||
|
final String primaryText;
|
||||||
|
|
||||||
|
/// Secondary text line (artist name or "Swipe to switch device")
|
||||||
|
/// If null, primary text will be vertically centered
|
||||||
|
final String? secondaryText;
|
||||||
|
|
||||||
|
/// Album art URL - if null, shows device icon
|
||||||
|
final String? imageUrl;
|
||||||
|
|
||||||
|
/// Player name for icon selection when no image
|
||||||
|
final String playerName;
|
||||||
|
|
||||||
|
/// Background color for the content area
|
||||||
|
final Color backgroundColor;
|
||||||
|
|
||||||
|
/// Text color
|
||||||
|
final Color textColor;
|
||||||
|
|
||||||
|
/// Width of the content area
|
||||||
|
final double width;
|
||||||
|
|
||||||
|
/// Horizontal slide offset for swipe animation (-1 to 1)
|
||||||
|
final double slideOffset;
|
||||||
|
|
||||||
|
/// Whether secondary text is a hint (shows lightbulb icon)
|
||||||
|
final bool isHint;
|
||||||
|
|
||||||
|
/// Whether to show the progress bar overlay (for playing state)
|
||||||
|
final bool showProgress;
|
||||||
|
|
||||||
|
/// Progress value 0.0 to 1.0 (only used if showProgress is true)
|
||||||
|
final double progress;
|
||||||
|
|
||||||
|
const MiniPlayerContent({
|
||||||
|
super.key,
|
||||||
|
required this.primaryText,
|
||||||
|
this.secondaryText,
|
||||||
|
this.imageUrl,
|
||||||
|
required this.playerName,
|
||||||
|
required this.backgroundColor,
|
||||||
|
required this.textColor,
|
||||||
|
required this.width,
|
||||||
|
this.slideOffset = 0.0,
|
||||||
|
this.isHint = false,
|
||||||
|
this.showProgress = false,
|
||||||
|
this.progress = 0.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hasSecondaryLine = secondaryText != null && secondaryText!.isNotEmpty;
|
||||||
|
final slidePixels = slideOffset * width;
|
||||||
|
|
||||||
|
// Calculate text width (leaves room for right padding)
|
||||||
|
final textWidth = width - MiniPlayerLayout.textLeft - MiniPlayerLayout.textRightPadding;
|
||||||
|
|
||||||
|
// Darkened background for icon area (matches DeviceSelectorBar)
|
||||||
|
final iconAreaBackground = Color.lerp(backgroundColor, Colors.black, 0.15)!;
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: width,
|
||||||
|
height: MiniPlayerLayout.height,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
children: [
|
||||||
|
// Progress bar background (only for playing state)
|
||||||
|
if (showProgress && progress > 0)
|
||||||
|
Positioned(
|
||||||
|
left: MiniPlayerLayout.artSize + slidePixels,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: (width - MiniPlayerLayout.artSize) * progress,
|
||||||
|
child: Container(color: backgroundColor),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Art / Icon area
|
||||||
|
Positioned(
|
||||||
|
left: slidePixels,
|
||||||
|
top: 0,
|
||||||
|
child: SizedBox(
|
||||||
|
width: MiniPlayerLayout.artSize,
|
||||||
|
height: MiniPlayerLayout.artSize,
|
||||||
|
child: imageUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 256,
|
||||||
|
memCacheHeight: 256,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => _buildIconArea(iconAreaBackground),
|
||||||
|
errorWidget: (_, __, ___) => _buildIconArea(iconAreaBackground),
|
||||||
|
)
|
||||||
|
: _buildIconArea(iconAreaBackground),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Primary text line
|
||||||
|
Positioned(
|
||||||
|
left: MiniPlayerLayout.textLeft + slidePixels,
|
||||||
|
top: hasSecondaryLine
|
||||||
|
? MiniPlayerLayout.primaryTop
|
||||||
|
: (MiniPlayerLayout.height - MiniPlayerLayout.primaryFontSize) / 2,
|
||||||
|
right: MiniPlayerLayout.textRightPadding - slidePixels,
|
||||||
|
child: Text(
|
||||||
|
primaryText,
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
|
fontSize: MiniPlayerLayout.primaryFontSize,
|
||||||
|
fontWeight: MiniPlayerLayout.primaryFontWeight,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Secondary text line (with optional hint icon)
|
||||||
|
if (hasSecondaryLine)
|
||||||
|
Positioned(
|
||||||
|
left: MiniPlayerLayout.textLeft + slidePixels,
|
||||||
|
top: MiniPlayerLayout.secondaryTop,
|
||||||
|
right: MiniPlayerLayout.textRightPadding - slidePixels,
|
||||||
|
child: isHint
|
||||||
|
? Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.lightbulb_outline,
|
||||||
|
size: 14,
|
||||||
|
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
secondaryText!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
|
||||||
|
fontSize: MiniPlayerLayout.secondaryFontSize,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
secondaryText!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
|
||||||
|
fontSize: MiniPlayerLayout.secondaryFontSize,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the icon area with device-appropriate icon
|
||||||
|
Widget _buildIconArea(Color background) {
|
||||||
|
return Container(
|
||||||
|
color: background,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
_getPlayerIcon(playerName),
|
||||||
|
color: textColor.withOpacity(MiniPlayerLayout.iconOpacity),
|
||||||
|
size: MiniPlayerLayout.iconSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get appropriate icon based on player name
|
||||||
|
static IconData _getPlayerIcon(String playerName) {
|
||||||
|
final nameLower = playerName.toLowerCase();
|
||||||
|
if (nameLower.contains('phone') || nameLower.contains('ensemble') || nameLower.contains('mobile')) {
|
||||||
|
return Icons.phone_android_rounded;
|
||||||
|
} else if (nameLower.contains('group') || nameLower.contains('sync') || nameLower.contains('all')) {
|
||||||
|
return Icons.speaker_group_rounded;
|
||||||
|
} else if (nameLower.contains('tv') || nameLower.contains('television')) {
|
||||||
|
return Icons.tv_rounded;
|
||||||
|
} else if (nameLower.contains('cast') || nameLower.contains('chromecast')) {
|
||||||
|
return Icons.cast_rounded;
|
||||||
|
} else {
|
||||||
|
return Icons.speaker_rounded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import '../../providers/music_assistant_provider.dart';
|
||||||
|
import '../../theme/design_tokens.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// A player card that matches the mini player's visual style.
|
||||||
|
/// Used in the player reveal overlay when swiping down or tapping the device button.
|
||||||
|
class PlayerCard extends StatelessWidget {
|
||||||
|
final dynamic player;
|
||||||
|
final dynamic trackInfo;
|
||||||
|
final String? albumArtUrl;
|
||||||
|
final bool isSelected;
|
||||||
|
final bool isPlaying;
|
||||||
|
final bool isGrouped;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final Color textColor;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback? onLongPress;
|
||||||
|
final VoidCallback? onPlayPause;
|
||||||
|
final VoidCallback? onSkipNext;
|
||||||
|
final VoidCallback? onPower;
|
||||||
|
|
||||||
|
// Pastel yellow for grouped players
|
||||||
|
static const Color groupBorderColor = Color(0xFFFFF59D);
|
||||||
|
|
||||||
|
const PlayerCard({
|
||||||
|
super.key,
|
||||||
|
required this.player,
|
||||||
|
this.trackInfo,
|
||||||
|
this.albumArtUrl,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.isPlaying,
|
||||||
|
this.isGrouped = false,
|
||||||
|
required this.backgroundColor,
|
||||||
|
required this.textColor,
|
||||||
|
required this.onTap,
|
||||||
|
this.onLongPress,
|
||||||
|
this.onPlayPause,
|
||||||
|
this.onSkipNext,
|
||||||
|
this.onPower,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Match mini player dimensions
|
||||||
|
const double cardHeight = Dimensions.miniPlayerHeight; // 64px
|
||||||
|
const double artSize = cardHeight;
|
||||||
|
const double borderRadius = Radii.xl; // 16px - same as mini player
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
child: Container(
|
||||||
|
height: cardHeight,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
border: isGrouped
|
||||||
|
? Border.all(color: groupBorderColor, width: 1.5)
|
||||||
|
: null,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.2),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Album art or speaker icon - same size for consistent text alignment
|
||||||
|
SizedBox(
|
||||||
|
width: artSize,
|
||||||
|
height: artSize,
|
||||||
|
child: albumArtUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: albumArtUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 128,
|
||||||
|
memCacheHeight: 128,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => _buildSpeakerIcon(),
|
||||||
|
errorWidget: (_, __, ___) => _buildSpeakerIcon(),
|
||||||
|
)
|
||||||
|
: _buildSpeakerIcon(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Player info
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Player name
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
// Status indicator
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
margin: const EdgeInsets.only(right: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: _getStatusColor(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
player.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontFamily: 'Roboto',
|
||||||
|
decoration: TextDecoration.none,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
// Track name or status
|
||||||
|
Text(
|
||||||
|
_getSubtitle(context),
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor.withOpacity(0.6),
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Roboto',
|
||||||
|
decoration: TextDecoration.none,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Transport controls - compact sizing to align with mini player
|
||||||
|
// Play/Pause and Next only shown when powered with content
|
||||||
|
if (player.available && player.powered && trackInfo != null) ...[
|
||||||
|
// Play/Pause - slight nudge right
|
||||||
|
Transform.translate(
|
||||||
|
offset: const Offset(3, 0),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
||||||
|
color: textColor,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
onPressed: onPlayPause,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Skip next - nudged right to close gap with power
|
||||||
|
Transform.translate(
|
||||||
|
offset: const Offset(6, 0),
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.skip_next_rounded,
|
||||||
|
color: textColor,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
onPressed: onSkipNext,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// Power button - smallest
|
||||||
|
if (player.available)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.power_settings_new_rounded,
|
||||||
|
color: player.powered ? textColor : textColor.withOpacity(0.5),
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
onPressed: onPower,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSpeakerIcon() {
|
||||||
|
// Darker shade of the card background to match album art square
|
||||||
|
final iconBgColor = Color.lerp(backgroundColor, Colors.black, 0.15)!;
|
||||||
|
return Container(
|
||||||
|
color: iconBgColor,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.speaker_rounded,
|
||||||
|
color: textColor.withOpacity(0.4),
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getStatusColor() {
|
||||||
|
if (!player.available) {
|
||||||
|
return Colors.grey.withOpacity(0.5);
|
||||||
|
}
|
||||||
|
if (!player.powered) {
|
||||||
|
return Colors.grey;
|
||||||
|
}
|
||||||
|
if (isPlaying) {
|
||||||
|
return Colors.green;
|
||||||
|
}
|
||||||
|
if (trackInfo != null) {
|
||||||
|
return Colors.orange; // Has content but paused
|
||||||
|
}
|
||||||
|
return Colors.grey.shade400; // Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getSubtitle(BuildContext context) {
|
||||||
|
if (!player.available) {
|
||||||
|
return S.of(context)!.playerStateUnavailable;
|
||||||
|
}
|
||||||
|
if (!player.powered) {
|
||||||
|
return S.of(context)!.playerStateOff;
|
||||||
|
}
|
||||||
|
if (trackInfo != null) {
|
||||||
|
return trackInfo.name ?? S.of(context)!.playerStateIdle;
|
||||||
|
}
|
||||||
|
return S.of(context)!.playerStateIdle;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,14 +55,13 @@ class PlayerControls extends StatelessWidget {
|
|||||||
mainAxisAlignment: isExpanded ? MainAxisAlignment.center : MainAxisAlignment.end,
|
mainAxisAlignment: isExpanded ? MainAxisAlignment.center : MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
// Shuffle (expanded only)
|
// Shuffle (expanded only)
|
||||||
if (isExpanded)
|
// GPU PERF: Use color alpha instead of Opacity to avoid saveLayer
|
||||||
Opacity(
|
if (isExpanded && expandedElementsOpacity > 0.1)
|
||||||
opacity: expandedElementsOpacity,
|
_buildSecondaryButton(
|
||||||
child: _buildSecondaryButton(
|
icon: Icons.shuffle_rounded,
|
||||||
icon: Icons.shuffle_rounded,
|
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
|
||||||
color: shuffle == true ? primaryColor : textColor.withOpacity(0.5),
|
.withOpacity(expandedElementsOpacity),
|
||||||
onPressed: isLoadingQueue ? null : onToggleShuffle,
|
onPressed: isLoadingQueue ? null : onToggleShuffle,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
|
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
|
||||||
|
|
||||||
@@ -90,17 +89,16 @@ class PlayerControls extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Repeat (expanded only)
|
// Repeat (expanded only)
|
||||||
|
// GPU PERF: Use color alpha instead of Opacity to avoid saveLayer
|
||||||
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
|
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
|
||||||
if (isExpanded)
|
if (isExpanded && expandedElementsOpacity > 0.1)
|
||||||
Opacity(
|
_buildSecondaryButton(
|
||||||
opacity: expandedElementsOpacity,
|
icon: repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
|
||||||
child: _buildSecondaryButton(
|
color: (repeatMode != null && repeatMode != 'off'
|
||||||
icon: repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
|
? primaryColor
|
||||||
color: repeatMode != null && repeatMode != 'off'
|
: textColor.withOpacity(0.5))
|
||||||
? primaryColor
|
.withOpacity(expandedElementsOpacity),
|
||||||
: textColor.withOpacity(0.5),
|
onPressed: isLoadingQueue ? null : onCycleRepeat,
|
||||||
onPressed: isLoadingQueue ? null : onCycleRepeat,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,498 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../providers/music_assistant_provider.dart';
|
||||||
|
import '../../services/settings_service.dart';
|
||||||
|
import '../../theme/design_tokens.dart';
|
||||||
|
import '../../theme/theme_provider.dart';
|
||||||
|
import '../../theme/palette_helper.dart';
|
||||||
|
import '../../constants/timings.dart';
|
||||||
|
import '../global_player_overlay.dart';
|
||||||
|
import 'player_card.dart';
|
||||||
|
|
||||||
|
/// Overlay that reveals all available players with a staggered slide-up animation.
|
||||||
|
/// Triggered by swiping down on the mini player or tapping the device button.
|
||||||
|
class PlayerRevealOverlay extends StatefulWidget {
|
||||||
|
final VoidCallback onDismiss;
|
||||||
|
final double miniPlayerBottom; // Bottom position of mini player
|
||||||
|
final double miniPlayerHeight;
|
||||||
|
final bool showOnboardingHints; // Show additional hints for first-time users
|
||||||
|
|
||||||
|
const PlayerRevealOverlay({
|
||||||
|
super.key,
|
||||||
|
required this.onDismiss,
|
||||||
|
required this.miniPlayerBottom,
|
||||||
|
required this.miniPlayerHeight,
|
||||||
|
this.showOnboardingHints = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlayerRevealOverlay> createState() => PlayerRevealOverlayState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
late AnimationController _revealController;
|
||||||
|
late Animation<double> _revealAnimation;
|
||||||
|
late Animation<double> _backdropAnimation;
|
||||||
|
|
||||||
|
Timer? _refreshTimer;
|
||||||
|
|
||||||
|
// Track vertical drag for dismissal
|
||||||
|
double _dragOffset = 0;
|
||||||
|
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
|
||||||
|
final Map<String, ColorScheme?> _playerColors = {};
|
||||||
|
|
||||||
|
// Hint system
|
||||||
|
bool _showHints = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
// Load hint settings
|
||||||
|
SettingsService.getShowHints().then((value) {
|
||||||
|
if (mounted) setState(() => _showHints = value);
|
||||||
|
});
|
||||||
|
|
||||||
|
_revealController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Quick smooth animation with subtle overshoot
|
||||||
|
_revealAnimation = CurvedAnimation(
|
||||||
|
parent: _revealController,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
reverseCurve: Curves.easeInCubic,
|
||||||
|
);
|
||||||
|
|
||||||
|
_backdropAnimation = CurvedAnimation(
|
||||||
|
parent: _revealController,
|
||||||
|
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Preload all player track info and extract colors
|
||||||
|
_preloadColorsForPlayers();
|
||||||
|
|
||||||
|
// Start reveal animation
|
||||||
|
_revealController.duration = const Duration(milliseconds: 200);
|
||||||
|
_revealController.forward();
|
||||||
|
|
||||||
|
// Auto-refresh player data
|
||||||
|
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) {
|
||||||
|
if (mounted) {
|
||||||
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
provider.refreshPlayers();
|
||||||
|
provider.preloadAllPlayerTracks().then((_) => _preloadColorsForPlayers());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract accent colors from album art for each player
|
||||||
|
Future<void> _preloadColorsForPlayers() async {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
await maProvider.preloadAllPlayerTracks();
|
||||||
|
|
||||||
|
final players = maProvider.availablePlayers;
|
||||||
|
for (final player in players) {
|
||||||
|
if (_playerColors.containsKey(player.playerId)) continue;
|
||||||
|
|
||||||
|
final track = maProvider.getCachedTrackForPlayer(player.playerId);
|
||||||
|
if (track == null) continue;
|
||||||
|
|
||||||
|
final imageUrl = maProvider.getImageUrl(track, size: 128);
|
||||||
|
if (imageUrl == null) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final imageProvider = NetworkImage(imageUrl);
|
||||||
|
final schemes = await PaletteHelper.extractColorSchemes(imageProvider);
|
||||||
|
if (mounted && schemes != null) {
|
||||||
|
final colorScheme = isDark ? schemes.$2 : schemes.$1;
|
||||||
|
setState(() {
|
||||||
|
_playerColors[player.playerId] = colorScheme;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore color extraction errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_revealController.dispose();
|
||||||
|
_refreshTimer?.cancel();
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Animate reveal (called from parent)
|
||||||
|
void reveal() {
|
||||||
|
_revealController.duration = const Duration(milliseconds: 200);
|
||||||
|
_revealController.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Animate dismissal and call callback when done
|
||||||
|
void dismiss() {
|
||||||
|
// Trigger single bounce on mini player when cards are ~70% collapsed
|
||||||
|
// This creates the effect of cards "landing" on the mini player
|
||||||
|
bool bounceFired = false;
|
||||||
|
void checkBounce() {
|
||||||
|
if (!bounceFired && _revealController.value < 0.3) {
|
||||||
|
bounceFired = true;
|
||||||
|
GlobalPlayerOverlay.triggerBounce();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_revealController.duration = const Duration(milliseconds: 150);
|
||||||
|
_revealController.addListener(checkBounce);
|
||||||
|
_revealController.reverse().then((_) {
|
||||||
|
_revealController.removeListener(checkBounce);
|
||||||
|
widget.onDismiss();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
||||||
|
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(() {
|
||||||
|
_isDragging = true;
|
||||||
|
_dragOffset += delta;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleVerticalDragEnd(DragEndDetails details) {
|
||||||
|
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
|
||||||
|
if (_dragOffset > 100 || velocity > 500) {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
// Reset drag offset before dismiss so animation starts from correct position
|
||||||
|
setState(() {
|
||||||
|
_isDragging = false;
|
||||||
|
_dragOffset = 0;
|
||||||
|
});
|
||||||
|
dismiss();
|
||||||
|
} else {
|
||||||
|
// Spring back
|
||||||
|
setState(() {
|
||||||
|
_isDragging = false;
|
||||||
|
_dragOffset = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final themeProvider = context.watch<ThemeProvider>();
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
// Back gesture is handled at GlobalPlayerOverlay level
|
||||||
|
return Consumer<MusicAssistantProvider>(
|
||||||
|
builder: (context, maProvider, child) {
|
||||||
|
final allPlayers = maProvider.availablePlayers;
|
||||||
|
final selectedPlayer = maProvider.selectedPlayer;
|
||||||
|
final currentTrack = maProvider.currentTrack;
|
||||||
|
|
||||||
|
// Filter out the selected player - it's already shown in the mini player
|
||||||
|
final players = allPlayers
|
||||||
|
.where((p) => p.playerId != selectedPlayer?.playerId)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Default card colors - used when player doesn't have extracted colors
|
||||||
|
final defaultBgColor = colorScheme.primaryContainer;
|
||||||
|
final defaultTextColor = colorScheme.onPrimaryContainer;
|
||||||
|
|
||||||
|
// Calculate total stack height for animation
|
||||||
|
const cardHeight = 64.0;
|
||||||
|
const cardSpacing = 12.0;
|
||||||
|
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(
|
||||||
|
animation: _revealAnimation,
|
||||||
|
builder: (context, child) {
|
||||||
|
final t = _revealAnimation.value;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// Backdrop - tap to dismiss (darkness handled by GlobalPlayerOverlay)
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: dismiss,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: const SizedBox.expand(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Player cards - positioned above mini player
|
||||||
|
Positioned(
|
||||||
|
left: 12,
|
||||||
|
right: 12,
|
||||||
|
bottom: widget.miniPlayerBottom + widget.miniPlayerHeight + 8 - _dragOffset,
|
||||||
|
child: GestureDetector(
|
||||||
|
onVerticalDragUpdate: _handleVerticalDragUpdate,
|
||||||
|
onVerticalDragEnd: _handleVerticalDragEnd,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Hint above player list - different for onboarding vs regular use
|
||||||
|
// Onboarding: show first-time hint (how to select)
|
||||||
|
// Regular: show sync hint (tap and hold to sync)
|
||||||
|
if (players.isNotEmpty && (_showHints || widget.showOnboardingHints))
|
||||||
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
// Calculate slide offset to match the topmost card
|
||||||
|
const baseOffset = 80.0;
|
||||||
|
final hintDistanceToTravel = baseOffset + (players.length * (cardHeight + cardSpacing));
|
||||||
|
final hintSlideOffset = hintDistanceToTravel * (1.0 - t);
|
||||||
|
|
||||||
|
// Show different hint for onboarding vs regular use
|
||||||
|
final isOnboarding = widget.showOnboardingHints;
|
||||||
|
final hintText = isOnboarding
|
||||||
|
? S.of(context)!.selectPlayerHint
|
||||||
|
: S.of(context)!.holdToSync;
|
||||||
|
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(0, hintSlideOffset),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isOnboarding ? Icons.touch_app_outlined : Icons.lightbulb_outline,
|
||||||
|
size: 18,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
hintText,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// Build player cards - 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) {
|
||||||
|
final player = players[index];
|
||||||
|
final isPlaying = player.state == 'playing';
|
||||||
|
|
||||||
|
// Get track info for this player
|
||||||
|
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
|
||||||
|
|
||||||
|
// Get album art URL - use track directly like mini player does
|
||||||
|
String? albumArtUrl;
|
||||||
|
if (playerTrack != null && player.available && player.powered) {
|
||||||
|
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use per-player colors if available, otherwise use defaults
|
||||||
|
final playerColorScheme = _playerColors[player.playerId];
|
||||||
|
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
|
||||||
|
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
|
||||||
|
|
||||||
|
// Animation: all cards start hidden behind mini player
|
||||||
|
// and fan out to their final positions with spring physics
|
||||||
|
const baseOffset = 80.0;
|
||||||
|
final reverseIndex = players.length - 1 - index;
|
||||||
|
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
|
||||||
|
|
||||||
|
// Use the elastic animation value directly (already has bounce)
|
||||||
|
final slideOffset = distanceToTravel * (1.0 - t);
|
||||||
|
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(0, slideOffset),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
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();
|
||||||
|
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Syncing ${player.name}...'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
maProvider.togglePlayerSync(player.playerId);
|
||||||
|
},
|
||||||
|
onPlayPause: () {
|
||||||
|
if (isPlaying) {
|
||||||
|
maProvider.pausePlayer(player.playerId);
|
||||||
|
} else {
|
||||||
|
maProvider.resumePlayer(player.playerId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSkipNext: () {
|
||||||
|
maProvider.nextTrack(player.playerId);
|
||||||
|
},
|
||||||
|
onPower: () {
|
||||||
|
maProvider.togglePower(player.playerId);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,6 +118,11 @@ class QueuePanel extends StatelessWidget {
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
// PERF: 4x cache size for 44x44 display, zero fade for scrolling
|
||||||
|
memCacheWidth: 176,
|
||||||
|
memCacheHeight: 176,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (context, url) => Container(
|
placeholder: (context, url) => Container(
|
||||||
color: textColor.withOpacity(0.1),
|
color: textColor.withOpacity(0.1),
|
||||||
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
|
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
@@ -18,40 +19,60 @@ 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)
|
||||||
|
// User already sees if selected player is playing, badge shows "other" activity
|
||||||
|
final selectedPlayerId = selectedPlayer?.playerId;
|
||||||
|
final playingCount = availablePlayers
|
||||||
|
.where((p) => p.state == 'playing' && p.playerId != selectedPlayerId)
|
||||||
|
.length;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
child: Material(
|
child: Badge(
|
||||||
color: colorScheme.primaryContainer,
|
isLabelVisible: playingCount > 0,
|
||||||
borderRadius: BorderRadius.circular(8),
|
backgroundColor: colorScheme.tertiary,
|
||||||
child: InkWell(
|
textColor: colorScheme.onTertiary,
|
||||||
onTap: () => _showPlayerSelector(context, maProvider, availablePlayers),
|
label: Text(
|
||||||
|
playingCount.toString(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
fontSize: 11,
|
||||||
|
color: colorScheme.onTertiary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Padding(
|
child: InkWell(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
onTap: () => _showPlayerSelector(context, maProvider, availablePlayers),
|
||||||
child: Row(
|
borderRadius: BorderRadius.circular(8),
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Padding(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
||||||
if (selectedPlayer != null) ...[
|
child: Row(
|
||||||
ConstrainedBox(
|
mainAxisSize: MainAxisSize.min,
|
||||||
constraints: const BoxConstraints(maxWidth: 120),
|
children: [
|
||||||
child: Text(
|
if (selectedPlayer != null) ...[
|
||||||
selectedPlayer.name,
|
ConstrainedBox(
|
||||||
style: TextStyle(
|
constraints: const BoxConstraints(maxWidth: 120),
|
||||||
color: colorScheme.onPrimaryContainer,
|
child: Text(
|
||||||
fontWeight: FontWeight.w600,
|
selectedPlayer.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
Icon(
|
||||||
|
MdiIcons.castAudio,
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
size: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
|
||||||
],
|
],
|
||||||
Icon(
|
),
|
||||||
MdiIcons.castAudio,
|
|
||||||
color: colorScheme.onPrimaryContainer,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -64,27 +85,8 @@ class PlayerSelector extends StatelessWidget {
|
|||||||
MusicAssistantProvider provider,
|
MusicAssistantProvider provider,
|
||||||
List players,
|
List players,
|
||||||
) {
|
) {
|
||||||
// Collapse player if expanded before showing sheet
|
// Use the new player reveal animation
|
||||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
GlobalPlayerOverlay.showPlayerReveal();
|
||||||
GlobalPlayerOverlay.collapsePlayer();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slide mini player down out of the way
|
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
|
||||||
|
|
||||||
// Show the bottom sheet
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
isScrollControlled: true,
|
|
||||||
isDismissible: true,
|
|
||||||
builder: (context) {
|
|
||||||
return _PlayerSelectorSheet();
|
|
||||||
},
|
|
||||||
).whenComplete(() {
|
|
||||||
// Slide mini player back up when sheet is dismissed
|
|
||||||
GlobalPlayerOverlay.showPlayer();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,23 +202,34 @@ 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this player is part of a sync group
|
||||||
|
final isGrouped = player.isGrouped;
|
||||||
|
// Pastel yellow for grouped players
|
||||||
|
const groupBorderColor = Color(0xFFFFF59D);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@@ -224,6 +237,23 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(player);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
|
onLongPress: () {
|
||||||
|
// Haptic feedback for sync action
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
|
||||||
|
// Show snackbar for visual feedback
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Syncing ${player.name}...'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Long-press to sync/unsync player
|
||||||
|
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
|
||||||
|
debugPrint('🔗 Player isGrouped: ${player.isGrouped}, groupMembers: ${player.groupMembers}');
|
||||||
|
maProvider.togglePlayerSync(player.playerId);
|
||||||
|
},
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 72,
|
height: 72,
|
||||||
@@ -232,6 +262,9 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
? colorScheme.primary.withOpacity(0.15)
|
? colorScheme.primary.withOpacity(0.15)
|
||||||
: colorScheme.surfaceVariant.withOpacity(0.3),
|
: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: isGrouped
|
||||||
|
? Border.all(color: groupBorderColor, width: 1.5)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -294,11 +327,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),
|
||||||
@@ -308,9 +343,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
|
||||||
@@ -321,6 +358,35 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (isGrouped) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: groupBorderColor.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.link_rounded,
|
||||||
|
size: 10,
|
||||||
|
color: Colors.amber.shade800,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Text(
|
||||||
|
'Synced',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.amber.shade800,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
if (isSelected) ...[
|
if (isSelected) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Container(
|
Container(
|
||||||
@@ -344,8 +410,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,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../models/media_item.dart';
|
|||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
import '../screens/audiobook_series_screen.dart';
|
import '../screens/audiobook_series_screen.dart';
|
||||||
|
import '../utils/page_transitions.dart';
|
||||||
|
|
||||||
class SeriesRow extends StatefulWidget {
|
class SeriesRow extends StatefulWidget {
|
||||||
final String title;
|
final String title;
|
||||||
@@ -267,10 +268,11 @@ class _SeriesCard extends StatelessWidget {
|
|||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
// PERF: Use FadeSlidePageRoute for consistent hero animation timing
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
FadeSlidePageRoute(
|
||||||
builder: (context) => AudiobookSeriesScreen(
|
child: AudiobookSeriesScreen(
|
||||||
series: series,
|
series: series,
|
||||||
heroTag: heroTag,
|
heroTag: heroTag,
|
||||||
initialCovers: covers,
|
initialCovers: covers,
|
||||||
@@ -284,6 +286,44 @@ class _SeriesCard extends StatelessWidget {
|
|||||||
// Use AspectRatio to guarantee square cover
|
// Use AspectRatio to guarantee square cover
|
||||||
Hero(
|
Hero(
|
||||||
tag: heroTag,
|
tag: heroTag,
|
||||||
|
// PERF: Use flightShuttleBuilder to avoid animating complex grid
|
||||||
|
// during hero flight. Show cached first cover image instead.
|
||||||
|
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
|
||||||
|
// Use first cover as simple placeholder during flight
|
||||||
|
final heroChild = toContext.widget as Hero;
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8 + 4 * animation.value),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: covers != null && covers!.isNotEmpty
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: covers!.first,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.collections_bookmark_rounded,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
@@ -373,6 +413,8 @@ class _SeriesCard extends StatelessWidget {
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: coverUrl,
|
imageUrl: coverUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (_, __) => Container(
|
placeholder: (_, __) => Container(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -186,7 +186,9 @@ class _TrackCard extends StatelessWidget {
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
memCacheWidth: 256,
|
memCacheWidth: 256,
|
||||||
memCacheHeight: 256,
|
memCacheHeight: 256,
|
||||||
fadeInDuration: const Duration(milliseconds: 150),
|
// PERF: Duration.zero for smooth scrolling
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (context, url) => const SizedBox(),
|
placeholder: (context, url) => const SizedBox(),
|
||||||
errorWidget: (context, url, error) => Icon(
|
errorWidget: (context, url, error) => Icon(
|
||||||
Icons.music_note_rounded,
|
Icons.music_note_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.3.2-beta+24
|
version: 2.7.2-beta+31
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|||||||
Reference in New Issue
Block a user