Compare commits
61
Commits
@@ -41,6 +41,9 @@ jobs:
|
||||
- name: Generate code (Drift database)
|
||||
run: dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
- name: Generate localizations
|
||||
run: flutter gen-l10n
|
||||
|
||||
- name: Generate launcher icons
|
||||
run: flutter pub run flutter_launcher_icons
|
||||
|
||||
|
||||
+200
-3
@@ -93,12 +93,72 @@ class SyncMetadata extends Table {
|
||||
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()();
|
||||
}
|
||||
|
||||
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
int get schemaVersion => 2;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@@ -107,7 +167,12 @@ class AppDatabase extends _$AppDatabase {
|
||||
await m.createAll();
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -327,4 +392,136 @@ class AppDatabase extends _$AppDatabase {
|
||||
if (lastSync == null) return true;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -269,8 +269,16 @@
|
||||
"audiobookSingular": "Hörbuch",
|
||||
"trackCount": "{count} {count, plural, =1{Song} other{Songs}}",
|
||||
"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",
|
||||
"playerStateOff": "Aus",
|
||||
"playerStateIdle": "Inaktiv",
|
||||
"playerSelected": "Ausgewählt"
|
||||
"playerSelected": "Ausgewählt",
|
||||
"actionQueuedForSync": "Wird synchronisiert wenn online",
|
||||
"pendingOfflineActions": "{count} ausstehende Synchronisierung"
|
||||
}
|
||||
|
||||
@@ -1143,6 +1143,36 @@
|
||||
"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": {
|
||||
"description": "Player state when unavailable"
|
||||
@@ -1161,5 +1191,20 @@
|
||||
"playerSelected": "Selected",
|
||||
"@playerSelected": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -465,8 +465,16 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"playerStateOff": "Apagado",
|
||||
"playerStateIdle": "Inactivo",
|
||||
"playerSelected": "Seleccionado"
|
||||
"playerSelected": "Seleccionado",
|
||||
"actionQueuedForSync": "Se sincronizará cuando esté en línea",
|
||||
"pendingOfflineActions": "{count} sincronización pendiente"
|
||||
}
|
||||
|
||||
+13
-4
@@ -172,11 +172,20 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
@override
|
||||
Future<bool> didPopRoute() async {
|
||||
// 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) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
return true; // We handled it, don't let Navigator process it
|
||||
}
|
||||
|
||||
// Let Navigator handle the back gesture normally
|
||||
return super.didPopRoute();
|
||||
}
|
||||
@@ -221,9 +230,9 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
lightColorScheme = light;
|
||||
darkColorScheme = dark;
|
||||
} else {
|
||||
// Use brand color schemes
|
||||
lightColorScheme = brandLightColorScheme;
|
||||
darkColorScheme = brandDarkColorScheme;
|
||||
// Use custom color from theme provider
|
||||
lightColorScheme = generateLightColorScheme(themeProvider.customColor);
|
||||
darkColorScheme = generateDarkColorScheme(themeProvider.customColor);
|
||||
}
|
||||
|
||||
return SystemUIWrapper(
|
||||
|
||||
@@ -110,7 +110,8 @@ class MediaItem {
|
||||
'media_type': mediaType.name,
|
||||
if (sortName != null) 'sort_name': sortName,
|
||||
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 (favorite != null) 'favorite': favorite,
|
||||
if (position != null) 'position': position,
|
||||
|
||||
@@ -189,7 +189,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
_logger.log('✅ MA authentication successful');
|
||||
onAuthenticated?.call();
|
||||
} 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();
|
||||
}
|
||||
},
|
||||
@@ -215,6 +217,12 @@ class ConnectionProvider with ChangeNotifier {
|
||||
Future<void> disconnect() async {
|
||||
await _api?.disconnect();
|
||||
_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();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:audio_service/audio_service.dart' as audio_service;
|
||||
@@ -20,7 +21,9 @@ import '../services/metadata_service.dart';
|
||||
import '../services/position_tracker.dart';
|
||||
import '../services/sendspin_service.dart';
|
||||
import '../services/pcm_audio_player.dart';
|
||||
import '../services/offline_action_queue.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../main.dart' show audioHandler;
|
||||
|
||||
/// Main provider that coordinates connection, player, and library state.
|
||||
@@ -177,6 +180,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
return getImageUrl(track, size: size);
|
||||
}
|
||||
|
||||
/// Number of pending offline actions
|
||||
int get pendingOfflineActionsCount => OfflineActionQueue.instance.pendingCount;
|
||||
|
||||
/// Whether there are pending offline actions
|
||||
bool get hasPendingOfflineActions => OfflineActionQueue.instance.hasPendingActions;
|
||||
|
||||
// ============================================================================
|
||||
// INITIALIZATION
|
||||
// ============================================================================
|
||||
@@ -188,6 +197,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
Future<void> _initialize() async {
|
||||
_serverUrl = await SettingsService.getServerUrl();
|
||||
|
||||
// Load cached players from database for instant display (before connecting)
|
||||
await _loadPlayersFromDatabase();
|
||||
|
||||
// Initialize offline action queue
|
||||
await OfflineActionQueue.instance.initialize();
|
||||
|
||||
if (_serverUrl != null && _serverUrl!.isNotEmpty) {
|
||||
await _restoreAuthCredentials();
|
||||
await connectToServer(_serverUrl!);
|
||||
@@ -195,6 +211,74 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load cached players from database for instant UI display
|
||||
Future<void> _loadPlayersFromDatabase() async {
|
||||
try {
|
||||
if (!DatabaseService.instance.isInitialized) {
|
||||
await DatabaseService.instance.initialize();
|
||||
}
|
||||
|
||||
final cachedPlayers = await DatabaseService.instance.getCachedPlayers();
|
||||
if (cachedPlayers.isEmpty) {
|
||||
_logger.log('📦 No cached players in database');
|
||||
return;
|
||||
}
|
||||
|
||||
final players = <Player>[];
|
||||
for (final cached in cachedPlayers) {
|
||||
try {
|
||||
final playerData = jsonDecode(cached.playerJson) as Map<String, dynamic>;
|
||||
players.add(Player.fromJson(playerData));
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error parsing cached player: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (players.isNotEmpty) {
|
||||
_availablePlayers = players;
|
||||
_cacheService.setCachedPlayers(players);
|
||||
|
||||
// Restore selected player from settings
|
||||
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
||||
if (lastSelectedPlayerId != null) {
|
||||
try {
|
||||
_selectedPlayer = players.firstWhere((p) => p.playerId == lastSelectedPlayerId);
|
||||
_cacheService.setCachedSelectedPlayer(_selectedPlayer);
|
||||
_logger.log('📦 Restored selected player from database: ${_selectedPlayer?.name}');
|
||||
} catch (_) {
|
||||
// Player not in cached list
|
||||
}
|
||||
}
|
||||
|
||||
_logger.log('📦 Loaded ${players.length} players from database (instant)');
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error loading players from database: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist players to database for app restart persistence
|
||||
void _persistPlayersToDatabase(List<Player> players) {
|
||||
// Run async but don't await - this is fire-and-forget persistence
|
||||
() async {
|
||||
try {
|
||||
if (!DatabaseService.instance.isInitialized) return;
|
||||
|
||||
final playerMaps = players.map((p) => {
|
||||
'playerId': p.playerId,
|
||||
'playerJson': jsonEncode(p.toJson()),
|
||||
'currentTrackJson': null as String?, // Will be updated separately when track changes
|
||||
}).toList();
|
||||
|
||||
await DatabaseService.instance.cachePlayers(playerMaps);
|
||||
_logger.log('💾 Persisted ${players.length} players to database');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error persisting players to database: $e');
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
Future<void> _restoreAuthCredentials() async {
|
||||
final savedCredentials = await SettingsService.getAuthCredentials();
|
||||
if (savedCredentials != null) {
|
||||
@@ -247,11 +331,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// Now safe to initialize since we're authenticated
|
||||
await _initializeAfterConnection();
|
||||
} else if (state == MAConnectionState.disconnected) {
|
||||
_availablePlayers = [];
|
||||
_selectedPlayer = null;
|
||||
// Only clear detail caches on disconnect, NOT home screen caches
|
||||
// Home screen data should persist across reconnects for better UX
|
||||
_cacheService.clearAllDetailCaches();
|
||||
// DON'T clear players or caches on disconnect!
|
||||
// Keep showing cached data for instant UI display on reconnect
|
||||
// Player list and state will be refreshed when connection is restored
|
||||
_logger.log('📡 Disconnected - keeping cached players and data for instant resume');
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
@@ -378,6 +461,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
loadLibrary();
|
||||
|
||||
// Process any queued offline actions now that we're connected
|
||||
await _processOfflineQueue();
|
||||
|
||||
_logger.log('✅ Post-connection initialization complete');
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error during post-connection initialization: $e');
|
||||
@@ -386,6 +472,131 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Process queued offline actions (favorites, playlist modifications, etc.)
|
||||
Future<void> _processOfflineQueue() async {
|
||||
if (_api == null || !isConnected) return;
|
||||
|
||||
final queue = OfflineActionQueue.instance;
|
||||
if (!queue.hasPendingActions) return;
|
||||
|
||||
_logger.log('📋 Processing ${queue.pendingCount} offline actions...');
|
||||
|
||||
await queue.processQueue((action) async {
|
||||
try {
|
||||
switch (action.type) {
|
||||
case OfflineActionTypes.toggleFavorite:
|
||||
return await _executeToggleFavorite(action.params);
|
||||
case OfflineActionTypes.addToPlaylist:
|
||||
return await _executeAddToPlaylist(action.params);
|
||||
case OfflineActionTypes.removeFromPlaylist:
|
||||
return await _executeRemoveFromPlaylist(action.params);
|
||||
default:
|
||||
_logger.log('⚠️ Unknown offline action type: ${action.type}');
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error executing offline action ${action.type}: $e');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Execute a queued toggle favorite action
|
||||
Future<bool> _executeToggleFavorite(Map<String, dynamic> params) async {
|
||||
if (_api == null) return false;
|
||||
|
||||
final mediaType = params['mediaType'] as String;
|
||||
final add = params['add'] as bool;
|
||||
|
||||
if (add) {
|
||||
final itemId = params['itemId'] as String;
|
||||
final provider = params['provider'] as String;
|
||||
await _api!.addToFavorites(mediaType, itemId, provider);
|
||||
} else {
|
||||
final libraryItemId = params['libraryItemId'] as int;
|
||||
await _api!.removeFromFavorites(mediaType, libraryItemId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Execute a queued add to playlist action
|
||||
Future<bool> _executeAddToPlaylist(Map<String, dynamic> params) async {
|
||||
// TODO: Implement when playlist modification is added
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Execute a queued remove from playlist action
|
||||
Future<bool> _executeRemoveFromPlaylist(Map<String, dynamic> params) async {
|
||||
// TODO: Implement when playlist modification is added
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FAVORITE MANAGEMENT (WITH OFFLINE SUPPORT)
|
||||
// ============================================================================
|
||||
|
||||
/// Add item to favorites with offline queuing support
|
||||
/// Returns true if action was executed or queued successfully
|
||||
Future<bool> addToFavorites({
|
||||
required String mediaType,
|
||||
required String itemId,
|
||||
required String provider,
|
||||
}) async {
|
||||
if (isConnected && _api != null) {
|
||||
// Online - execute immediately
|
||||
try {
|
||||
await _api!.addToFavorites(mediaType, itemId, provider);
|
||||
return true;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to add to favorites: $e');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Offline - queue the action
|
||||
await OfflineActionQueue.instance.queueAction(
|
||||
OfflineActionTypes.toggleFavorite,
|
||||
{
|
||||
'mediaType': mediaType,
|
||||
'add': true,
|
||||
'itemId': itemId,
|
||||
'provider': provider,
|
||||
},
|
||||
);
|
||||
_logger.log('📋 Queued add to favorites (offline): $mediaType');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove item from favorites with offline queuing support
|
||||
/// Returns true if action was executed or queued successfully
|
||||
Future<bool> removeFromFavorites({
|
||||
required String mediaType,
|
||||
required int libraryItemId,
|
||||
}) async {
|
||||
if (isConnected && _api != null) {
|
||||
// Online - execute immediately
|
||||
try {
|
||||
await _api!.removeFromFavorites(mediaType, libraryItemId);
|
||||
return true;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to remove from favorites: $e');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Offline - queue the action
|
||||
await OfflineActionQueue.instance.queueAction(
|
||||
OfflineActionTypes.toggleFavorite,
|
||||
{
|
||||
'mediaType': mediaType,
|
||||
'add': false,
|
||||
'libraryItemId': libraryItemId,
|
||||
},
|
||||
);
|
||||
_logger.log('📋 Queued remove from favorites (offline): $mediaType');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_playerStateTimer?.cancel();
|
||||
_playerStateTimer = null;
|
||||
@@ -404,11 +615,21 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
await _api?.disconnect();
|
||||
_connectionState = MAConnectionState.disconnected;
|
||||
// DON'T clear caches or player state - keep for instant reconnect
|
||||
_logger.log('📡 Explicit disconnect - keeping cached data for instant resume');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear all caches and state (for logout or server change)
|
||||
void clearAllOnLogout() {
|
||||
_availablePlayers = [];
|
||||
_selectedPlayer = null;
|
||||
_artists = [];
|
||||
_albums = [];
|
||||
_tracks = [];
|
||||
_currentTrack = null;
|
||||
_cacheService.clearAll();
|
||||
_logger.log('🗑️ Cleared all data on logout');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -529,24 +750,35 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if server version is >= 2.7.0b20 (uses Sendspin instead of builtin_player)
|
||||
bool _serverUsesSendspin() {
|
||||
/// Parse server version into components for version comparisons
|
||||
/// Returns null if version cannot be parsed
|
||||
({int major, int minor, int patch, int? beta})? _parseServerVersion() {
|
||||
final serverInfo = _api?.serverInfo;
|
||||
if (serverInfo == null) return false;
|
||||
if (serverInfo == null) return null;
|
||||
|
||||
final versionStr = serverInfo['server_version'] as String?;
|
||||
if (versionStr == null) return false;
|
||||
if (versionStr == null) return null;
|
||||
|
||||
// Parse version like "2.8.0b2" or "2.7.0b20" or "2.7.1"
|
||||
// Format: MAJOR.MINOR.PATCH[bBETA]
|
||||
final versionRegex = RegExp(r'^(\d+)\.(\d+)\.(\d+)(?:b(\d+))?');
|
||||
final match = versionRegex.firstMatch(versionStr);
|
||||
if (match == null) return false;
|
||||
if (match == null) return null;
|
||||
|
||||
final major = int.parse(match.group(1)!);
|
||||
final minor = int.parse(match.group(2)!);
|
||||
final patch = int.parse(match.group(3)!);
|
||||
final beta = match.group(4) != null ? int.parse(match.group(4)!) : null;
|
||||
return (
|
||||
major: int.parse(match.group(1)!),
|
||||
minor: int.parse(match.group(2)!),
|
||||
patch: int.parse(match.group(3)!),
|
||||
beta: match.group(4) != null ? int.parse(match.group(4)!) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if server version is >= 2.7.0b20 (uses Sendspin instead of builtin_player)
|
||||
bool _serverUsesSendspin() {
|
||||
final version = _parseServerVersion();
|
||||
if (version == null) return false;
|
||||
|
||||
final (:major, :minor, :patch, :beta) = version;
|
||||
|
||||
// Compare with 2.7.0b20
|
||||
if (major > 2) return true;
|
||||
@@ -562,6 +794,32 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
return beta >= 20;
|
||||
}
|
||||
|
||||
/// Check if server version is >= 2.8.0 (has built-in /sendspin proxy endpoint)
|
||||
/// The /sendspin proxy was added in MA 2.8.0 (PR #2840)
|
||||
/// MA 2.7.x does NOT have this proxy - users must expose port 8927 directly
|
||||
/// or manually configure reverse proxy routing to port 8927
|
||||
bool _serverHasSendspinProxy() {
|
||||
final version = _parseServerVersion();
|
||||
if (version == null) return false;
|
||||
|
||||
final (:major, :minor, :patch, :beta) = version;
|
||||
|
||||
// Compare with 2.8.0
|
||||
if (major > 2) return true;
|
||||
if (major < 2) return false;
|
||||
// major == 2
|
||||
if (minor > 8) return true;
|
||||
if (minor < 8) return false;
|
||||
// minor == 8, any 2.8.x version has the proxy
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Get the server version string for logging
|
||||
String _getServerVersionString() {
|
||||
final serverInfo = _api?.serverInfo;
|
||||
return serverInfo?['server_version'] as String? ?? 'unknown';
|
||||
}
|
||||
|
||||
Future<void> _registerLocalPlayer() async {
|
||||
if (_api == null) return;
|
||||
|
||||
@@ -665,19 +923,24 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
/// Connect to Music Assistant via Sendspin protocol (MA 2.7.0b20+)
|
||||
/// This is the replacement for builtin_player when that API is not available.
|
||||
///
|
||||
/// Connection strategy:
|
||||
/// 1. If server is HTTPS, try external wss://{server}/sendspin first
|
||||
/// 2. Get local_ws_url from API and try that
|
||||
/// 3. WebRTC fallback as last resort (requires TURN servers)
|
||||
/// Connection strategy depends on MA version and network:
|
||||
/// - MA 2.8.0+: Has built-in /sendspin proxy, works with any reverse proxy setup
|
||||
/// - MA 2.7.x: NO proxy, must either:
|
||||
/// - Use local IP with port 8927 exposed, OR
|
||||
/// - Manually configure reverse proxy to route /sendspin to port 8927
|
||||
Future<bool> _connectViaSendspin() async {
|
||||
if (_api == null || _serverUrl == null) return false;
|
||||
|
||||
try {
|
||||
final serverVersion = _getServerVersionString();
|
||||
final hasProxy = _serverHasSendspinProxy();
|
||||
_logger.log('Sendspin: Server version $serverVersion, has proxy: $hasProxy');
|
||||
|
||||
// Initialize Sendspin service
|
||||
_sendspinService?.dispose();
|
||||
_sendspinService = SendspinService(_serverUrl!);
|
||||
|
||||
// Set auth token for MA 2.7.1+ proxy authentication
|
||||
// Set auth token for proxy authentication (MA 2.8.0+ or manually configured proxy)
|
||||
final authToken = await SettingsService.getMaAuthToken();
|
||||
if (authToken != null) {
|
||||
_sendspinService!.setAuthToken(authToken);
|
||||
@@ -706,42 +969,59 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_sendspinService!.onStreamEnd = _handleSendspinStreamEnd;
|
||||
|
||||
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
|
||||
_logger.log('Sendspin: Player ID: $playerId');
|
||||
|
||||
// Strategy 1: If server is HTTPS, try external wss:// first
|
||||
final isHttps = _serverUrl!.startsWith('https://') ||
|
||||
(!_serverUrl!.contains('://') && !_serverUrl!.contains(':'));
|
||||
// Parse server URL to determine connection strategy
|
||||
final serverUri = Uri.parse(_serverUrl!.startsWith('http')
|
||||
? _serverUrl!
|
||||
: 'https://$_serverUrl');
|
||||
final isLocalIp = _isLocalNetworkHost(serverUri.host);
|
||||
final isHttps = serverUri.scheme == 'https' ||
|
||||
(!_serverUrl!.contains('://') && !isLocalIp);
|
||||
|
||||
// Strategy 1: For local IPs, connect directly to Sendspin port 8927
|
||||
if (isLocalIp) {
|
||||
final localSendspinUrl = 'ws://${serverUri.host}:8927/sendspin';
|
||||
_logger.log('Sendspin: Local network detected, trying direct connection: $localSendspinUrl');
|
||||
final connected = await _sendspinService!.connectWithUrl(localSendspinUrl);
|
||||
if (connected) {
|
||||
_sendspinConnected = true;
|
||||
_logger.log('✅ Sendspin: Connected via local network (port 8927)');
|
||||
return true;
|
||||
}
|
||||
_logger.log('⚠️ Sendspin: Local connection to port 8927 failed');
|
||||
|
||||
// For local IPs, also try the proxy path in case user has a local reverse proxy
|
||||
_logger.log('Sendspin: Trying local proxy fallback...');
|
||||
final localProxyUrl = 'ws://${serverUri.host}:${serverUri.hasPort ? serverUri.port : 8095}/sendspin';
|
||||
final proxyConnected = await _sendspinService!.connectWithUrl(localProxyUrl);
|
||||
if (proxyConnected) {
|
||||
_sendspinConnected = true;
|
||||
_logger.log('✅ Sendspin: Connected via local proxy');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: For external/HTTPS servers, use the proxy at /sendspin
|
||||
if (isHttps || !isLocalIp) {
|
||||
if (hasProxy) {
|
||||
_logger.log('Sendspin: MA 2.8.0+ detected, using built-in /sendspin proxy');
|
||||
} else {
|
||||
_logger.log('Sendspin: MA 2.7.x detected, trying /sendspin (requires manual proxy config)');
|
||||
}
|
||||
|
||||
if (isHttps) {
|
||||
_logger.log('Sendspin: Server is HTTPS, trying external connection first');
|
||||
final connected = await _sendspinService!.connect();
|
||||
if (connected) {
|
||||
_sendspinConnected = true;
|
||||
_logger.log('✅ Sendspin: Connected via external URL');
|
||||
_logger.log('✅ Sendspin: Connected via external proxy');
|
||||
return true;
|
||||
}
|
||||
_logger.log('⚠️ Sendspin: External connection failed, trying local URL');
|
||||
_logger.log('⚠️ Sendspin: External proxy connection failed');
|
||||
}
|
||||
|
||||
// Strategy 2: Get local_ws_url from API and try that
|
||||
final connectionInfo = await _api!.getSendspinConnectionInfo(playerId);
|
||||
if (connectionInfo != null) {
|
||||
final localWsUrl = connectionInfo['local_ws_url'] as String?;
|
||||
if (localWsUrl != null) {
|
||||
_logger.log('Sendspin: Trying local WebSocket URL: $localWsUrl');
|
||||
final connected = await _sendspinService!.connectWithUrl(localWsUrl);
|
||||
if (connected) {
|
||||
_sendspinConnected = true;
|
||||
_logger.log('✅ Sendspin: Connected via local URL');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: WebRTC fallback (requires TURN servers from Nabucasa)
|
||||
// For now, just log that we'd need WebRTC - implementing full WebRTC
|
||||
// would require additional dependencies (flutter_webrtc)
|
||||
_logger.log('⚠️ Sendspin: WebRTC fallback not yet implemented');
|
||||
_logger.log('ℹ️ For external access, configure /sendspin route in your reverse proxy');
|
||||
// All strategies failed - provide version-specific guidance
|
||||
_logger.log('❌ Sendspin: All connection strategies failed');
|
||||
_logSendspinTroubleshooting(isLocalIp, hasProxy, serverVersion);
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
@@ -750,6 +1030,29 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Log troubleshooting guidance based on setup
|
||||
void _logSendspinTroubleshooting(bool isLocalIp, bool hasProxy, String serverVersion) {
|
||||
if (isLocalIp) {
|
||||
_logger.log('ℹ️ LOCAL IP SETUP: Add port 8927 to your Docker compose:');
|
||||
_logger.log(' ports:');
|
||||
_logger.log(' - "8095:8095"');
|
||||
_logger.log(' - "8927:8927" # Required for Sendspin');
|
||||
} else if (!hasProxy) {
|
||||
// MA 2.7.x without built-in proxy
|
||||
_logger.log('ℹ️ MA $serverVersion does not have built-in /sendspin proxy');
|
||||
_logger.log('ℹ️ OPTIONS:');
|
||||
_logger.log(' 1. Upgrade to Music Assistant 2.8.0+ (recommended)');
|
||||
_logger.log(' 2. Or add reverse proxy config for /sendspin → port 8927');
|
||||
_logger.log(' Traefik: PathPrefix(`/sendspin`) → service port 8927');
|
||||
_logger.log(' Nginx: location /sendspin { proxy_pass http://ma:8927; }');
|
||||
} else {
|
||||
// MA 2.8.0+ but still failing
|
||||
_logger.log('ℹ️ MA $serverVersion should have /sendspin proxy');
|
||||
_logger.log('ℹ️ Check that your reverse proxy forwards WebSocket connections');
|
||||
_logger.log(' Ensure Upgrade and Connection headers are passed through');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle Sendspin play command
|
||||
void _handleSendspinPlay(String streamUrl, Map<String, dynamic> trackInfo) async {
|
||||
_logger.log('🎵 Sendspin: Play command received');
|
||||
@@ -1317,25 +1620,17 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// ============================================================================
|
||||
|
||||
Future<List<Album>> getRecentAlbumsWithCache({bool forceRefresh = false}) async {
|
||||
// Try local database first for instant results
|
||||
if (!forceRefresh) {
|
||||
final localAlbums = await RecentlyPlayedService.instance.getRecentAlbums(
|
||||
// Always fetch from API to ensure we have full album data with images
|
||||
// Local/cached data is used by getCachedRecentAlbums() for instant display
|
||||
if (_api == null) {
|
||||
// Fallback when offline: try memory cache, then local database
|
||||
final cached = _cacheService.getCachedRecentAlbums();
|
||||
if (cached != null && cached.isNotEmpty) return cached;
|
||||
return RecentlyPlayedService.instance.getRecentAlbums(
|
||||
limit: LibraryConstants.defaultRecentLimit,
|
||||
);
|
||||
if (localAlbums.isNotEmpty) {
|
||||
_logger.log('📦 Using ${localAlbums.length} local recently played albums');
|
||||
return localAlbums;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to memory cache
|
||||
if (_cacheService.isRecentAlbumsCacheValid(forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached recent albums');
|
||||
return _cacheService.getCachedRecentAlbums()!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedRecentAlbums() ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching fresh recent albums from MA...');
|
||||
final albums = await _api!.getRecentAlbums(limit: LibraryConstants.defaultRecentLimit);
|
||||
@@ -1343,7 +1638,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
return albums;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch recent albums: $e');
|
||||
return _cacheService.getCachedRecentAlbums() ?? [];
|
||||
// Fallback on error: try memory cache, then local database
|
||||
final cached = _cacheService.getCachedRecentAlbums();
|
||||
if (cached != null && cached.isNotEmpty) return cached;
|
||||
return RecentlyPlayedService.instance.getRecentAlbums(
|
||||
limit: LibraryConstants.defaultRecentLimit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,6 +1689,15 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_cacheService.invalidateHomeCache();
|
||||
}
|
||||
|
||||
/// Get cached recent albums synchronously (for instant display)
|
||||
List<Album>? getCachedRecentAlbums() => _cacheService.getCachedRecentAlbums();
|
||||
|
||||
/// Get cached discover artists synchronously (for instant display)
|
||||
List<Artist>? getCachedDiscoverArtists() => _cacheService.getCachedDiscoverArtists();
|
||||
|
||||
/// Get cached discover albums synchronously (for instant display)
|
||||
List<Album>? getCachedDiscoverAlbums() => _cacheService.getCachedDiscoverAlbums();
|
||||
|
||||
/// Force a full library sync (for pull-to-refresh)
|
||||
Future<void> forceLibrarySync() async {
|
||||
if (_api == null) return;
|
||||
@@ -1635,6 +1944,21 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_cacheService.clearAllDetailCaches();
|
||||
}
|
||||
|
||||
/// Get cached album tracks (for instant display before background refresh)
|
||||
List<Track>? getCachedAlbumTracks(String cacheKey) {
|
||||
return _cacheService.getCachedAlbumTracks(cacheKey);
|
||||
}
|
||||
|
||||
/// Get cached playlist tracks (for instant display before background refresh)
|
||||
List<Track>? getCachedPlaylistTracks(String cacheKey) {
|
||||
return _cacheService.getCachedPlaylistTracks(cacheKey);
|
||||
}
|
||||
|
||||
/// Get cached artist albums (for instant display before background refresh)
|
||||
List<Album>? getCachedArtistAlbums(String artistName) {
|
||||
return _cacheService.getCachedArtistAlbums(artistName.toLowerCase());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAYER SELECTION
|
||||
// ============================================================================
|
||||
@@ -1704,17 +2028,63 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
_availablePlayers.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
// Sort players - check if smart sort is enabled
|
||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||
if (smartSort) {
|
||||
// Smart sort: local player first, then playing, then on, then off
|
||||
_availablePlayers.sort((a, b) {
|
||||
// Local player always first
|
||||
final aIsLocal = builtinPlayerId != null && a.playerId == builtinPlayerId;
|
||||
final bIsLocal = builtinPlayerId != null && b.playerId == builtinPlayerId;
|
||||
if (aIsLocal && !bIsLocal) return -1;
|
||||
if (bIsLocal && !aIsLocal) return 1;
|
||||
|
||||
// Then by status: playing > on > off
|
||||
int statusPriority(Player p) {
|
||||
if (p.state == 'playing') return 0;
|
||||
if (p.powered && p.state != 'off') return 1;
|
||||
return 2;
|
||||
}
|
||||
final aPriority = statusPriority(a);
|
||||
final bPriority = statusPriority(b);
|
||||
if (aPriority != bPriority) return aPriority.compareTo(bPriority);
|
||||
|
||||
// Within same status, sort alphabetically
|
||||
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
});
|
||||
} else {
|
||||
// Default alphabetical sort
|
||||
_availablePlayers.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
}
|
||||
|
||||
// Cache players for instant display on app resume
|
||||
_cacheService.setCachedPlayers(_availablePlayers);
|
||||
|
||||
// Persist players to database for app restart persistence
|
||||
_persistPlayersToDatabase(_availablePlayers);
|
||||
|
||||
_logger.log('🎛️ After filtering: ${_availablePlayers.length} players available');
|
||||
|
||||
if (_availablePlayers.isNotEmpty) {
|
||||
Player? playerToSelect;
|
||||
final preferLocalPlayer = await SettingsService.getPreferLocalPlayer();
|
||||
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
||||
|
||||
if (_selectedPlayer != null) {
|
||||
// If "Prefer Local Player" is ON, always try to select local player first
|
||||
// This takes priority even over the currently selected player
|
||||
if (preferLocalPlayer && builtinPlayerId != null) {
|
||||
try {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == builtinPlayerId && p.available,
|
||||
);
|
||||
_logger.log('📱 Auto-selected local player (preferred): ${playerToSelect?.name}');
|
||||
} catch (e) {
|
||||
// Local player not available yet, will fall through to other options
|
||||
}
|
||||
}
|
||||
|
||||
// Keep currently selected player if still available (and not overridden by prefer local)
|
||||
if (playerToSelect == null && _selectedPlayer != null) {
|
||||
final stillAvailable = _availablePlayers.any(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId && p.available,
|
||||
);
|
||||
@@ -1726,9 +2096,32 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
if (playerToSelect == null) {
|
||||
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
||||
// Smart auto-selection priority:
|
||||
// If "Prefer Local Player" is OFF: Single playing player -> Local player -> Last selected -> First available
|
||||
|
||||
if (lastSelectedPlayerId != null) {
|
||||
if (!preferLocalPlayer) {
|
||||
// Priority 1 (normal): Single playing player (skip if multiple are playing)
|
||||
final playingPlayers = _availablePlayers.where(
|
||||
(p) => p.state == 'playing' && p.available,
|
||||
).toList();
|
||||
if (playingPlayers.length == 1) {
|
||||
playerToSelect = playingPlayers.first;
|
||||
_logger.log('🎵 Auto-selected playing player: ${playerToSelect?.name}');
|
||||
}
|
||||
|
||||
// Priority 2: Local player
|
||||
if (playerToSelect == null && builtinPlayerId != null) {
|
||||
try {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == builtinPlayerId && p.available,
|
||||
);
|
||||
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Last manually selected player
|
||||
if (playerToSelect == null && lastSelectedPlayerId != null) {
|
||||
try {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == lastSelectedPlayerId && p.available,
|
||||
@@ -1737,23 +2130,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (playerToSelect == null && builtinPlayerId != null) {
|
||||
try {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == builtinPlayerId && p.available,
|
||||
);
|
||||
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (playerToSelect == null) {
|
||||
try {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.state == 'playing' && p.available,
|
||||
);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Priority 4: First available player
|
||||
if (playerToSelect == null) {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.available,
|
||||
@@ -2157,6 +2534,26 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
await Future.wait(
|
||||
players.map((player) => _preloadPlayerTrack(player)),
|
||||
);
|
||||
|
||||
// After preloading, update _currentTrack from cache if it has better data
|
||||
// This fixes the issue where mini player shows wrong info but device list is correct
|
||||
if (_selectedPlayer != null && _currentTrack != null) {
|
||||
final cachedTrack = _cacheService.getCachedTrackForPlayer(_selectedPlayer!.playerId);
|
||||
if (cachedTrack != null && cachedTrack.uri == _currentTrack!.uri) {
|
||||
final cachedHasImage = cachedTrack.metadata?['images'] != null;
|
||||
final currentHasImage = _currentTrack!.metadata?['images'] != null;
|
||||
final cachedHasArtist = cachedTrack.artistsString.isNotEmpty &&
|
||||
cachedTrack.artistsString != 'Unknown Artist';
|
||||
final currentHasArtist = _currentTrack!.artistsString.isNotEmpty &&
|
||||
_currentTrack!.artistsString != 'Unknown Artist';
|
||||
|
||||
if ((cachedHasImage && !currentHasImage) || (cachedHasArtist && !currentHasArtist)) {
|
||||
_currentTrack = cachedTrack;
|
||||
_logger.log('🎵 Updated currentTrack from cache with better metadata');
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _startPlayerStatePolling() {
|
||||
@@ -2231,12 +2628,56 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
final queue = await getQueue(_selectedPlayer!.playerId);
|
||||
|
||||
if (queue != null && queue.currentItem != null) {
|
||||
final queueTrack = queue.currentItem!.track;
|
||||
final trackChanged = _currentTrack == null ||
|
||||
_currentTrack!.uri != queue.currentItem!.track.uri ||
|
||||
_currentTrack!.name != queue.currentItem!.track.name;
|
||||
_currentTrack!.uri != queueTrack.uri ||
|
||||
_currentTrack!.name != queueTrack.name;
|
||||
|
||||
if (trackChanged) {
|
||||
_currentTrack = queue.currentItem!.track;
|
||||
// Check if cached track has better metadata (images, proper artist info)
|
||||
// This prevents losing album art and artist data when resuming the app
|
||||
final cachedTrack = _cacheService.getCachedTrackForPlayer(_selectedPlayer!.playerId);
|
||||
final cachedHasImage = cachedTrack?.metadata?['images'] != null;
|
||||
final queueHasImage = queueTrack.metadata?['images'] != null;
|
||||
// Also check for malformed artist names (e.g., "Artist - Title" format in name)
|
||||
final cachedHasProperArtist = cachedTrack?.artistsString.isNotEmpty == true &&
|
||||
cachedTrack?.artistsString != 'Unknown Artist' &&
|
||||
!cachedTrack!.name.contains(' - '); // Proper track doesn't have artist in name
|
||||
final queueHasProperArtist = queueTrack.artistsString.isNotEmpty &&
|
||||
queueTrack.artistsString != 'Unknown Artist' &&
|
||||
!queueTrack.name.contains(' - ');
|
||||
|
||||
// Also check if _currentTrack (set from cache in selectPlayer) has good data
|
||||
final currentHasImage = _currentTrack?.metadata?['images'] != null;
|
||||
final currentHasProperArtist = _currentTrack?.artistsString.isNotEmpty == true &&
|
||||
_currentTrack?.artistsString != 'Unknown Artist' &&
|
||||
!(_currentTrack?.name.contains(' - ') ?? false);
|
||||
|
||||
// Match by URI - name might differ if queue has malformed data
|
||||
final isSameTrackAsCached = cachedTrack?.uri == queueTrack.uri;
|
||||
final cachedHasBetterData = (cachedHasImage && !queueHasImage) ||
|
||||
(cachedHasProperArtist && !queueHasProperArtist);
|
||||
|
||||
// Queue track has bad metadata if it lacks image AND lacks proper artist
|
||||
final queueHasBadMetadata = !queueHasImage && !queueHasProperArtist;
|
||||
// Current track (from cache at selectPlayer) has good metadata
|
||||
final currentHasGoodMetadata = currentHasImage || currentHasProperArtist;
|
||||
|
||||
if (isSameTrackAsCached && cachedHasBetterData) {
|
||||
// Use cached track which has better metadata
|
||||
_currentTrack = cachedTrack;
|
||||
_logger.log('🎵 Using cached track with better metadata for ${cachedTrack!.name}');
|
||||
} else if (queueHasBadMetadata && currentHasGoodMetadata) {
|
||||
// Queue has bad data, but _currentTrack (set from cache) is good - keep it
|
||||
_logger.log('🎵 Keeping currentTrack - queue has bad metadata but current is good');
|
||||
// Don't change _currentTrack, keep the good data
|
||||
} else {
|
||||
_currentTrack = queueTrack;
|
||||
// Update cache if queue track has good metadata
|
||||
if (queueHasImage || queueHasProperArtist) {
|
||||
_cacheService.setCachedTrackForPlayer(_selectedPlayer!.playerId, queueTrack);
|
||||
}
|
||||
}
|
||||
stateChanged = true;
|
||||
|
||||
// Clear audiobook context if switched to a different media item
|
||||
@@ -2583,7 +3024,60 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<PlayerQueue?> getQueue(String playerId) async {
|
||||
return await _api?.getQueue(playerId);
|
||||
final queue = await _api?.getQueue(playerId);
|
||||
|
||||
// Persist queue to database for instant display on app resume
|
||||
if (queue != null) {
|
||||
_persistQueueToDatabase(playerId, queue);
|
||||
}
|
||||
|
||||
return queue;
|
||||
}
|
||||
|
||||
/// Get cached queue for instant display (before API refresh)
|
||||
Future<PlayerQueue?> getCachedQueue(String playerId) async {
|
||||
try {
|
||||
if (!DatabaseService.instance.isInitialized) return null;
|
||||
|
||||
final cachedItems = await DatabaseService.instance.getCachedQueue(playerId);
|
||||
if (cachedItems.isEmpty) return null;
|
||||
|
||||
final items = <QueueItem>[];
|
||||
for (final cached in cachedItems) {
|
||||
try {
|
||||
final itemData = jsonDecode(cached.itemJson) as Map<String, dynamic>;
|
||||
items.add(QueueItem.fromJson(itemData));
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error parsing cached queue item: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (items.isEmpty) return null;
|
||||
|
||||
return PlayerQueue(
|
||||
playerId: playerId,
|
||||
items: items,
|
||||
currentIndex: 0, // Will be updated from fresh data
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error loading cached queue: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist queue to database for app restart persistence
|
||||
void _persistQueueToDatabase(String playerId, PlayerQueue queue) {
|
||||
() async {
|
||||
try {
|
||||
if (!DatabaseService.instance.isInitialized) return;
|
||||
|
||||
final itemJsonList = queue.items.map((item) => jsonEncode(item.toJson())).toList();
|
||||
await DatabaseService.instance.saveQueue(playerId, itemJsonList);
|
||||
_logger.log('💾 Persisted ${queue.items.length} queue items to database');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error persisting queue to database: $e');
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
Future<void> playTrack(String playerId, Track track, {bool clearQueue = true}) async {
|
||||
@@ -2684,11 +3178,65 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// The stream_start event will trigger local playback
|
||||
await _api?.resumePlayer(playerId);
|
||||
} catch (e) {
|
||||
// Check if this is a "No playable item" error - means server queue is empty
|
||||
final errorStr = e.toString();
|
||||
if (errorStr.contains('No playable item')) {
|
||||
_logger.log('⚠️ Server queue empty - attempting to restore from cached queue...');
|
||||
|
||||
// Try to restore queue from cached data
|
||||
final restored = await _restoreQueueFromCache(playerId);
|
||||
if (restored) {
|
||||
_logger.log('✅ Queue restored from cache - playback started');
|
||||
return; // Successfully restored and playing
|
||||
}
|
||||
_logger.log('❌ Could not restore queue from cache');
|
||||
}
|
||||
|
||||
ErrorHandler.logError('Resume player', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to restore the queue from cached data and start playback
|
||||
Future<bool> _restoreQueueFromCache(String playerId) async {
|
||||
try {
|
||||
final cachedQueue = await getCachedQueue(playerId);
|
||||
if (cachedQueue == null || cachedQueue.items.isEmpty) {
|
||||
_logger.log('⚠️ No cached queue to restore');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract tracks from queue items
|
||||
// playTracks needs either: providerMappings with available entries, OR provider+itemId
|
||||
final tracks = cachedQueue.items
|
||||
.map((item) => item.track)
|
||||
.where((track) {
|
||||
// Check for providerMappings with at least one available entry
|
||||
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
|
||||
return track.providerMappings!.any((m) => m.available);
|
||||
}
|
||||
// Fallback: provider + itemId can be used to construct URI
|
||||
return track.provider.isNotEmpty && track.itemId.isNotEmpty;
|
||||
})
|
||||
.toList();
|
||||
|
||||
if (tracks.isEmpty) {
|
||||
_logger.log('⚠️ Cached queue has no valid tracks');
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.log('🔄 Restoring ${tracks.length} tracks from cached queue');
|
||||
|
||||
// Re-queue all tracks and start playback
|
||||
await playTracks(playerId, tracks, startIndex: 0);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error restoring queue from cache: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> nextTrack(String playerId) async {
|
||||
try {
|
||||
// Optimistic local stop for builtin player on skip - non-blocking
|
||||
@@ -2972,4 +3520,25 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_api?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UTILITY METHODS
|
||||
// ============================================================================
|
||||
|
||||
/// Check if a hostname is a local/private network address
|
||||
bool _isLocalNetworkHost(String host) {
|
||||
return host.startsWith('192.168.') ||
|
||||
host.startsWith('10.') ||
|
||||
host.startsWith('172.16.') ||
|
||||
host.startsWith('172.17.') ||
|
||||
host.startsWith('172.18.') ||
|
||||
host.startsWith('172.19.') ||
|
||||
host.startsWith('172.2') ||
|
||||
host.startsWith('172.30.') ||
|
||||
host.startsWith('172.31.') ||
|
||||
host == 'localhost' ||
|
||||
host.startsWith('127.') ||
|
||||
host.endsWith('.local') ||
|
||||
host.endsWith('.ts.net'); // Tailscale - treat as local since it's a VPN
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
|
||||
@@ -16,11 +16,14 @@ import 'artist_details_screen.dart';
|
||||
class AlbumDetailsScreen extends StatefulWidget {
|
||||
final Album album;
|
||||
final String? heroTagSuffix;
|
||||
/// Initial image URL from the source (e.g., AlbumCard) for seamless hero animation
|
||||
final String? initialImageUrl;
|
||||
|
||||
const AlbumDetailsScreen({
|
||||
super.key,
|
||||
required this.album,
|
||||
this.heroTagSuffix,
|
||||
this.initialImageUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -37,6 +40,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
int? _expandedTrackIndex;
|
||||
bool _isDescriptionExpanded = false;
|
||||
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}' : '';
|
||||
|
||||
@@ -46,29 +53,31 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
_isFavorite = widget.album.favorite ?? false;
|
||||
_loadTracks();
|
||||
_loadAlbumDescription();
|
||||
_refreshFavoriteStatus();
|
||||
|
||||
// CRITICAL FIX: Defer color extraction until after the transition completes
|
||||
// This prevents expensive palette extraction during hero animation
|
||||
// Wait for the first frame to be painted, then extract colors
|
||||
// CRITICAL FIX: Defer both fresh data loading AND color extraction until
|
||||
// after the hero animation completes. This prevents:
|
||||
// 1. setState with new image URL during animation → grey icon flash
|
||||
// 2. Expensive palette extraction blocking animation frames
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Add small delay to ensure transition is complete
|
||||
// 350ms matches FadeSlidePageRoute duration (300ms) + buffer
|
||||
Future.delayed(const Duration(milliseconds: 350), () {
|
||||
if (mounted) {
|
||||
_loadFreshAlbumData();
|
||||
_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>();
|
||||
if (maProvider.api == null) return;
|
||||
|
||||
// Need a URI to fetch fresh album data
|
||||
final albumUri = widget.album.uri;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -76,17 +85,20 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
final freshAlbum = await maProvider.api!.getAlbumByUri(albumUri);
|
||||
if (freshAlbum != null && mounted) {
|
||||
setState(() {
|
||||
_freshAlbum = freshAlbum;
|
||||
_isFavorite = freshAlbum.favorite ?? false;
|
||||
});
|
||||
// Re-extract colors now that we have fresh album with images
|
||||
_extractColors();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error refreshing favorite status: $e');
|
||||
_logger.log('Error loading fresh album data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _extractColors() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.getImageUrl(widget.album, size: 512);
|
||||
final imageUrl = maProvider.getImageUrl(_displayAlbum, size: 512);
|
||||
|
||||
if (imageUrl == null) return;
|
||||
|
||||
@@ -108,10 +120,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
|
||||
Future<void> _toggleFavorite() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
if (maProvider.api == null) return;
|
||||
|
||||
try {
|
||||
final newState = !_isFavorite;
|
||||
bool success;
|
||||
|
||||
if (newState) {
|
||||
// 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');
|
||||
await maProvider.api!.addToFavorites('album', actualItemId, actualProvider);
|
||||
success = await maProvider.addToFavorites(
|
||||
mediaType: 'album',
|
||||
itemId: actualItemId,
|
||||
provider: actualProvider,
|
||||
);
|
||||
} else {
|
||||
// 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;
|
||||
|
||||
if (widget.album.provider == 'library') {
|
||||
libraryItemId = int.tryParse(widget.album.itemId);
|
||||
} else if (widget.album.providerMappings != null) {
|
||||
// Find the library mapping
|
||||
final libraryMapping = widget.album.providerMappings!.firstWhere(
|
||||
(m) => m.providerInstance == 'library',
|
||||
orElse: () => widget.album.providerMappings!.first,
|
||||
@@ -158,25 +171,34 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
}
|
||||
|
||||
_logger.log('Removing from favorites: libraryItemId=$libraryItemId');
|
||||
await maProvider.api!.removeFromFavorites('album', libraryItemId);
|
||||
success = await maProvider.removeFromFavorites(
|
||||
mediaType: 'album',
|
||||
libraryItemId: libraryItemId,
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isFavorite = newState;
|
||||
});
|
||||
if (success) {
|
||||
// Optimistically update UI regardless of online/offline state
|
||||
setState(() {
|
||||
_isFavorite = newState;
|
||||
});
|
||||
|
||||
// Invalidate home cache so the home screen shows updated favorite status
|
||||
maProvider.invalidateHomeCache();
|
||||
// Invalidate home cache so the home screen shows updated favorite status
|
||||
maProvider.invalidateHomeCache();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
|
||||
if (mounted) {
|
||||
final isOffline = !maProvider.isConnected;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
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) {
|
||||
_logger.log('Error toggling favorite: $e');
|
||||
@@ -196,11 +218,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
|
||||
final track = _tracks[trackIndex];
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
if (maProvider.api == null) return;
|
||||
|
||||
final currentFavorite = track.favorite ?? false;
|
||||
|
||||
try {
|
||||
bool success;
|
||||
|
||||
if (currentFavorite) {
|
||||
// Remove from favorites - need library_item_id
|
||||
int? libraryItemId;
|
||||
@@ -217,7 +239,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
}
|
||||
|
||||
if (libraryItemId != null) {
|
||||
await maProvider.api!.removeFromFavorites('track', libraryItemId);
|
||||
success = await maProvider.removeFromFavorites(
|
||||
mediaType: 'track',
|
||||
libraryItemId: libraryItemId,
|
||||
);
|
||||
} else {
|
||||
success = false;
|
||||
}
|
||||
} else {
|
||||
// Add to favorites
|
||||
@@ -236,33 +263,42 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
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
|
||||
setState(() {
|
||||
_tracks[trackIndex] = Track(
|
||||
itemId: track.itemId,
|
||||
provider: track.provider,
|
||||
name: track.name,
|
||||
uri: track.uri,
|
||||
favorite: !currentFavorite,
|
||||
artists: track.artists,
|
||||
album: track.album,
|
||||
duration: track.duration,
|
||||
providerMappings: track.providerMappings,
|
||||
);
|
||||
});
|
||||
if (success) {
|
||||
// Optimistically update local state
|
||||
setState(() {
|
||||
_tracks[trackIndex] = Track(
|
||||
itemId: track.itemId,
|
||||
provider: track.provider,
|
||||
name: track.name,
|
||||
uri: track.uri,
|
||||
favorite: !currentFavorite,
|
||||
artists: track.artists,
|
||||
album: track.album,
|
||||
duration: track.duration,
|
||||
providerMappings: track.providerMappings,
|
||||
);
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
!currentFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
|
||||
if (mounted) {
|
||||
final isOffline = !maProvider.isConnected;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
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) {
|
||||
_logger.log('Error toggling track favorite: $e');
|
||||
@@ -279,16 +315,46 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
|
||||
Future<void> _loadTracks() async {
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
final tracks = await provider.getAlbumTracksWithCache(
|
||||
widget.album.provider,
|
||||
widget.album.itemId,
|
||||
);
|
||||
final cacheKey = '${widget.album.provider}_${widget.album.itemId}';
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_isLoading = false;
|
||||
});
|
||||
// 1. Show cached data immediately (if available)
|
||||
final cachedTracks = provider.getCachedAlbumTracks(cacheKey);
|
||||
if (cachedTracks != null && cachedTracks.isNotEmpty) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,9 +661,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
Widget build(BuildContext context) {
|
||||
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
|
||||
// Only rebuild when specific properties change, not on every provider update
|
||||
final imageUrl = context.select<MusicAssistantProvider, String?>(
|
||||
(provider) => provider.getImageUrl(widget.album, size: 512),
|
||||
// Use _displayAlbum which has fresh data with images if available
|
||||
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>(
|
||||
(provider) => provider.adaptiveTheme,
|
||||
);
|
||||
|
||||
@@ -155,10 +155,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
|
||||
Future<void> _toggleFavorite() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
if (maProvider.api == null) return;
|
||||
|
||||
try {
|
||||
final newState = !_isFavorite;
|
||||
bool success;
|
||||
|
||||
if (newState) {
|
||||
String actualProvider = widget.artist.provider;
|
||||
@@ -177,7 +177,11 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
}
|
||||
|
||||
_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 {
|
||||
int? libraryItemId;
|
||||
|
||||
@@ -199,24 +203,32 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
}
|
||||
|
||||
_logger.log('Removing artist from favorites: libraryItemId=$libraryItemId');
|
||||
await maProvider.api!.removeFromFavorites('artist', libraryItemId);
|
||||
success = await maProvider.removeFromFavorites(
|
||||
mediaType: 'artist',
|
||||
libraryItemId: libraryItemId,
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isFavorite = newState;
|
||||
});
|
||||
if (success) {
|
||||
setState(() {
|
||||
_isFavorite = newState;
|
||||
});
|
||||
|
||||
maProvider.invalidateHomeCache();
|
||||
maProvider.invalidateHomeCache();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
|
||||
if (mounted) {
|
||||
final isOffline = !maProvider.isConnected;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
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) {
|
||||
_logger.log('Error toggling artist favorite: $e');
|
||||
@@ -427,20 +439,51 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
Future<void> _loadArtistAlbums() async {
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// Use cached artist albums method
|
||||
final allAlbums = await provider.getArtistAlbumsWithCache(widget.artist.name);
|
||||
// 1. Show cached data immediately (if available)
|
||||
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) {
|
||||
// Separate library albums from provider-only albums
|
||||
final libraryAlbums = allAlbums.where((a) => a.inLibrary).toList();
|
||||
final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_albums = libraryAlbums;
|
||||
_providerAlbums = providerOnlyAlbums;
|
||||
_sortAlbums();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_albums = libraryAlbums;
|
||||
_providerAlbums = providerOnlyAlbums;
|
||||
_sortAlbums(); // Apply saved sort order
|
||||
_isLoading = false;
|
||||
});
|
||||
// 2. Fetch fresh data in background (silent refresh)
|
||||
try {
|
||||
final allAlbums = await provider.getArtistAlbumsWithCache(
|
||||
widget.artist.name,
|
||||
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 +553,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
height: 200,
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.person_rounded,
|
||||
size: 100,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
@@ -817,6 +862,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
child: AlbumDetailsScreen(
|
||||
album: album,
|
||||
heroTagSuffix: heroSuffix,
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -833,12 +879,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
child: Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
errorBuilder: (_, __, ___) => Center(
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
@@ -940,10 +988,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
height: 56,
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.album_rounded,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -979,6 +1029,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
child: AlbumDetailsScreen(
|
||||
album: album,
|
||||
heroTagSuffix: 'artist_albums',
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -19,12 +19,14 @@ class AudiobookAuthorScreen extends StatefulWidget {
|
||||
final String authorName;
|
||||
final List<Audiobook> audiobooks;
|
||||
final String? heroTagSuffix;
|
||||
final String? initialAuthorImageUrl;
|
||||
|
||||
const AudiobookAuthorScreen({
|
||||
super.key,
|
||||
required this.authorName,
|
||||
required this.audiobooks,
|
||||
this.heroTagSuffix,
|
||||
this.initialAuthorImageUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -48,6 +50,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_audiobooks = List.from(widget.audiobooks);
|
||||
// Use initial image URL immediately for smooth hero animation
|
||||
_authorImageUrl = widget.initialAuthorImageUrl;
|
||||
_loadViewPreferences();
|
||||
_sortAudiobooks();
|
||||
_loadAuthorImage();
|
||||
@@ -181,6 +185,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
fit: BoxFit.cover,
|
||||
width: 200,
|
||||
height: 200,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Icon(
|
||||
MdiIcons.accountOutline,
|
||||
size: 100,
|
||||
@@ -329,7 +335,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
final heroSuffix = 'author${_heroTagSuffix}';
|
||||
|
||||
return InkWell(
|
||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix),
|
||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -349,6 +355,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
@@ -443,6 +451,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
@@ -508,17 +518,18 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
),
|
||||
)
|
||||
: 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(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
child: AudiobookDetailScreen(
|
||||
audiobook: book,
|
||||
heroTagSuffix: heroTagSuffix,
|
||||
initialImageUrl: initialImageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -15,11 +15,14 @@ import '../l10n/app_localizations.dart';
|
||||
class AudiobookDetailScreen extends StatefulWidget {
|
||||
final Audiobook audiobook;
|
||||
final String? heroTagSuffix;
|
||||
/// Initial image URL from the source for seamless hero animation
|
||||
final String? initialImageUrl;
|
||||
|
||||
const AudiobookDetailScreen({
|
||||
super.key,
|
||||
required this.audiobook,
|
||||
this.heroTagSuffix,
|
||||
this.initialImageUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -119,10 +122,10 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
|
||||
Future<void> _toggleFavorite() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
if (maProvider.api == null) return;
|
||||
|
||||
try {
|
||||
final newState = !_isFavorite;
|
||||
bool success;
|
||||
|
||||
if (newState) {
|
||||
String actualProvider = widget.audiobook.provider;
|
||||
@@ -141,7 +144,11 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
}
|
||||
|
||||
_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 {
|
||||
int? libraryItemId;
|
||||
|
||||
@@ -162,22 +169,30 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
}
|
||||
|
||||
_logger.log('Removing audiobook from favorites: libraryItemId=$libraryItemId');
|
||||
await maProvider.api!.removeFromFavorites('audiobook', libraryItemId);
|
||||
success = await maProvider.removeFromFavorites(
|
||||
mediaType: 'audiobook',
|
||||
libraryItemId: libraryItemId,
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isFavorite = newState;
|
||||
});
|
||||
if (success) {
|
||||
setState(() {
|
||||
_isFavorite = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
|
||||
if (mounted) {
|
||||
final isOffline = !maProvider.isConnected;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
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) {
|
||||
_logger.log('Error toggling audiobook favorite: $e');
|
||||
@@ -414,7 +429,9 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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>(
|
||||
(provider) => provider.adaptiveTheme,
|
||||
@@ -528,6 +545,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
|
||||
@@ -517,6 +517,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
? CachedNetworkImage(
|
||||
imageUrl: coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
@@ -633,7 +635,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
final heroSuffix = 'series_${widget.series.id}';
|
||||
|
||||
return InkWell(
|
||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix),
|
||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -653,6 +655,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
@@ -746,6 +750,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
@@ -811,17 +817,18 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
),
|
||||
)
|
||||
: 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(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
child: AudiobookDetailScreen(
|
||||
audiobook: book,
|
||||
heroTagSuffix: heroTagSuffix,
|
||||
initialImageUrl: initialImageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -62,6 +62,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
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 (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
|
||||
@@ -47,7 +47,9 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
||||
Widget _buildAlbumsList(BuildContext context, List<Album> albums, bool isLoading) {
|
||||
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(
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
);
|
||||
|
||||
@@ -49,7 +49,9 @@ class LibraryArtistsScreen extends StatelessWidget {
|
||||
Widget _buildArtistsList(BuildContext context, List<Artist> artists, bool isLoading) {
|
||||
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(
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
);
|
||||
|
||||
@@ -75,7 +75,9 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
Widget _buildPlaylistsList(BuildContext context, MusicAssistantProvider provider) {
|
||||
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(
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
);
|
||||
|
||||
@@ -42,7 +42,9 @@ class LibraryTracksScreen extends StatelessWidget {
|
||||
BuildContext context, MusicAssistantProvider provider) {
|
||||
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(
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
);
|
||||
|
||||
@@ -304,6 +304,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
key: const ValueKey('recent-albums'),
|
||||
title: S.of(context)!.recentlyPlayed,
|
||||
loadAlbums: () => provider.getRecentAlbumsWithCache(),
|
||||
getCachedAlbums: () => provider.getCachedRecentAlbums(),
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'discover-artists':
|
||||
@@ -312,6 +313,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
key: const ValueKey('discover-artists'),
|
||||
title: S.of(context)!.discoverArtists,
|
||||
loadArtists: () => provider.getDiscoverArtistsWithCache(),
|
||||
getCachedArtists: () => provider.getCachedDiscoverArtists(),
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'discover-albums':
|
||||
@@ -320,6 +322,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
key: const ValueKey('discover-albums'),
|
||||
title: S.of(context)!.discoverAlbums,
|
||||
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
|
||||
getCachedAlbums: () => provider.getCachedDiscoverAlbums(),
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'continue-listening':
|
||||
|
||||
@@ -1005,6 +1005,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
fit: BoxFit.cover,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Text(
|
||||
authorName.isNotEmpty ? authorName[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
@@ -1047,7 +1049,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
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
|
||||
return GestureDetector(
|
||||
onTap: () => _navigateToAuthor(authorName, books, heroTagSuffix: 'library$heroSuffix'),
|
||||
onTap: () => _navigateToAuthor(authorName, books, heroTagSuffix: 'library$heroSuffix', initialAuthorImageUrl: authorImageUrl),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
@@ -1088,6 +1090,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
fit: BoxFit.cover,
|
||||
width: size,
|
||||
height: size,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Center(
|
||||
child: Text(
|
||||
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(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
@@ -1150,6 +1154,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
authorName: authorName,
|
||||
audiobooks: books,
|
||||
heroTagSuffix: heroTagSuffix,
|
||||
initialAuthorImageUrl: initialAuthorImageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1222,17 +1227,18 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
),
|
||||
)
|
||||
: 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(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
child: AudiobookDetailScreen(
|
||||
audiobook: book,
|
||||
heroTagSuffix: heroTagSuffix,
|
||||
initialImageUrl: initialImageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1325,7 +1331,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
final heroSuffix = _showFavoritesOnly ? '_fav' : '';
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: 'library$heroSuffix'),
|
||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: 'library$heroSuffix', initialImageUrl: imageUrl),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -1346,6 +1352,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox(),
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
@@ -2188,7 +2196,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
Navigator.push(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
child: AlbumDetailsScreen(album: album),
|
||||
child: AlbumDetailsScreen(
|
||||
album: album,
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -35,21 +35,48 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
|
||||
}
|
||||
|
||||
Future<void> _loadTracks() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final tracks = await maProvider.getPlaylistTracksWithCache(
|
||||
widget.provider,
|
||||
widget.itemId,
|
||||
);
|
||||
final cacheKey = '${widget.provider}_${widget.itemId}';
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_isLoading = false;
|
||||
});
|
||||
// 1. Show cached data immediately (if available)
|
||||
final cachedTracks = maProvider.getCachedPlaylistTracks(cacheKey);
|
||||
if (cachedTracks != null && cachedTracks.isNotEmpty) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -200,7 +200,12 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
Widget _buildSearchContent() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (_isSearching) {
|
||||
// Show cached results even while searching - only show spinner if no cached results
|
||||
final hasCachedResults = _searchResults['artists']?.isNotEmpty == true ||
|
||||
_searchResults['albums']?.isNotEmpty == true ||
|
||||
_searchResults['tracks']?.isNotEmpty == true;
|
||||
|
||||
if (_isSearching && !hasCachedResults) {
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
);
|
||||
@@ -491,7 +496,10 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AlbumDetailsScreen(album: album),
|
||||
builder: (context) => AlbumDetailsScreen(
|
||||
album: album,
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../providers/music_assistant_provider.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import 'debug_log_screen.dart';
|
||||
import 'login_screen.dart';
|
||||
@@ -37,6 +38,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Audiobook libraries
|
||||
List<Map<String, String>> _discoveredLibraries = [];
|
||||
Map<String, bool> _libraryEnabled = {};
|
||||
// Player settings
|
||||
bool _preferLocalPlayer = false;
|
||||
bool _smartSortPlayers = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -81,6 +85,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
libraryEnabled[path] = enabled == null || enabled.contains(path);
|
||||
}
|
||||
|
||||
// Load player settings
|
||||
final preferLocal = await SettingsService.getPreferLocalPlayer();
|
||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showRecentAlbums = showRecent;
|
||||
@@ -95,6 +103,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_homeRowOrder = rowOrder;
|
||||
_discoveredLibraries = discovered;
|
||||
_libraryEnabled = libraryEnabled;
|
||||
_preferLocalPlayer = preferLocal;
|
||||
_smartSortPlayers = smartSort;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -410,7 +420,137 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
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>(
|
||||
builder: (context, localeProvider, _) {
|
||||
String getLanguageName(String? code) {
|
||||
@@ -493,64 +633,68 @@ 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),
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
},
|
||||
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)!.preferLocalPlayer,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
subtitle: Text(
|
||||
S.of(context)!.preferLocalPlayerDescription,
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||
),
|
||||
value: _preferLocalPlayer,
|
||||
onChanged: (value) {
|
||||
setState(() => _preferLocalPlayer = value);
|
||||
SettingsService.setPreferLocalPlayer(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,
|
||||
),
|
||||
);
|
||||
},
|
||||
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)!.smartSortPlayers,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
subtitle: Text(
|
||||
S.of(context)!.smartSortPlayersDescription,
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||
),
|
||||
value: _smartSortPlayers,
|
||||
onChanged: (value) {
|
||||
setState(() => _smartSortPlayers = value);
|
||||
SettingsService.setSmartSortPlayers(value);
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'auth_strategy.dart';
|
||||
import 'no_auth_strategy.dart';
|
||||
@@ -86,6 +87,22 @@ class AuthManager {
|
||||
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
|
||||
/// Returns the strategy if successful, null if connection failed
|
||||
Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async {
|
||||
@@ -187,15 +204,50 @@ class AuthManager {
|
||||
|
||||
_logger.log('Checking MA API at: $apiUrl');
|
||||
|
||||
// Try to get server info - this tells us if auth is required
|
||||
// Note: The command is just 'info', not 'server/info'
|
||||
final response = await http.post(
|
||||
apiUrl,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'command': 'info',
|
||||
}),
|
||||
).timeout(const Duration(seconds: 5));
|
||||
// Use IOClient to control redirect behavior for local IPs
|
||||
final isLocalIp = _isLocalIpAddress(uri.host);
|
||||
http.Response response;
|
||||
|
||||
if (isLocalIp) {
|
||||
// For local IPs, don't follow redirects - handle them explicitly
|
||||
final client = HttpClient();
|
||||
try {
|
||||
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 body: ${response.body}');
|
||||
@@ -255,21 +307,47 @@ class AuthManager {
|
||||
/// Test if server is accessible without authentication
|
||||
Future<bool> _canConnectWithoutAuth(String serverUrl) async {
|
||||
try {
|
||||
// Test root endpoint - Music Assistant doesn't have /api/info
|
||||
final testUrl = serverUrl;
|
||||
_logger.log('No-auth test URL: $testUrl');
|
||||
final uri = Uri.parse(serverUrl);
|
||||
_logger.log('No-auth test URL: $serverUrl');
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse(testUrl),
|
||||
// Don't follow redirects - we want to see if server requires auth
|
||||
).timeout(const Duration(seconds: 10));
|
||||
final isLocalIp = _isLocalIpAddress(uri.host);
|
||||
int statusCode;
|
||||
|
||||
_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
|
||||
// 302/307 = redirect (probably to auth)
|
||||
// 302/307/308 = redirect (probably to auth or HTTPS)
|
||||
// 401 = auth required
|
||||
if (response.statusCode == 200) {
|
||||
if (statusCode == 200) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -156,4 +156,70 @@ class DatabaseService {
|
||||
|
||||
/// Clear all cached data (useful for logout)
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1069,16 +1069,14 @@ class MusicAssistantAPI {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Batch fetch track details using parallel requests (max 5 concurrent)
|
||||
final seenAlbumIds = <String>{};
|
||||
final albums = <Album>[];
|
||||
// Step 1: Fetch tracks in batches and collect unique album URIs
|
||||
final seenAlbumUris = <String>{};
|
||||
final albumUris = <String>[];
|
||||
|
||||
// Process in batches to avoid overwhelming the server
|
||||
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();
|
||||
|
||||
// Fetch batch in parallel
|
||||
final futures = batch.map((uri) => _sendCommand(
|
||||
'music/item_by_uri',
|
||||
args: {'uri': uri},
|
||||
@@ -1087,27 +1085,52 @@ class MusicAssistantAPI {
|
||||
final results = await Future.wait(futures, eagerError: false);
|
||||
|
||||
for (final trackResponse in results) {
|
||||
if (albums.length >= limit) break;
|
||||
if (albumUris.length >= limit) break;
|
||||
|
||||
try {
|
||||
final fullTrack = trackResponse['result'] as Map<String, dynamic>?;
|
||||
final albumData = fullTrack?['album'] as Map<String, dynamic>?;
|
||||
final albumUri = albumData?['uri'] as String?;
|
||||
|
||||
if (albumData != null) {
|
||||
final albumId = albumData['item_id']?.toString() ?? albumData['uri']?.toString();
|
||||
if (albumId != null && !seenAlbumIds.contains(albumId)) {
|
||||
seenAlbumIds.add(albumId);
|
||||
albums.add(Album.fromJson(albumData));
|
||||
}
|
||||
if (albumUri != null && !seenAlbumUris.contains(albumUri)) {
|
||||
seenAlbumUris.add(albumUri);
|
||||
albumUris.add(albumUri);
|
||||
}
|
||||
} catch (_) {
|
||||
// Skip tracks that fail to parse
|
||||
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;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error getting recent albums: $e');
|
||||
@@ -2302,89 +2325,15 @@ class MusicAssistantAPI {
|
||||
return _eventStreams['sendspin_player']!.stream;
|
||||
}
|
||||
|
||||
/// Get Sendspin connection info for a player
|
||||
/// Returns connection details including:
|
||||
/// - local_ws_url: Direct WebSocket URL (e.g., ws://192.168.4.120:8927/sendspin)
|
||||
/// - ice_servers: List of ICE/TURN servers for WebRTC fallback
|
||||
/// - player_id: The player's ID
|
||||
Future<Map<String, dynamic>?> getSendspinConnectionInfo(String playerId) async {
|
||||
try {
|
||||
_logger.log('Sendspin: Getting connection info for player $playerId');
|
||||
|
||||
final response = await _sendCommand(
|
||||
'sendspin/connection_info',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
},
|
||||
);
|
||||
|
||||
final result = response['result'] as Map<String, dynamic>?;
|
||||
if (result != null) {
|
||||
_logger.log('Sendspin: Got connection info - local_ws_url: ${result['local_ws_url']}');
|
||||
_logger.log('Sendspin: ICE servers count: ${(result['ice_servers'] as List?)?.length ?? 0}');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Error getting connection info: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send WebRTC offer to Sendspin server
|
||||
/// Used when establishing WebRTC connection as fallback
|
||||
Future<Map<String, dynamic>?> sendspinOffer(String playerId, String sdp) async {
|
||||
try {
|
||||
_logger.log('Sendspin: Sending WebRTC offer for player $playerId');
|
||||
|
||||
final response = await _sendCommand(
|
||||
'sendspin/webrtc_offer',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
'sdp': sdp,
|
||||
},
|
||||
);
|
||||
|
||||
return response['result'] as Map<String, dynamic>?;
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Error sending WebRTC offer: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send WebRTC answer to Sendspin server
|
||||
Future<void> sendspinAnswer(String playerId, String sdp) async {
|
||||
try {
|
||||
_logger.log('Sendspin: Sending WebRTC answer for player $playerId');
|
||||
|
||||
await _sendCommand(
|
||||
'sendspin/webrtc_answer',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
'sdp': sdp,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Error sending WebRTC answer: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send ICE candidate to Sendspin server
|
||||
Future<void> sendspinIceCandidate(String playerId, Map<String, dynamic> candidate) async {
|
||||
try {
|
||||
await _sendCommand(
|
||||
'sendspin/ice_candidate',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
'candidate': candidate,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Error sending ICE candidate: $e');
|
||||
// Don't rethrow - ICE candidate failures are common and non-fatal
|
||||
}
|
||||
}
|
||||
// NOTE: The following Sendspin API methods were removed because they don't exist in MA:
|
||||
// - getSendspinConnectionInfo (sendspin/connection_info)
|
||||
// - sendspinOffer (sendspin/webrtc_offer)
|
||||
// - sendspinAnswer (sendspin/webrtc_answer)
|
||||
// - sendspinIceCandidate (sendspin/ice_candidate)
|
||||
//
|
||||
// Sendspin connection is handled directly via WebSocket to:
|
||||
// - Local: ws://{server-ip}:8927/sendspin
|
||||
// - External: wss://{server}/sendspin (via MA's proxy with auth)
|
||||
|
||||
/// Update Sendspin player state
|
||||
/// 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;
|
||||
|
||||
try {
|
||||
// Store images metadata for instant artwork display
|
||||
final images = album.metadata?['images'];
|
||||
await _db.addRecentlyPlayed(
|
||||
mediaId: album.itemId,
|
||||
mediaType: 'album',
|
||||
@@ -33,6 +35,7 @@ class RecentlyPlayedService {
|
||||
metadata: {
|
||||
'provider': album.provider,
|
||||
'uri': album.uri,
|
||||
if (images != null) 'images': images,
|
||||
},
|
||||
);
|
||||
_logger.log('📝 Recorded album play: ${album.name}');
|
||||
@@ -151,6 +154,12 @@ class RecentlyPlayedService {
|
||||
} 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(
|
||||
itemId: item.mediaId,
|
||||
provider: metadata?['provider'] ?? 'library',
|
||||
@@ -160,6 +169,7 @@ class RecentlyPlayedService {
|
||||
artists: item.artistName != null
|
||||
? [Artist(itemId: '', provider: '', name: item.artistName!)]
|
||||
: null,
|
||||
metadata: albumMetadata,
|
||||
));
|
||||
|
||||
if (albums.length >= limit) break;
|
||||
|
||||
@@ -120,12 +120,11 @@ class SendspinService {
|
||||
connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 5), useProxyAuth: true);
|
||||
}
|
||||
|
||||
// Strategy 2: If external failed or server is HTTP, try local connection
|
||||
// Note: We would need local_ws_url from getSendspinConnectionInfo API
|
||||
// For now, skip this step and fall back to WebRTC (handled by provider)
|
||||
// Note: Local network connections (ws://ip:8927/sendspin) are handled
|
||||
// by the provider calling connectWithUrl() directly, not by this method.
|
||||
|
||||
if (!connected) {
|
||||
_logger.log('Sendspin: External connection failed, WebRTC fallback needed');
|
||||
_logger.log('Sendspin: External proxy connection failed');
|
||||
_updateState(SendspinConnectionState.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ class SettingsService {
|
||||
static const String _keyLocalPlayerName = 'local_player_name';
|
||||
static const String _keyOwnerName = 'owner_name';
|
||||
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 _keyShowDiscoverArtists = 'show_discover_artists';
|
||||
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
|
||||
@@ -364,6 +366,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
|
||||
static String _makePlayerName(String ownerName) {
|
||||
// Handle possessive: "Chris" -> "Chris' Phone", "Mom" -> "Mom's Phone"
|
||||
|
||||
@@ -4,7 +4,17 @@ import 'package:flutter/material.dart';
|
||||
const Color brandPrimaryColor = Color(0xFF1a1a1a);
|
||||
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(
|
||||
seedColor: brandAccentColor,
|
||||
brightness: Brightness.light,
|
||||
@@ -21,6 +31,27 @@ final ColorScheme brandDarkColorScheme = ColorScheme.fromSeed(
|
||||
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 {
|
||||
// Generate light theme
|
||||
static ThemeData lightTheme({ColorScheme? colorScheme}) {
|
||||
|
||||
@@ -97,6 +97,7 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
child: AlbumDetailsScreen(
|
||||
album: widget.album,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -119,7 +120,8 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: const Duration(milliseconds: 150),
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
errorWidget: (context, url, error) {
|
||||
// Try fallback on error (only for MA URLs, not fallback URLs)
|
||||
|
||||
+111
-69
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
@@ -10,6 +11,8 @@ class AlbumRow extends StatefulWidget {
|
||||
final Future<List<Album>> Function() loadAlbums;
|
||||
final String? heroTagSuffix;
|
||||
final double? rowHeight;
|
||||
/// Optional: synchronous getter for cached data (for instant display)
|
||||
final List<Album>? Function()? getCachedAlbums;
|
||||
|
||||
const AlbumRow({
|
||||
super.key,
|
||||
@@ -17,6 +20,7 @@ class AlbumRow extends StatefulWidget {
|
||||
required this.loadAlbums,
|
||||
this.heroTagSuffix,
|
||||
this.rowHeight,
|
||||
this.getCachedAlbums,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -24,8 +28,8 @@ class AlbumRow extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin {
|
||||
late Future<List<Album>> _albumsFuture;
|
||||
List<Album>? _cachedAlbums; // Keep last loaded data to prevent flash
|
||||
List<Album> _albums = [];
|
||||
bool _isLoading = true;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
@override
|
||||
@@ -34,21 +38,117 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAlbumsOnce();
|
||||
_loadAlbums();
|
||||
}
|
||||
|
||||
void _loadAlbumsOnce() {
|
||||
if (!_hasLoaded) {
|
||||
_albumsFuture = widget.loadAlbums().then((albums) {
|
||||
_cachedAlbums = albums;
|
||||
return albums;
|
||||
});
|
||||
_hasLoaded = true;
|
||||
Future<void> _loadAlbums() async {
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// 1. Try to get cached data synchronously for instant display
|
||||
final cachedAlbums = widget.getCachedAlbums?.call();
|
||||
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();
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
_logger.startBuild('AlbumRow:${widget.title}');
|
||||
@@ -78,65 +178,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FutureBuilder<List<Album>>(
|
||||
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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: _buildContent(contentHeight, colorScheme),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -100,6 +100,8 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
|
||||
width: widget.radius * 2,
|
||||
height: widget.radius * 2,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant),
|
||||
|
||||
@@ -100,49 +100,50 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Artist image - circular, constrained to be a proper circle
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Use the smaller dimension to ensure a circle
|
||||
final size = constraints.maxWidth < constraints.maxHeight
|
||||
? constraints.maxWidth
|
||||
: constraints.maxHeight;
|
||||
return Hero(
|
||||
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
|
||||
child: ClipOval(
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: const Duration(milliseconds: 150),
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
errorWidget: (context, url, error) {
|
||||
// Try fallback on error (only for MA URLs, not fallback URLs)
|
||||
if (!_maImageFailed && url == maImageUrl) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_onImageError();
|
||||
});
|
||||
}
|
||||
return Icon(
|
||||
Icons.person_rounded,
|
||||
size: size * 0.55,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
);
|
||||
},
|
||||
)
|
||||
: Icon(Icons.person_rounded, size: size * 0.55, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
// Artist image - circular
|
||||
// PERF: Use AspectRatio instead of LayoutBuilder to provide fixed geometry
|
||||
// before Hero animation starts. This prevents grey icon flash caused by
|
||||
// dynamic sizing inside the Hero widget.
|
||||
AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Hero(
|
||||
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
|
||||
child: ClipOval(
|
||||
child: Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
errorWidget: (context, url, error) {
|
||||
// Try fallback on error (only for MA URLs, not fallback URLs)
|
||||
if (!_maImageFailed && url == maImageUrl) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_onImageError();
|
||||
});
|
||||
}
|
||||
return Center(
|
||||
child: Icon(
|
||||
Icons.person_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.person_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+111
-69
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
@@ -10,6 +11,8 @@ class ArtistRow extends StatefulWidget {
|
||||
final Future<List<Artist>> Function() loadArtists;
|
||||
final String? heroTagSuffix;
|
||||
final double? rowHeight;
|
||||
/// Optional: synchronous getter for cached data (for instant display)
|
||||
final List<Artist>? Function()? getCachedArtists;
|
||||
|
||||
const ArtistRow({
|
||||
super.key,
|
||||
@@ -17,6 +20,7 @@ class ArtistRow extends StatefulWidget {
|
||||
required this.loadArtists,
|
||||
this.heroTagSuffix,
|
||||
this.rowHeight,
|
||||
this.getCachedArtists,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -24,8 +28,8 @@ class ArtistRow extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixin {
|
||||
late Future<List<Artist>> _artistsFuture;
|
||||
List<Artist>? _cachedArtists; // Keep last loaded data to prevent flash
|
||||
List<Artist> _artists = [];
|
||||
bool _isLoading = true;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
@override
|
||||
@@ -34,21 +38,117 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadArtistsOnce();
|
||||
_loadArtists();
|
||||
}
|
||||
|
||||
void _loadArtistsOnce() {
|
||||
if (!_hasLoaded) {
|
||||
_artistsFuture = widget.loadArtists().then((artists) {
|
||||
_cachedArtists = artists;
|
||||
return artists;
|
||||
});
|
||||
_hasLoaded = true;
|
||||
Future<void> _loadArtists() async {
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// 1. Try to get cached data synchronously for instant display
|
||||
final cachedArtists = widget.getCachedArtists?.call();
|
||||
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();
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
_logger.startBuild('ArtistRow:${widget.title}');
|
||||
@@ -78,65 +178,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FutureBuilder<List<Artist>>(
|
||||
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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: _buildContent(contentHeight, colorScheme),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -173,6 +173,7 @@ class _AudiobookCard extends StatelessWidget {
|
||||
child: AudiobookDetailScreen(
|
||||
audiobook: audiobook,
|
||||
heroTagSuffix: heroTagSuffix,
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -195,6 +196,11 @@ class _AudiobookCard extends StatelessWidget {
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// PERF: Duration.zero for hero-wrapped images
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
placeholder: (_, __) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
|
||||
+276
-206
@@ -15,6 +15,7 @@ import 'animated_icon_button.dart';
|
||||
import 'global_player_overlay.dart';
|
||||
import 'volume_control.dart';
|
||||
import 'player/player_widgets.dart';
|
||||
import 'player/mini_player_content.dart';
|
||||
|
||||
/// A unified player widget that seamlessly expands from mini to full-screen.
|
||||
///
|
||||
@@ -25,9 +26,17 @@ class ExpandablePlayer extends StatefulWidget {
|
||||
/// Slide offset for hiding the mini player (0.0 = visible, 1.0 = hidden below screen)
|
||||
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;
|
||||
|
||||
const ExpandablePlayer({
|
||||
super.key,
|
||||
this.slideOffset = 0.0,
|
||||
this.bounceOffset = 0.0,
|
||||
this.onRevealPlayers,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -91,6 +100,18 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
String? _lastMeasuredTrackName;
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -98,6 +119,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
// Simple curved animation - performant and smooth
|
||||
_expandAnimation = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutCubic,
|
||||
@@ -128,7 +151,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
);
|
||||
|
||||
_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();
|
||||
} else if (status == AnimationStatus.dismissed) {
|
||||
// Close queue panel when player collapses
|
||||
@@ -182,23 +207,94 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
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() {
|
||||
if (_isVerticalDragging) return;
|
||||
AnimationDebugger.startSession('playerExpand');
|
||||
_controller.duration = _expandDuration;
|
||||
_controller.forward().then((_) {
|
||||
AnimationDebugger.endSession();
|
||||
});
|
||||
}
|
||||
|
||||
void collapse() {
|
||||
if (_isVerticalDragging) return;
|
||||
AnimationDebugger.startSession('playerCollapse');
|
||||
// Instantly hide queue panel when collapsing to avoid visual glitches
|
||||
// during Android's predictive back gesture
|
||||
_queuePanelController.value = 0;
|
||||
_controller.duration = _collapseDuration;
|
||||
_controller.reverse().then((_) {
|
||||
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;
|
||||
|
||||
double get expansionProgress => _controller.value;
|
||||
@@ -227,24 +323,49 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
Future<void> _loadQueue() async {
|
||||
if (_isLoadingQueue) return;
|
||||
|
||||
setState(() => _isLoadingQueue = true);
|
||||
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final player = maProvider.selectedPlayer;
|
||||
|
||||
if (player != null && maProvider.api != null) {
|
||||
final queue = await maProvider.api!.getQueue(player.playerId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_queue = queue;
|
||||
_isLoadingQueue = false;
|
||||
});
|
||||
}
|
||||
if (player == null) {
|
||||
if (mounted) setState(() => _isLoadingQueue = false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if (mounted) {
|
||||
setState(() => _isLoadingQueue = false);
|
||||
setState(() => _isLoadingQueue = true);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -324,14 +445,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 {
|
||||
if (currentTrack == null) return;
|
||||
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
if (maProvider.api == null) return;
|
||||
|
||||
try {
|
||||
bool success;
|
||||
|
||||
if (_isCurrentTrackFavorite) {
|
||||
// Remove from favorites - need library_item_id (numeric)
|
||||
int? libraryItemId;
|
||||
@@ -353,7 +475,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
}
|
||||
|
||||
if (libraryItemId != null) {
|
||||
await maProvider.api!.removeFromFavorites('track', libraryItemId);
|
||||
success = await maProvider.removeFromFavorites(
|
||||
mediaType: 'track',
|
||||
libraryItemId: libraryItemId,
|
||||
);
|
||||
} else {
|
||||
throw Exception('Could not determine library ID for this track');
|
||||
}
|
||||
@@ -386,16 +511,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
|
||||
setState(() {
|
||||
_isCurrentTrackFavorite = !_isCurrentTrackFavorite;
|
||||
});
|
||||
if (success) {
|
||||
// Toggle local state
|
||||
setState(() {
|
||||
_isCurrentTrackFavorite = !_isCurrentTrackFavorite;
|
||||
});
|
||||
|
||||
// Invalidate home cache so favorites are updated
|
||||
maProvider.invalidateHomeCache();
|
||||
// Invalidate home cache so favorites are updated
|
||||
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) {
|
||||
// Show error feedback
|
||||
if (mounted) {
|
||||
@@ -850,9 +996,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
final bottomOffset = bottomNavSpace + _collapsedMargin;
|
||||
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 adjustedBottomOffset = bottomOffset - slideDownAmount;
|
||||
final bounceDownAmount = widget.bounceOffset;
|
||||
final adjustedBottomOffset = bottomOffset - slideDownAmount - bounceDownAmount;
|
||||
|
||||
final availablePlayers = _getAvailablePlayersSorted(maProvider);
|
||||
final hasMultiplePlayers = availablePlayers.length > 1;
|
||||
@@ -861,7 +1008,13 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
left: _collapsedMargin,
|
||||
right: _collapsedMargin,
|
||||
bottom: adjustedBottomOffset,
|
||||
child: DeviceSelectorBar(
|
||||
child: GestureDetector(
|
||||
onVerticalDragUpdate: (details) {
|
||||
if (details.primaryDelta! > 5 && widget.onRevealPlayers != null) {
|
||||
widget.onRevealPlayers!();
|
||||
}
|
||||
},
|
||||
child: DeviceSelectorBar(
|
||||
selectedPlayer: selectedPlayer,
|
||||
peekPlayer: _peekPlayer,
|
||||
hasMultiplePlayers: hasMultiplePlayers,
|
||||
@@ -897,6 +1050,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
_handleHorizontalDragEnd(details, maProvider);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -954,11 +1108,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
final expandedHeight = screenSize.height - bottomNavSpace;
|
||||
|
||||
// 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 slideAdjustedBottomOffset = t < 0.1
|
||||
? collapsedBottomOffset - slideDownAmount
|
||||
: _lerpDouble(collapsedBottomOffset, expandedBottomOffset, t);
|
||||
final bounceDownAmount = widget.bounceOffset;
|
||||
// Fade out slide/bounce effects smoothly over first 15% of animation
|
||||
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 width = _lerpDouble(collapsedWidth, screenSize.width, t);
|
||||
@@ -997,23 +1154,18 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
final expandedArtTop = topPadding + headerHeight + 16;
|
||||
final artTop = _lerpDouble(collapsedArtTop, expandedArtTop, t);
|
||||
|
||||
// Typography - clear hierarchy
|
||||
// Title: bold, prominent (24px)
|
||||
// Artist: medium weight, secondary (18px expanded, was 16px)
|
||||
// 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
|
||||
// Typography - uses shared MiniPlayerLayout constants for collapsed state
|
||||
final titleFontSize = _lerpDouble(MiniPlayerLayout.primaryFontSize, 24.0, t);
|
||||
final artistFontSize = _lerpDouble(MiniPlayerLayout.secondaryFontSize, 18.0, t);
|
||||
|
||||
final collapsedTitleLeft = _collapsedArtSize + 8; // Reduced from 12 to 8 (4px less)
|
||||
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
|
||||
// For 8px gap: text ends at widgetWidth - 114 - 8 = widgetWidth - 122
|
||||
// Note: collapsedWidth = screenSize.width - 24 (12px margin each side)
|
||||
final collapsedTitleWidth = collapsedWidth - collapsedTitleLeft - 122;
|
||||
final collapsedTitleWidth = collapsedWidth - MiniPlayerLayout.textLeft - 122;
|
||||
final expandedTitleWidth = screenSize.width - (contentPadding * 2);
|
||||
final titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t);
|
||||
|
||||
@@ -1070,7 +1222,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
final titleTop = _lerpDouble(collapsedTitleTop, expandedTitleTop, t);
|
||||
|
||||
// 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 artistTop = _lerpDouble(collapsedArtistTop, expandedArtistTop, t);
|
||||
|
||||
@@ -1102,7 +1254,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
final selectedPlayerId = selectedPlayer.playerId;
|
||||
|
||||
// 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(
|
||||
left: horizontalMargin,
|
||||
@@ -1113,13 +1266,47 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
behavior: HitTestBehavior.translucent,
|
||||
// Only handle tap when collapsed - when expanded, let children handle their own taps
|
||||
onTap: isExpanded ? null : expand,
|
||||
onVerticalDragUpdate: (details) {
|
||||
if (details.primaryDelta! < -10 && !isExpanded) {
|
||||
expand();
|
||||
} else if (details.primaryDelta! > 10 && isExpanded && !isQueuePanelOpen) {
|
||||
collapse();
|
||||
onVerticalDragStart: (details) {
|
||||
// For expanded player or queue panel: start tracking immediately
|
||||
// For collapsed player: defer decision until we know swipe direction
|
||||
if (isExpanded || isQueuePanelOpen) {
|
||||
_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) {
|
||||
_horizontalDragStartX = details.globalPosition.dx;
|
||||
},
|
||||
@@ -1176,41 +1363,41 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
// Shows played portion with brighter color, unplayed with darker
|
||||
// Starts after album art so progress is visible
|
||||
// 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))
|
||||
Positioned(
|
||||
left: _collapsedArtSize + miniPlayerSlideOffset,
|
||||
top: 0,
|
||||
width: width - _collapsedArtSize,
|
||||
bottom: 0,
|
||||
child: ValueListenableBuilder<int>(
|
||||
valueListenable: _progressNotifier,
|
||||
builder: (context, elapsedSeconds, _) {
|
||||
final totalSeconds = currentTrack!.duration!.inSeconds;
|
||||
if (totalSeconds <= 0) return const SizedBox.shrink();
|
||||
final progress = (elapsedSeconds / totalSeconds).clamp(0.0, 1.0);
|
||||
// Fade out as we expand
|
||||
final progressOpacity = (1.0 - (t / 0.5)).clamp(0.0, 1.0);
|
||||
final progressAreaWidth = width - _collapsedArtSize;
|
||||
return Opacity(
|
||||
opacity: progressOpacity,
|
||||
child: ClipRRect(
|
||||
left: _collapsedArtSize + miniPlayerSlideOffset,
|
||||
top: 0,
|
||||
width: width - _collapsedArtSize,
|
||||
bottom: 0,
|
||||
child: ValueListenableBuilder<int>(
|
||||
valueListenable: _progressNotifier,
|
||||
builder: (context, elapsedSeconds, _) {
|
||||
final totalSeconds = currentTrack!.duration!.inSeconds;
|
||||
if (totalSeconds <= 0) return const SizedBox.shrink();
|
||||
final progress = (elapsedSeconds / totalSeconds).clamp(0.0, 1.0);
|
||||
// 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 progressAreaWidth = width - _collapsedArtSize;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.only(
|
||||
topRight: Radius.circular(borderRadius),
|
||||
bottomRight: Radius.circular(borderRadius),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: progressAreaWidth * progress,
|
||||
height: height,
|
||||
color: collapsedBg,
|
||||
color: collapsedBg.withOpacity(progressOpacity),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Peek player content (shows when dragging OR during transition)
|
||||
// Show when: actively dragging (slideOffset != 0) OR in transition state
|
||||
@@ -1218,20 +1405,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
if (t < 0.1 && _peekPlayer != null && (_slideOffset.abs() > 0.01 || _inTransition))
|
||||
_buildPeekContent(
|
||||
context: context,
|
||||
maProvider: maProvider,
|
||||
peekPlayer: _peekPlayer,
|
||||
peekImageUrl: _peekImageUrl,
|
||||
slideOffset: _slideOffset,
|
||||
containerWidth: collapsedWidth,
|
||||
artSize: _collapsedArtSize,
|
||||
titleLeft: collapsedTitleLeft,
|
||||
titleTop: collapsedTitleTop,
|
||||
titleWidth: collapsedTitleWidth,
|
||||
artistTop: collapsedArtistTop,
|
||||
titleFontSize: 16.0,
|
||||
artistFontSize: 14.0,
|
||||
backgroundColor: collapsedBg,
|
||||
textColor: textColor,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
|
||||
// Album art - with slide animation when collapsed
|
||||
@@ -1303,9 +1482,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
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,
|
||||
height: 1.2,
|
||||
height: t > 0.5 ? 1.2 : null, // Only use line height when expanded
|
||||
),
|
||||
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
|
||||
maxLines: t > 0.5 ? 2 : 1,
|
||||
@@ -1331,7 +1510,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor)
|
||||
: currentTrack.artistsString,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(t > 0.5 ? 0.7 : 0.6),
|
||||
color: textColor.withOpacity(t > 0.5 ? 0.7 : MiniPlayerLayout.secondaryTextOpacity),
|
||||
fontSize: artistFontSize,
|
||||
fontWeight: t > 0.5 ? FontWeight.w400 : FontWeight.normal,
|
||||
),
|
||||
@@ -1712,158 +1891,49 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
}
|
||||
|
||||
/// Build the peek player content that slides in from the edge during drag
|
||||
/// Uses shared MiniPlayerContent for consistency
|
||||
Widget _buildPeekContent({
|
||||
required BuildContext context,
|
||||
required MusicAssistantProvider maProvider,
|
||||
required dynamic peekPlayer,
|
||||
required String? peekImageUrl,
|
||||
required double slideOffset,
|
||||
required double containerWidth,
|
||||
required double artSize,
|
||||
required double titleLeft,
|
||||
required double titleTop,
|
||||
required double titleWidth,
|
||||
required double artistTop,
|
||||
required double titleFontSize,
|
||||
required double artistFontSize,
|
||||
required Color backgroundColor,
|
||||
required Color textColor,
|
||||
required ColorScheme colorScheme,
|
||||
}) {
|
||||
// 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;
|
||||
|
||||
if (_inTransition && slideOffset.abs() < 0.01) {
|
||||
// During transition with slideOffset at 0, show peek at center
|
||||
peekBaseOffset = 0.0;
|
||||
} else {
|
||||
final isFromRight = slideOffset < 0;
|
||||
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
|
||||
? containerWidth * (1 - peekProgress) // Slides in from right
|
||||
: -containerWidth * (1 - peekProgress); // Slides in from left
|
||||
? containerWidth * (1 - peekProgress)
|
||||
: -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 peekTrackName = hasTrack ? _peekTrack!.name : (peekPlayer?.name ?? S.of(context)!.unknown);
|
||||
final peekArtistName = hasTrack ? (_peekTrack!.artistsString ?? '') : S.of(context)!.swipeToSwitchDevice;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Peek album art / device icon
|
||||
Positioned(
|
||||
left: peekBaseOffset,
|
||||
top: 0,
|
||||
child: Container(
|
||||
width: artSize,
|
||||
height: artSize,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
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,
|
||||
),
|
||||
return Transform.translate(
|
||||
offset: Offset(peekBaseOffset, 0),
|
||||
child: MiniPlayerContent(
|
||||
primaryText: peekTrackName,
|
||||
secondaryText: peekArtistName,
|
||||
imageUrl: hasTrack ? peekImageUrl : null,
|
||||
playerName: peekPlayer?.name ?? '',
|
||||
backgroundColor: backgroundColor,
|
||||
textColor: textColor,
|
||||
width: containerWidth,
|
||||
slideOffset: 0, // Transform handles positioning
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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
|
||||
Widget _buildPeekProgressBar({
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import 'expandable_player.dart';
|
||||
import 'player/player_reveal_overlay.dart';
|
||||
|
||||
/// Cached color with contrast adjustment
|
||||
/// Avoids expensive HSL conversions during scroll
|
||||
@@ -111,13 +113,51 @@ class GlobalPlayerOverlay extends StatefulWidget {
|
||||
static void showPlayer() {
|
||||
_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 the bounce animation on mini player (called when overlay dismiss starts)
|
||||
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>
|
||||
with SingleTickerProviderStateMixin {
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _slideController;
|
||||
late Animation<double> _slideAnimation;
|
||||
|
||||
// Controller for player reveal animation with bounce
|
||||
late AnimationController _bounceController;
|
||||
late Animation<double> _bounceAnimation;
|
||||
|
||||
// State for player reveal overlay
|
||||
bool _isRevealVisible = false;
|
||||
|
||||
// Use ValueNotifier instead of setState for bounce offset
|
||||
// This prevents full widget tree rebuilds during animation
|
||||
final _bounceOffsetNotifier = ValueNotifier<double>(0.0);
|
||||
|
||||
// Key for the reveal overlay
|
||||
final _revealKey = GlobalKey<PlayerRevealOverlayState>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -130,11 +170,31 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
|
||||
// Bounce animation - quick dip down then back up
|
||||
_bounceController = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
vsync: this,
|
||||
);
|
||||
_bounceAnimation = CurvedAnimation(
|
||||
parent: _bounceController,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
|
||||
// Use ValueNotifier instead of setState to avoid full widget rebuilds
|
||||
// This isolates the rebuild to only the widgets that depend on bounce offset
|
||||
_bounceController.addListener(() {
|
||||
final t = _bounceAnimation.value;
|
||||
// Sine curve: 0 -> 1 -> 0 as t goes 0 -> 0.5 -> 1
|
||||
_bounceOffsetNotifier.value = 10.0 * (t < 0.5 ? t * 2 : (1.0 - t) * 2);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_slideController.dispose();
|
||||
_bounceController.dispose();
|
||||
_bounceOffsetNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -146,12 +206,55 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
}
|
||||
}
|
||||
|
||||
void _showPlayerReveal() {
|
||||
if (_isRevealVisible) return;
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
}
|
||||
HapticFeedback.mediumImpact();
|
||||
_bounceController.reset();
|
||||
setState(() {
|
||||
_isRevealVisible = true;
|
||||
});
|
||||
_bounceController.forward();
|
||||
}
|
||||
|
||||
void _hidePlayerReveal() {
|
||||
if (!_isRevealVisible) return;
|
||||
_bounceOffsetNotifier.value = 0;
|
||||
setState(() {
|
||||
_isRevealVisible = 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
|
||||
// Note: dismiss() triggers the bounce animation internally
|
||||
_revealKey.currentState?.dismiss();
|
||||
}
|
||||
|
||||
/// Trigger bounce animation on mini player
|
||||
void _triggerBounce() {
|
||||
_bounceController.reset();
|
||||
_bounceController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Stack(
|
||||
// Handle back gesture at top level - dismiss device list if visible
|
||||
return PopScope(
|
||||
canPop: !_isRevealVisible,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop && _isRevealVisible) {
|
||||
_dismissPlayerReveal();
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
// The main app content (Navigator, screens, etc.)
|
||||
// Add bottom padding to account for bottom nav + mini player
|
||||
@@ -259,7 +362,16 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
},
|
||||
),
|
||||
),
|
||||
// Global player overlay - slides down when hidden, renders ON TOP of bottom nav
|
||||
// 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,
|
||||
),
|
||||
|
||||
// Global player overlay - renders ON TOP so cards slide behind it
|
||||
// Use Selector instead of Consumer to avoid rebuilds during animation
|
||||
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer})>(
|
||||
selector: (_, provider) => (
|
||||
@@ -271,20 +383,28 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
if (!state.isConnected || !state.hasPlayer) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return AnimatedBuilder(
|
||||
animation: _slideAnimation,
|
||||
child: ExpandablePlayer(key: globalPlayerKey, slideOffset: 0),
|
||||
builder: (context, staticChild) {
|
||||
// Only slideOffset changes during animation, player widget stays same
|
||||
return ExpandablePlayer(
|
||||
key: globalPlayerKey,
|
||||
slideOffset: _slideAnimation.value,
|
||||
// Combine slide and bounce animations with ValueListenableBuilder
|
||||
// This prevents full widget tree rebuilds - only ExpandablePlayer updates
|
||||
return ValueListenableBuilder<double>(
|
||||
valueListenable: _bounceOffsetNotifier,
|
||||
builder: (context, bounceOffset, _) {
|
||||
return AnimatedBuilder(
|
||||
animation: _slideAnimation,
|
||||
builder: (context, _) {
|
||||
return ExpandablePlayer(
|
||||
key: globalPlayerKey,
|
||||
slideOffset: _slideAnimation.value,
|
||||
bounceOffset: bounceOffset,
|
||||
onRevealPlayers: _showPlayerReveal,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.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
|
||||
class DeviceSelectorBar extends StatelessWidget {
|
||||
@@ -35,6 +35,8 @@ class DeviceSelectorBar extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final swipeHint = hasMultiplePlayers ? S.of(context)!.swipeToSwitchDevice : null;
|
||||
|
||||
return GestureDetector(
|
||||
onHorizontalDragStart: hasMultiplePlayers ? onHorizontalDragStart : null,
|
||||
onHorizontalDragUpdate: hasMultiplePlayers ? onHorizontalDragUpdate : null,
|
||||
@@ -53,50 +55,18 @@ class DeviceSelectorBar extends StatelessWidget {
|
||||
children: [
|
||||
// Peek player content (shows when dragging)
|
||||
if (slideOffset.abs() > 0.01 && peekPlayer != null)
|
||||
_buildPeekContent(context),
|
||||
_buildPeekContent(context, swipeHint),
|
||||
|
||||
// Current player content
|
||||
Transform.translate(
|
||||
offset: Offset(slideOffset * width, 0),
|
||||
child: Padding(
|
||||
padding: Spacing.paddingH16,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getPlayerIcon(selectedPlayer.name),
|
||||
color: textColor,
|
||||
size: IconSizes.md,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
MiniPlayerContent(
|
||||
primaryText: selectedPlayer.name,
|
||||
secondaryText: swipeHint,
|
||||
imageUrl: null,
|
||||
playerName: selectedPlayer.name,
|
||||
backgroundColor: backgroundColor,
|
||||
textColor: textColor,
|
||||
width: width,
|
||||
slideOffset: slideOffset,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -107,7 +77,7 @@ class DeviceSelectorBar extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 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 peekProgress = slideOffset.abs();
|
||||
|
||||
@@ -118,61 +88,16 @@ class DeviceSelectorBar extends StatelessWidget {
|
||||
|
||||
return Transform.translate(
|
||||
offset: Offset(peekBaseOffset, 0),
|
||||
child: Padding(
|
||||
padding: Spacing.paddingH16,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getPlayerIcon(peekPlayer.name),
|
||||
color: textColor,
|
||||
size: IconSizes.md,
|
||||
),
|
||||
Spacing.hGap12,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: MiniPlayerContent(
|
||||
primaryText: peekPlayer.name,
|
||||
secondaryText: swipeHint,
|
||||
imageUrl: null,
|
||||
playerName: peekPlayer.name,
|
||||
backgroundColor: backgroundColor,
|
||||
textColor: textColor,
|
||||
width: width,
|
||||
slideOffset: 0, // No additional slide - Transform handles positioning
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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,189 @@
|
||||
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 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.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
|
||||
if (hasSecondaryLine)
|
||||
Positioned(
|
||||
left: MiniPlayerLayout.textLeft + slidePixels,
|
||||
top: MiniPlayerLayout.secondaryTop,
|
||||
right: MiniPlayerLayout.textRightPadding - slidePixels,
|
||||
child: Text(
|
||||
secondaryText!,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
|
||||
fontSize: MiniPlayerLayout.secondaryFontSize,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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,233 @@
|
||||
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 Color backgroundColor;
|
||||
final Color textColor;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onPlayPause;
|
||||
final VoidCallback? onSkipNext;
|
||||
final VoidCallback? onPower;
|
||||
|
||||
const PlayerCard({
|
||||
super.key,
|
||||
required this.player,
|
||||
this.trackInfo,
|
||||
this.albumArtUrl,
|
||||
required this.isSelected,
|
||||
required this.isPlaying,
|
||||
required this.backgroundColor,
|
||||
required this.textColor,
|
||||
required this.onTap,
|
||||
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,
|
||||
child: Container(
|
||||
height: cardHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
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,
|
||||
children: [
|
||||
// Shuffle (expanded only)
|
||||
if (isExpanded)
|
||||
Opacity(
|
||||
opacity: expandedElementsOpacity,
|
||||
child: _buildSecondaryButton(
|
||||
icon: Icons.shuffle_rounded,
|
||||
color: shuffle == true ? primaryColor : textColor.withOpacity(0.5),
|
||||
onPressed: isLoadingQueue ? null : onToggleShuffle,
|
||||
),
|
||||
// GPU PERF: Use color alpha instead of Opacity to avoid saveLayer
|
||||
if (isExpanded && expandedElementsOpacity > 0.1)
|
||||
_buildSecondaryButton(
|
||||
icon: Icons.shuffle_rounded,
|
||||
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
|
||||
.withOpacity(expandedElementsOpacity),
|
||||
onPressed: isLoadingQueue ? null : onToggleShuffle,
|
||||
),
|
||||
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
|
||||
|
||||
@@ -90,17 +89,16 @@ class PlayerControls extends StatelessWidget {
|
||||
),
|
||||
|
||||
// Repeat (expanded only)
|
||||
// GPU PERF: Use color alpha instead of Opacity to avoid saveLayer
|
||||
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
|
||||
if (isExpanded)
|
||||
Opacity(
|
||||
opacity: expandedElementsOpacity,
|
||||
child: _buildSecondaryButton(
|
||||
icon: repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
|
||||
color: repeatMode != null && repeatMode != 'off'
|
||||
? primaryColor
|
||||
: textColor.withOpacity(0.5),
|
||||
onPressed: isLoadingQueue ? null : onCycleRepeat,
|
||||
),
|
||||
if (isExpanded && expandedElementsOpacity > 0.1)
|
||||
_buildSecondaryButton(
|
||||
icon: repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
|
||||
color: (repeatMode != null && repeatMode != 'off'
|
||||
? primaryColor
|
||||
: textColor.withOpacity(0.5))
|
||||
.withOpacity(expandedElementsOpacity),
|
||||
onPressed: isLoadingQueue ? null : onCycleRepeat,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/music_assistant_provider.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;
|
||||
|
||||
const PlayerRevealOverlay({
|
||||
super.key,
|
||||
required this.onDismiss,
|
||||
required this.miniPlayerBottom,
|
||||
required this.miniPlayerHeight,
|
||||
});
|
||||
|
||||
@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;
|
||||
|
||||
// Cache extracted colors per player for per-device accent colors
|
||||
final Map<String, ColorScheme?> _playerColors = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_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();
|
||||
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 settle 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();
|
||||
});
|
||||
}
|
||||
|
||||
void _handleVerticalDragUpdate(DragUpdateDetails details) {
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
// Negate delta so swipe down = cards move down (toward dismissal)
|
||||
_dragOffset -= details.primaryDelta ?? 0;
|
||||
// Clamp: allow moving down (positive) freely, limit moving up to 20px
|
||||
_dragOffset = _dragOffset.clamp(-20.0, double.infinity);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleVerticalDragEnd(DragEndDetails details) {
|
||||
final velocity = details.primaryVelocity ?? 0;
|
||||
|
||||
// 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;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _revealAnimation,
|
||||
builder: (context, child) {
|
||||
final t = _revealAnimation.value;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Backdrop - tap to dismiss
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: dismiss,
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.6 * _backdropAnimation.value),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 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: [
|
||||
// Build player cards - all slide from behind mini player
|
||||
...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,
|
||||
backgroundColor: cardBgColor,
|
||||
textColor: cardTextColor,
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
maProvider.selectPlayer(player);
|
||||
dismiss();
|
||||
},
|
||||
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(
|
||||
imageUrl: imageUrl,
|
||||
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(
|
||||
color: textColor.withOpacity(0.1),
|
||||
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
|
||||
|
||||
@@ -18,40 +18,56 @@ class PlayerSelector extends StatelessWidget {
|
||||
final availablePlayers = maProvider.availablePlayers;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Count players that are currently playing
|
||||
final playingCount = availablePlayers.where((p) => p.state == 'playing').length;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Material(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: () => _showPlayerSelector(context, maProvider, availablePlayers),
|
||||
child: Badge(
|
||||
isLabelVisible: playingCount > 0,
|
||||
backgroundColor: colorScheme.tertiary,
|
||||
textColor: colorScheme.onTertiary,
|
||||
label: Text(
|
||||
playingCount.toString(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 11,
|
||||
color: colorScheme.onTertiary,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (selectedPlayer != null) ...[
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 120),
|
||||
child: Text(
|
||||
selectedPlayer.name,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
child: InkWell(
|
||||
onTap: () => _showPlayerSelector(context, maProvider, availablePlayers),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (selectedPlayer != null) ...[
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 120),
|
||||
child: Text(
|
||||
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 +80,8 @@ class PlayerSelector extends StatelessWidget {
|
||||
MusicAssistantProvider provider,
|
||||
List players,
|
||||
) {
|
||||
// Collapse player if expanded before showing sheet
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
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();
|
||||
});
|
||||
// Use the new player reveal animation
|
||||
GlobalPlayerOverlay.showPlayerReveal();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../screens/audiobook_series_screen.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
|
||||
class SeriesRow extends StatefulWidget {
|
||||
final String title;
|
||||
@@ -267,10 +268,11 @@ class _SeriesCard extends StatelessWidget {
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
// PERF: Use FadeSlidePageRoute for consistent hero animation timing
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AudiobookSeriesScreen(
|
||||
FadeSlidePageRoute(
|
||||
child: AudiobookSeriesScreen(
|
||||
series: series,
|
||||
heroTag: heroTag,
|
||||
initialCovers: covers,
|
||||
@@ -284,6 +286,44 @@ class _SeriesCard extends StatelessWidget {
|
||||
// Use AspectRatio to guarantee square cover
|
||||
Hero(
|
||||
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(
|
||||
aspectRatio: 1,
|
||||
child: ClipRRect(
|
||||
@@ -373,6 +413,8 @@ class _SeriesCard extends StatelessWidget {
|
||||
? CachedNetworkImage(
|
||||
imageUrl: coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
|
||||
@@ -186,7 +186,9 @@ class _TrackCard extends StatelessWidget {
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 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(),
|
||||
errorWidget: (context, url, error) => Icon(
|
||||
Icons.music_note_rounded,
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 2.3.2-beta+24
|
||||
version: 2.4.1-beta+26
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
Reference in New Issue
Block a user