Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5cefca71f | ||
|
|
f658393d30 | ||
|
|
a4554ef7d7 | ||
|
|
a078eb55e8 | ||
|
|
dfe3f26515 | ||
|
|
d3d87dceda | ||
|
|
73bb12fc03 | ||
|
|
e975e24939 | ||
|
|
707768e524 | ||
|
|
e6349c309b | ||
|
|
00f065fbe9 | ||
|
|
e440f7b1d7 | ||
|
|
5a73d2d65b | ||
|
|
3b5a8631a8 | ||
|
|
1f3a01c22f | ||
|
|
27e1b8b746 | ||
|
|
9be3737a9d | ||
|
|
882e8b2ea4 | ||
|
|
aef9423b42 | ||
|
|
c0138828a7 |
@@ -17,6 +17,12 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
|
||||
---
|
||||
|
||||
## Also See
|
||||
|
||||
**[Ensemble TV](https://github.com/CollotsSpot/ensemble-tv)** - A simple Android TV client for Music Assistant that displays what's currently playing on your TV with remote control support.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Local Playback
|
||||
@@ -127,25 +133,11 @@ To find the correct URL:
|
||||
|
||||
If you run Music Assistant as a Home Assistant add-on:
|
||||
- Use the IP address of your Home Assistant server
|
||||
- Enter `8095` in the port field
|
||||
- Do **not** use your Home Assistant URL or ingress URL
|
||||
|
||||
### Remote Access
|
||||
|
||||
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Nginx Proxy Manager, Cloudflare Tunnel).
|
||||
|
||||
## Authentication
|
||||
|
||||
Ensemble supports multiple authentication methods:
|
||||
|
||||
| Method | Status |
|
||||
|--------|--------|
|
||||
| Music Assistant native auth | Tested |
|
||||
| No authentication | Tested |
|
||||
| Authelia | Implemented, not recently tested |
|
||||
| HTTP Basic Auth | Implemented, not recently tested |
|
||||
|
||||
**Note:** Development and testing is done against Music Assistant beta with native authentication enabled.
|
||||
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Traefik, Nginx Proxy Manager, Cloudflare Tunnel).
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ if (keystorePropertiesFile.exists()) {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.musicassistant.music_assistant"
|
||||
namespace "com.collotsspot.ensemble"
|
||||
compileSdk 35
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
@@ -58,7 +58,7 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.musicassistant.music_assistant"
|
||||
applicationId "com.collotsspot.ensemble"
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdk 35
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
@@ -70,6 +70,11 @@ android {
|
||||
signingConfig keystorePropertiesFile.exists() ? signingConfigs.release : signingConfigs.debug
|
||||
}
|
||||
}
|
||||
|
||||
dependenciesInfo {
|
||||
includeInApk = false
|
||||
includeInBundle = false
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.musicassistant.music_assistant
|
||||
package com.collotsspot.ensemble
|
||||
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
@@ -10,7 +10,7 @@ import io.flutter.plugin.common.MethodChannel
|
||||
// while also intercepting volume button events
|
||||
class MainActivity: AudioServiceActivity() {
|
||||
private val TAG = "EnsembleVolume"
|
||||
private val CHANNEL = "com.musicassistant.music_assistant/volume_buttons"
|
||||
private val CHANNEL = "com.collotsspot.ensemble/volume_buttons"
|
||||
private var methodChannel: MethodChannel? = null
|
||||
private var isListening = false
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
New package ID (com.collotsspot.ensemble) - reinstall required.
|
||||
Prepares for F-Droid and Play Store submission.
|
||||
@@ -0,0 +1 @@
|
||||
Add fastlane metadata for F-Droid.
|
||||
@@ -0,0 +1 @@
|
||||
Disable dependency metadata in APK for F-Droid compliance.
|
||||
@@ -0,0 +1,13 @@
|
||||
Ensemble is an unofficial, community-built mobile client for Music Assistant, providing both local playback and remote control of your music library.
|
||||
|
||||
Features:
|
||||
* Stream music directly to your phone with background playback
|
||||
* Control any speaker or device connected to Music Assistant
|
||||
* Multi-room grouping capabilities
|
||||
* Queue management with drag-to-reorder
|
||||
* Background playback with media notifications
|
||||
* Fast ~300ms response for play/pause controls
|
||||
|
||||
Ensemble supports both local playback (streaming to your phone) and remote control of any Music Assistant player in your home.
|
||||
|
||||
Note: Requires a running Music Assistant server. This is a community project and is not affiliated with the official Music Assistant team.
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 893 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 427 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 503 KiB |
|
After Width: | Height: | Size: 362 KiB |
|
After Width: | Height: | Size: 909 KiB |
@@ -0,0 +1 @@
|
||||
Unofficial mobile client for Music Assistant
|
||||
@@ -0,0 +1 @@
|
||||
Ensemble
|
||||
@@ -52,6 +52,7 @@
|
||||
"recentlyPlayed": "Zuletzt gespielt",
|
||||
"discoverArtists": "Kunstler entdecken",
|
||||
"discoverAlbums": "Alben entdecken",
|
||||
"playlistMixes": "Playlists entdecken",
|
||||
"continueListening": "Weiterhoren",
|
||||
"discoverAudiobooks": "Horbücher entdecken",
|
||||
"discoverSeries": "Serien entdecken",
|
||||
@@ -171,6 +172,7 @@
|
||||
"noArtistsFound": "Keine Künstler gefunden",
|
||||
"noTracksFound": "Keine Songs gefunden",
|
||||
"noPlaylistsFound": "Keine Wiedergabelisten gefunden",
|
||||
"noPlaylistMixesFound": "Keine Playlist-Mixes gefunden",
|
||||
"queueIsEmpty": "Warteschlange ist leer",
|
||||
"noResultsFound": "Keine Ergebnisse gefunden",
|
||||
"refresh": "Aktualisieren",
|
||||
@@ -216,6 +218,7 @@
|
||||
"showRecentlyPlayedAlbums": "Kürzlich gespielte Alben anzeigen",
|
||||
"showRandomArtists": "Zufällige Künstler zum Entdecken anzeigen",
|
||||
"showRandomAlbums": "Zufällige Alben zum Entdecken anzeigen",
|
||||
"showPlaylistMixes": "Playlist-Mixes von externen Anbietern anzeigen",
|
||||
"showAudiobooksInProgress": "Hörbücher in Bearbeitung anzeigen",
|
||||
"showRandomAudiobooks": "Zufällige Hörbücher zum Entdecken anzeigen",
|
||||
"showRandomSeries": "Zufällige Hörbuchserien zum Entdecken anzeigen",
|
||||
|
||||
@@ -221,6 +221,11 @@
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"playlistMixes": "Discover Playlists",
|
||||
"@playlistMixes": {
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"continueListening": "Continue Listening",
|
||||
"@continueListening": {
|
||||
"description": "Home screen section title"
|
||||
@@ -925,6 +930,11 @@
|
||||
"description": "Empty playlists"
|
||||
},
|
||||
|
||||
"noPlaylistMixesFound": "No playlist mixes found",
|
||||
"@noPlaylistMixesFound": {
|
||||
"description": "Empty playlist mixes"
|
||||
},
|
||||
|
||||
"queueIsEmpty": "Queue is empty",
|
||||
"@queueIsEmpty": {
|
||||
"description": "Empty queue"
|
||||
@@ -1085,6 +1095,7 @@
|
||||
"showRecentlyPlayedAlbums": "Show recently played albums",
|
||||
"showRandomArtists": "Show random artists to discover",
|
||||
"showRandomAlbums": "Show random albums to discover",
|
||||
"showPlaylistMixes": "Show playlist mixes from external providers",
|
||||
"showAudiobooksInProgress": "Show audiobooks in progress",
|
||||
"showRandomAudiobooks": "Show random audiobooks to discover",
|
||||
"showRandomSeries": "Show random audiobook series to discover",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"recentlyPlayed": "Reproducido recientemente",
|
||||
"discoverArtists": "Descubrir artistas",
|
||||
"discoverAlbums": "Descubrir álbumes",
|
||||
"playlistMixes": "Descubrir listas de reproducción",
|
||||
"continueListening": "Continuar escuchando",
|
||||
"discoverAudiobooks": "Descubrir audiolibros",
|
||||
"discoverSeries": "Descubrir series",
|
||||
@@ -320,6 +321,7 @@
|
||||
"noArtistsFound": "No se encontraron artistas",
|
||||
"noTracksFound": "No se encontraron canciones",
|
||||
"noPlaylistsFound": "No se encontraron listas de reproducción",
|
||||
"noPlaylistMixesFound": "No se encontraron mezclas de playlists",
|
||||
"queueIsEmpty": "La cola está vacía",
|
||||
"noResultsFound": "No se encontraron resultados",
|
||||
"refresh": "Actualizar",
|
||||
@@ -372,6 +374,7 @@
|
||||
"showRecentlyPlayedAlbums": "Mostrar álbumes reproducidos recientemente",
|
||||
"showRandomArtists": "Mostrar artistas aleatorios para descubrir",
|
||||
"showRandomAlbums": "Mostrar álbumes aleatorios para descubrir",
|
||||
"showPlaylistMixes": "Mostrar mezclas de playlists de proveedores externos",
|
||||
"showAudiobooksInProgress": "Mostrar audiolibros en progreso",
|
||||
"showRandomAudiobooks": "Mostrar audiolibros aleatorios para descubrir",
|
||||
"showRandomSeries": "Mostrar series de audiolibros aleatorias para descubrir",
|
||||
|
||||
@@ -221,6 +221,11 @@
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"playlistMixes": "Découvrir des playlists",
|
||||
"@playlistMixes": {
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"continueListening": "Continuer l'écoute",
|
||||
"@continueListening": {
|
||||
"description": "Home screen section title"
|
||||
@@ -915,6 +920,11 @@
|
||||
"description": "Empty playlists"
|
||||
},
|
||||
|
||||
"noPlaylistMixesFound": "Aucun mix de playlists trouve",
|
||||
"@noPlaylistMixesFound": {
|
||||
"description": "Empty playlist mixes"
|
||||
},
|
||||
|
||||
"queueIsEmpty": "La file d'attente est vide",
|
||||
"@queueIsEmpty": {
|
||||
"description": "Empty queue"
|
||||
@@ -1071,6 +1081,7 @@
|
||||
"showRecentlyPlayedAlbums": "Afficher les albums écoutes recemment",
|
||||
"showRandomArtists": "Afficher des artistes aléatoires a découvrir",
|
||||
"showRandomAlbums": "Afficher des albums aléatoires a découvrir",
|
||||
"showPlaylistMixes": "Afficher les mixes de playlists provenant de fournisseurs externes",
|
||||
"showAudiobooksInProgress": "Afficher les livres audio en cours",
|
||||
"showRandomAudiobooks": "Afficher des livres audio aléatoires a découvrir",
|
||||
"showRandomSeries": "Afficher des séries de livres audio aléatoires a découvrir",
|
||||
|
||||
@@ -359,6 +359,12 @@ abstract class S {
|
||||
/// **'Discover Albums'**
|
||||
String get discoverAlbums;
|
||||
|
||||
/// Home screen section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Discover Playlists'**
|
||||
String get playlistMixes;
|
||||
|
||||
/// Home screen section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1049,6 +1055,12 @@ abstract class S {
|
||||
/// **'No playlists found'**
|
||||
String get noPlaylistsFound;
|
||||
|
||||
/// Empty playlist mixes
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No playlist mixes found'**
|
||||
String get noPlaylistMixesFound;
|
||||
|
||||
/// Empty queue
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1301,6 +1313,12 @@ abstract class S {
|
||||
/// **'Show random albums to discover'**
|
||||
String get showRandomAlbums;
|
||||
|
||||
/// No description provided for @showPlaylistMixes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show playlist mixes from external providers'**
|
||||
String get showPlaylistMixes;
|
||||
|
||||
/// No description provided for @showAudiobooksInProgress.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -151,6 +151,9 @@ class SDe extends S {
|
||||
@override
|
||||
String get discoverAlbums => 'Alben entdecken';
|
||||
|
||||
@override
|
||||
String get playlistMixes => 'Playlists entdecken';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Weiterhoren';
|
||||
|
||||
@@ -551,6 +554,9 @@ class SDe extends S {
|
||||
@override
|
||||
String get noPlaylistsFound => 'Keine Wiedergabelisten gefunden';
|
||||
|
||||
@override
|
||||
String get noPlaylistMixesFound => 'Keine Playlist-Mixes gefunden';
|
||||
|
||||
@override
|
||||
String get queueIsEmpty => 'Warteschlange ist leer';
|
||||
|
||||
@@ -682,6 +688,10 @@ class SDe extends S {
|
||||
@override
|
||||
String get showRandomAlbums => 'Zufällige Alben zum Entdecken anzeigen';
|
||||
|
||||
@override
|
||||
String get showPlaylistMixes =>
|
||||
'Playlist-Mixes von externen Anbietern anzeigen';
|
||||
|
||||
@override
|
||||
String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen';
|
||||
|
||||
|
||||
@@ -148,6 +148,9 @@ class SEn extends S {
|
||||
@override
|
||||
String get discoverAlbums => 'Discover Albums';
|
||||
|
||||
@override
|
||||
String get playlistMixes => 'Discover Playlists';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Continue Listening';
|
||||
|
||||
@@ -546,6 +549,9 @@ class SEn extends S {
|
||||
@override
|
||||
String get noPlaylistsFound => 'No playlists found';
|
||||
|
||||
@override
|
||||
String get noPlaylistMixesFound => 'No playlist mixes found';
|
||||
|
||||
@override
|
||||
String get queueIsEmpty => 'Queue is empty';
|
||||
|
||||
@@ -676,6 +682,9 @@ class SEn extends S {
|
||||
@override
|
||||
String get showRandomAlbums => 'Show random albums to discover';
|
||||
|
||||
@override
|
||||
String get showPlaylistMixes => 'Show playlist mixes from external providers';
|
||||
|
||||
@override
|
||||
String get showAudiobooksInProgress => 'Show audiobooks in progress';
|
||||
|
||||
|
||||
@@ -152,6 +152,9 @@ class SEs extends S {
|
||||
@override
|
||||
String get discoverAlbums => 'Descubrir álbumes';
|
||||
|
||||
@override
|
||||
String get playlistMixes => 'Descubrir listas de reproducción';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Continuar escuchando';
|
||||
|
||||
@@ -554,6 +557,9 @@ class SEs extends S {
|
||||
@override
|
||||
String get noPlaylistsFound => 'No se encontraron listas de reproducción';
|
||||
|
||||
@override
|
||||
String get noPlaylistMixesFound => 'No se encontraron mezclas de playlists';
|
||||
|
||||
@override
|
||||
String get queueIsEmpty => 'La cola está vacía';
|
||||
|
||||
@@ -687,6 +693,10 @@ class SEs extends S {
|
||||
@override
|
||||
String get showRandomAlbums => 'Mostrar álbumes aleatorios para descubrir';
|
||||
|
||||
@override
|
||||
String get showPlaylistMixes =>
|
||||
'Mostrar mezclas de playlists de proveedores externos';
|
||||
|
||||
@override
|
||||
String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso';
|
||||
|
||||
|
||||
@@ -153,6 +153,9 @@ class SFr extends S {
|
||||
@override
|
||||
String get discoverAlbums => 'Découvrir des albums';
|
||||
|
||||
@override
|
||||
String get playlistMixes => 'Découvrir des playlists';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Continuer l\'écoute';
|
||||
|
||||
@@ -555,6 +558,9 @@ class SFr extends S {
|
||||
@override
|
||||
String get noPlaylistsFound => 'Aucune playlist trouvee';
|
||||
|
||||
@override
|
||||
String get noPlaylistMixesFound => 'Aucun mix de playlists trouve';
|
||||
|
||||
@override
|
||||
String get queueIsEmpty => 'La file d\'attente est vide';
|
||||
|
||||
@@ -689,6 +695,10 @@ class SFr extends S {
|
||||
@override
|
||||
String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir';
|
||||
|
||||
@override
|
||||
String get showPlaylistMixes =>
|
||||
'Afficher les mixes de playlists provenant de fournisseurs externes';
|
||||
|
||||
@override
|
||||
String get showAudiobooksInProgress => 'Afficher les livres audio en cours';
|
||||
|
||||
|
||||
@@ -329,8 +329,17 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
|
||||
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
||||
builder: (context, child) {
|
||||
// Wrap entire app with global player overlay
|
||||
return GlobalPlayerOverlay(child: child ?? const SizedBox.shrink());
|
||||
// Wrap entire app with global player overlay.
|
||||
// Provide an Overlay ancestor so BottomNavigationBar/Tooltip can render safely.
|
||||
return Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(
|
||||
builder: (context) => GlobalPlayerOverlay(
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
home: const AppStartup(),
|
||||
),
|
||||
|
||||
@@ -10,6 +10,29 @@ enum MediaType {
|
||||
podcastEpisode,
|
||||
}
|
||||
|
||||
/// Parse a media item JSON map into the appropriate model type.
|
||||
MediaItem parseMediaItemJson(Map<String, dynamic> json) {
|
||||
final mediaTypeStr = (json['media_type'] as String?)?.toLowerCase();
|
||||
switch (mediaTypeStr) {
|
||||
case 'artist':
|
||||
return Artist.fromJson(json);
|
||||
case 'album':
|
||||
return Album.fromJson(json);
|
||||
case 'track':
|
||||
return Track.fromJson(json);
|
||||
case 'playlist':
|
||||
return Playlist.fromJson(json);
|
||||
case 'audiobook':
|
||||
return Audiobook.fromJson(json);
|
||||
case 'radio':
|
||||
return MediaItem.fromJson(json);
|
||||
case 'podcast':
|
||||
case 'podcast_episode':
|
||||
default:
|
||||
return MediaItem.fromJson(json);
|
||||
}
|
||||
}
|
||||
|
||||
class ProviderMapping {
|
||||
final String itemId;
|
||||
final String providerDomain;
|
||||
@@ -356,6 +379,15 @@ class Playlist extends MediaItem {
|
||||
if (provider == 'library') return true;
|
||||
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = super.toJson();
|
||||
if (owner != null) json['owner'] = owner;
|
||||
if (isEditable != null) json['is_editable'] = isEditable;
|
||||
if (trackCount != null) json['track_count'] = trackCount;
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
class Chapter {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'media_item.dart';
|
||||
|
||||
class RecommendationFolder {
|
||||
final String itemId;
|
||||
final String provider;
|
||||
final String name;
|
||||
final String? uri;
|
||||
final List<MediaItem> items;
|
||||
|
||||
RecommendationFolder({
|
||||
required this.itemId,
|
||||
required this.provider,
|
||||
required this.name,
|
||||
this.uri,
|
||||
required this.items,
|
||||
});
|
||||
|
||||
factory RecommendationFolder.fromJson(Map<String, dynamic> json) {
|
||||
final itemsJson = json['items'] as List<dynamic>?;
|
||||
final parsedItems = itemsJson
|
||||
?.whereType<Map<String, dynamic>>()
|
||||
.map(parseMediaItemJson)
|
||||
.toList() ?? <MediaItem>[];
|
||||
|
||||
return RecommendationFolder(
|
||||
itemId: json['item_id']?.toString() ?? json['id']?.toString() ?? '',
|
||||
provider: json['provider'] as String? ?? 'unknown',
|
||||
name: json['name'] as String? ?? '',
|
||||
uri: json['uri'] as String?,
|
||||
items: parsedItems,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'item_id': itemId,
|
||||
'provider': provider,
|
||||
'name': name,
|
||||
if (uri != null) 'uri': uri,
|
||||
'items': items.map((item) => item.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../models/provider_instance.dart';
|
||||
import '../models/provider_manifest.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
@@ -26,6 +27,7 @@ import '../services/pcm_audio_player.dart';
|
||||
import '../services/offline_action_queue.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/library_status_service.dart';
|
||||
import '../main.dart' show audioHandler;
|
||||
|
||||
/// Main provider that coordinates connection, player, and library state.
|
||||
@@ -64,8 +66,6 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
Player? _selectedPlayer;
|
||||
List<Player> _availablePlayers = [];
|
||||
bool _selectPlayerInProgress = false; // Reentrancy guard for selectPlayer()
|
||||
DateTime? _lastPlayerSwitchTime; // Debounce for auto-selection
|
||||
static const _playerSwitchCooldown = Duration(seconds: 3); // Minimum time between auto-switches
|
||||
Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping
|
||||
Track? _currentTrack;
|
||||
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
|
||||
@@ -922,6 +922,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_artists = syncService.cachedArtists;
|
||||
// Note: tracks are loaded separately via API, not cached in SyncService
|
||||
_logger.log('📦 Pre-loaded library for favorites: ${_albums.length} albums, ${_artists.length} artists');
|
||||
_syncLibraryStatusToService();
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1623,6 +1624,41 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// LIBRARY MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
/// Sync library status to centralized service for reactive UI updates
|
||||
void _syncLibraryStatusToService() {
|
||||
final service = LibraryStatusService.instance;
|
||||
|
||||
// Sync albums
|
||||
service.syncFromLibrary(
|
||||
mediaType: 'album',
|
||||
items: _albums,
|
||||
getInLibrary: (a) => (a as Album).inLibrary,
|
||||
getFavorite: (a) => (a as Album).favorite ?? false,
|
||||
getProvider: (a) => (a as Album).provider,
|
||||
getItemId: (a) => (a as Album).itemId,
|
||||
);
|
||||
|
||||
// Sync artists
|
||||
service.syncFromLibrary(
|
||||
mediaType: 'artist',
|
||||
items: _artists,
|
||||
getInLibrary: (a) => (a as Artist).inLibrary,
|
||||
getFavorite: (a) => (a as Artist).favorite ?? false,
|
||||
getProvider: (a) => (a as Artist).provider,
|
||||
getItemId: (a) => (a as Artist).itemId,
|
||||
);
|
||||
|
||||
// Sync tracks
|
||||
service.syncFromLibrary(
|
||||
mediaType: 'track',
|
||||
items: _tracks,
|
||||
getInLibrary: (t) => (t as Track).inLibrary,
|
||||
getFavorite: (t) => (t as Track).favorite ?? false,
|
||||
getProvider: (t) => (t as Track).provider,
|
||||
getItemId: (t) => (t as Track).itemId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Add item to library
|
||||
/// Returns true if action was executed successfully
|
||||
Future<bool> addToLibrary({
|
||||
@@ -1668,8 +1704,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// OPTIMISTIC UPDATE: Update local cache immediately for instant UI feedback
|
||||
// This ensures library screens show the change even before API completes
|
||||
_removeFromLocalLibrary(mediaType, libraryItemId);
|
||||
// Also remove from SyncService's in-memory cache
|
||||
SyncService.instance.removeFromCacheByLibraryId(mediaType, libraryItemId);
|
||||
// Also mark as deleted in database cache so it doesn't reappear on next load
|
||||
DatabaseService.instance.markCachedItemDeleted(mediaType, libraryItemId.toString());
|
||||
// Use the library ID aware method that handles cases where itemId differs from libraryId
|
||||
DatabaseService.instance.markCachedItemDeletedByLibraryId(mediaType, libraryItemId.toString());
|
||||
// Invalidate caches that could show stale inLibrary status
|
||||
_cacheService.invalidateSearchCache();
|
||||
if (mediaType == 'album') {
|
||||
@@ -1790,6 +1829,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
} else if (mediaType == 'podcast') {
|
||||
_podcasts = await _api!.getPodcasts(limit: 100);
|
||||
}
|
||||
_syncLibraryStatusToService();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Background library refresh failed: $e');
|
||||
@@ -3137,6 +3177,76 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<RecommendationFolder>> getExternalMixesWithCache({bool forceRefresh = false}) async {
|
||||
if (_cacheService.isExternalMixesCacheValid(forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached external mixes');
|
||||
return _cacheService.getCachedExternalMixes()!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedExternalMixes() ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching external mixes...');
|
||||
final recommendations = await _api!.getRecommendations();
|
||||
final mixes = _extractExternalMixes(recommendations);
|
||||
_cacheService.setCachedExternalMixes(mixes);
|
||||
return mixes;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch external mixes: $e');
|
||||
return _cacheService.getCachedExternalMixes() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
List<RecommendationFolder> _extractExternalMixes(List<RecommendationFolder> folders) {
|
||||
final result = <RecommendationFolder>[];
|
||||
|
||||
for (final folder in folders) {
|
||||
final providerFiltered = filterByProvider(folder.items);
|
||||
final filteredItems = providerFiltered
|
||||
.where((item) => item.mediaType == MediaType.playlist || item.mediaType == MediaType.radio)
|
||||
.where(_isExternalMixItem)
|
||||
.toList();
|
||||
|
||||
if (filteredItems.isEmpty) continue;
|
||||
|
||||
result.add(RecommendationFolder(
|
||||
itemId: folder.itemId,
|
||||
provider: folder.provider,
|
||||
name: folder.name,
|
||||
uri: folder.uri,
|
||||
items: _deduplicateMixes(filteredItems),
|
||||
));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool _isExternalMixItem(MediaItem item) {
|
||||
final provider = item.provider.toLowerCase();
|
||||
if (provider != 'library' && provider != 'filesystem') {
|
||||
return true;
|
||||
}
|
||||
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings != null) {
|
||||
return mappings.any((m) {
|
||||
final domain = m.providerDomain.toLowerCase();
|
||||
return domain.isNotEmpty && domain != 'library' && domain != 'filesystem';
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
List<MediaItem> _deduplicateMixes(List<MediaItem> mixes) {
|
||||
final Map<String, MediaItem> deduped = {};
|
||||
for (final mix in mixes) {
|
||||
final key = '${mix.provider}:${mix.itemId}';
|
||||
deduped.putIfAbsent(key, () => mix);
|
||||
}
|
||||
return deduped.values.toList();
|
||||
}
|
||||
|
||||
void invalidateHomeCache() {
|
||||
_cacheService.invalidateHomeCache();
|
||||
}
|
||||
@@ -3150,6 +3260,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
/// Get cached discover albums synchronously (for instant display)
|
||||
List<Album>? getCachedDiscoverAlbums() => _cacheService.getCachedDiscoverAlbums();
|
||||
|
||||
/// Get cached external mixes synchronously (for instant display)
|
||||
List<RecommendationFolder>? getCachedExternalMixes() => _cacheService.getCachedExternalMixes();
|
||||
|
||||
/// Force a full library sync (for pull-to-refresh)
|
||||
Future<void> forceLibrarySync() async {
|
||||
if (_api == null) {
|
||||
@@ -3175,6 +3288,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_logger.log('⚠️ Failed to refresh tracks: $e');
|
||||
}
|
||||
|
||||
_syncLibraryStatusToService();
|
||||
notifyListeners();
|
||||
_logger.log('✅ Force sync complete');
|
||||
}
|
||||
@@ -3793,33 +3907,20 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
if (_availablePlayers.isNotEmpty) {
|
||||
Player? playerToSelect;
|
||||
final preferLocalPlayer = await SettingsService.getPreferLocalPlayer();
|
||||
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
|
||||
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
||||
|
||||
// Log settings for debugging player selection issues
|
||||
_logger.log('⚙️ Player selection settings: preferLocal=$preferLocalPlayer, disableAutoSwitch=$disableAutoSwitch, coldStart=$coldStart');
|
||||
_logger.log('⚙️ Current selection: ${_selectedPlayer?.name ?? 'none'}, lastSelected=$lastSelectedPlayerId');
|
||||
_logger.log('⚙️ Current selection: ${_selectedPlayer?.name ?? 'none'}, lastSelected=$lastSelectedPlayerId, coldStart=$coldStart');
|
||||
|
||||
// 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
|
||||
_logger.log('⚠️ Prefer local player ON but local player not available');
|
||||
}
|
||||
}
|
||||
// SIMPLIFIED PLAYER SELECTION LOGIC:
|
||||
// 1. User's manual selection is always respected
|
||||
// 2. On app reopen: restore last selection
|
||||
// 3. If selected player unavailable: switch to local player
|
||||
// 4. First launch (no history): select local player
|
||||
// 5. Local player unavailable: select first available
|
||||
|
||||
// Keep currently selected player if still available (and not overridden by prefer local)
|
||||
// On coldStart, skip this block and apply full priority logic
|
||||
if (playerToSelect == null && _selectedPlayer != null && !coldStart) {
|
||||
// Keep currently selected player if still available
|
||||
if (_selectedPlayer != null && !coldStart) {
|
||||
// Check if selected player is still available - also check translated Cast/Sendspin IDs
|
||||
// When Cast player gets replaced by Sendspin version (or vice versa), we should keep selection
|
||||
final selectedId = _selectedPlayer!.playerId;
|
||||
final translatedId = _castToSendspinIdMap[selectedId];
|
||||
String? reverseTranslatedId;
|
||||
@@ -3836,103 +3937,55 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
(reverseTranslatedId != null && p.playerId == reverseTranslatedId)),
|
||||
);
|
||||
if (stillAvailable) {
|
||||
// Find the actual player in the list (might be Cast or Sendspin version)
|
||||
final currentPlayer = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == selectedId ||
|
||||
(translatedId != null && p.playerId == translatedId) ||
|
||||
(reverseTranslatedId != null && p.playerId == reverseTranslatedId),
|
||||
);
|
||||
|
||||
// Keep current selection - no auto-switching during non-coldStart refreshes
|
||||
// Auto-switch to playing player is now ONLY done on coldStart (see below)
|
||||
playerToSelect = currentPlayer;
|
||||
if (currentPlayer.playerId != selectedId) {
|
||||
_logger.log('🔄 Selected player ID changed (Cast<->Sendspin): $selectedId -> ${currentPlayer.playerId}');
|
||||
} else {
|
||||
_logger.log('✓ Keeping current selection: ${currentPlayer.name}');
|
||||
}
|
||||
} else {
|
||||
// Selected player went offline - will fall through to local player
|
||||
_logger.log('⚠️ Selected player ${_selectedPlayer!.name} no longer available');
|
||||
}
|
||||
}
|
||||
|
||||
if (coldStart) {
|
||||
_logger.log('🚀 Cold start: applying full priority logic');
|
||||
// Priority 1: Restore last selected player (on coldStart)
|
||||
if (playerToSelect == null && coldStart && lastSelectedPlayerId != null) {
|
||||
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
||||
(p) => p!.playerId == lastSelectedPlayerId && p.available,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (playerToSelect != null) {
|
||||
_logger.log('🔄 Restored last selected player: ${playerToSelect.name}');
|
||||
}
|
||||
}
|
||||
|
||||
if (playerToSelect == null) {
|
||||
// Smart auto-selection priority (coldStart only for auto-switch to playing):
|
||||
// Priority 1: Single playing player (only on coldStart and if disableAutoSwitch is OFF)
|
||||
// Priority 2: Local player (if preferLocalPlayer is OFF but we still try local as fallback)
|
||||
// Priority 3: Last manually selected player
|
||||
// Priority 4: First available player
|
||||
|
||||
// Priority 1: Auto-switch to single playing player
|
||||
// ONLY on coldStart AND only if disableAutoSwitch is OFF
|
||||
// Also apply cooldown to prevent rapid successive auto-switches
|
||||
final cooldownActive = _lastPlayerSwitchTime != null &&
|
||||
DateTime.now().difference(_lastPlayerSwitchTime!) < _playerSwitchCooldown;
|
||||
|
||||
if (coldStart && !disableAutoSwitch && !preferLocalPlayer && !cooldownActive) {
|
||||
final playingPlayers = _availablePlayers.where(
|
||||
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
|
||||
).toList();
|
||||
if (playingPlayers.length == 1) {
|
||||
playerToSelect = playingPlayers.first;
|
||||
_lastPlayerSwitchTime = DateTime.now(); // Update cooldown timestamp
|
||||
_logger.log('🎵 Auto-selected playing player: ${playerToSelect?.name}');
|
||||
} else if (playingPlayers.length > 1) {
|
||||
_logger.log('⚠️ Multiple players playing (${playingPlayers.length}), skipping auto-switch');
|
||||
}
|
||||
} else if (cooldownActive) {
|
||||
_logger.log('⏳ Auto-switch cooldown active, skipping');
|
||||
} else if (!coldStart) {
|
||||
_logger.log('⚠️ Not coldStart, skipping auto-switch to playing player');
|
||||
} else if (disableAutoSwitch) {
|
||||
_logger.log('⚠️ Auto-switch disabled by user setting');
|
||||
}
|
||||
|
||||
// Priority 2: Local player (fallback even when preferLocalPlayer is OFF)
|
||||
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) {
|
||||
// Local player not found or not available - check why
|
||||
final localPlayer = _availablePlayers
|
||||
.where((p) => p.playerId == builtinPlayerId)
|
||||
.toList();
|
||||
if (localPlayer.isEmpty) {
|
||||
_logger.log('⚠️ Priority 2 skipped: local player ($builtinPlayerId) not in available players list');
|
||||
} else {
|
||||
_logger.log('⚠️ Priority 2 skipped: local player found but available=${localPlayer.first.available}');
|
||||
}
|
||||
}
|
||||
} else if (playerToSelect == null && builtinPlayerId == null) {
|
||||
_logger.log('⚠️ Priority 2 skipped: builtinPlayerId is null');
|
||||
}
|
||||
|
||||
// Priority 3: Last manually selected player
|
||||
if (playerToSelect == null && lastSelectedPlayerId != null) {
|
||||
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
||||
(p) => p!.playerId == lastSelectedPlayerId && p.available,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (playerToSelect != null) {
|
||||
_logger.log('🔄 Auto-selected last used player: ${playerToSelect?.name}');
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: First available player
|
||||
if (playerToSelect == null) {
|
||||
// Priority 2: Local player (default fallback)
|
||||
if (playerToSelect == null && builtinPlayerId != null) {
|
||||
try {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.available,
|
||||
orElse: () => _availablePlayers.first,
|
||||
(p) => p.playerId == builtinPlayerId && p.available,
|
||||
);
|
||||
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
|
||||
_logger.log('📱 Selected local player: ${playerToSelect.name}');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Local player not available');
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: First available player
|
||||
if (playerToSelect == null) {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.available,
|
||||
orElse: () => _availablePlayers.first,
|
||||
);
|
||||
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
|
||||
}
|
||||
|
||||
selectPlayer(playerToSelect);
|
||||
}
|
||||
|
||||
@@ -4715,19 +4768,32 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No track data available - show player name placeholder for active players
|
||||
// No queue data available from API
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
final isBuiltinPlayer = builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId;
|
||||
|
||||
if (_currentTrack != null) {
|
||||
// For builtin player, try to preserve cached track data since queue API doesn't return it
|
||||
// The cached track was set in selectPlayer() and contains valid data
|
||||
if (isBuiltinPlayer && _currentTrack == null) {
|
||||
final cachedTrack = getCachedTrackForPlayer(_selectedPlayer!.playerId);
|
||||
if (cachedTrack != null) {
|
||||
_currentTrack = cachedTrack;
|
||||
stateChanged = true;
|
||||
_logger.log('🎵 Restored cached track for builtin player: ${cachedTrack.name}');
|
||||
}
|
||||
}
|
||||
|
||||
// Only clear track if we have no cached data to fall back on
|
||||
// Don't clear for builtin player - it maintains its own playback state
|
||||
if (_currentTrack != null && !isBuiltinPlayer) {
|
||||
_currentTrack = null;
|
||||
stateChanged = true;
|
||||
// Persist cleared track state
|
||||
_persistPlaybackState();
|
||||
}
|
||||
|
||||
// Show player name placeholder so notification shows correct player
|
||||
if (_selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused') {
|
||||
// Show player name placeholder only if we have no track data
|
||||
if (_currentTrack == null && (_selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused')) {
|
||||
final mediaItem = audio_service.MediaItem(
|
||||
id: 'player_${_selectedPlayer!.playerId}',
|
||||
title: _selectedPlayer!.name,
|
||||
@@ -4749,7 +4815,22 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
duration: Duration.zero,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
} else if (_currentTrack != null && isBuiltinPlayer) {
|
||||
// Builtin player has track data - update notification with it
|
||||
final track = _currentTrack!;
|
||||
final mediaItem = audio_service.MediaItem(
|
||||
id: track.uri ?? track.itemId,
|
||||
title: track.name,
|
||||
artist: track.artistsString,
|
||||
album: track.album?.name,
|
||||
artUri: _api != null ? Uri.tryParse(_api!.getImageUrl(track, size: 512) ?? '') : null,
|
||||
duration: track.duration,
|
||||
);
|
||||
audioHandler.updateLocalModeNotification(
|
||||
item: mediaItem,
|
||||
playing: _selectedPlayer!.state == 'playing',
|
||||
);
|
||||
} else if (_selectedPlayer!.state != 'playing' && _selectedPlayer!.state != 'paused') {
|
||||
audioHandler.clearRemotePlaybackState();
|
||||
_startIdleServiceTimer();
|
||||
_positionTracker.clear();
|
||||
@@ -4843,6 +4924,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_albums = syncService.cachedAlbums;
|
||||
_artists = syncService.cachedArtists;
|
||||
_logger.log('📦 Loaded ${_albums.length} albums, ${_artists.length} artists from cache');
|
||||
_syncLibraryStatusToService();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -4860,6 +4942,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
_syncLibraryStatusToService();
|
||||
notifyListeners();
|
||||
|
||||
// Trigger background sync (non-blocking)
|
||||
@@ -4886,6 +4969,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_albums = syncService.cachedAlbums;
|
||||
_artists = syncService.cachedArtists;
|
||||
_logger.log('🔄 Updated library from background sync: ${_albums.length} albums, ${_artists.length} artists');
|
||||
_syncLibraryStatusToService();
|
||||
notifyListeners();
|
||||
}
|
||||
syncService.removeListener(onSyncComplete);
|
||||
@@ -4983,6 +5067,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
SyncService.instance.updateCachedArtists(_artists);
|
||||
|
||||
_isLoading = false;
|
||||
_syncLibraryStatusToService();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
|
||||
@@ -5018,6 +5103,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
SyncService.instance.updateCachedAlbums(_albums);
|
||||
|
||||
_isLoading = false;
|
||||
_syncLibraryStatusToService();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
|
||||
|
||||
@@ -11,10 +11,12 @@ import '../theme/theme_provider.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/recently_played_service.dart';
|
||||
import '../services/library_status_service.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
import '../widgets/hires_badge.dart';
|
||||
import '../widgets/media_context_menu.dart';
|
||||
import '../widgets/library_status_builder.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'artist_details_screen.dart';
|
||||
@@ -36,12 +38,10 @@ class AlbumDetailsScreen extends StatefulWidget {
|
||||
State<AlbumDetailsScreen> createState() => _AlbumDetailsScreenState();
|
||||
}
|
||||
|
||||
class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTickerProviderStateMixin {
|
||||
class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTickerProviderStateMixin, LibraryStatusMixin {
|
||||
final _logger = DebugLogger();
|
||||
List<Track> _tracks = [];
|
||||
bool _isLoading = true;
|
||||
bool _isFavorite = false;
|
||||
bool _isInLibrary = false;
|
||||
ColorScheme? _lightColorScheme;
|
||||
ColorScheme? _darkColorScheme;
|
||||
bool _isDescriptionExpanded = false;
|
||||
@@ -53,11 +53,25 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
|
||||
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||
|
||||
@override
|
||||
String get libraryItemKey => LibraryStatusService.makeKey(
|
||||
'album',
|
||||
widget.album.provider,
|
||||
widget.album.itemId,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isFavorite = widget.album.favorite ?? false;
|
||||
_isInLibrary = widget.album.inLibrary;
|
||||
// Initialize status in centralized service from widget data
|
||||
final service = LibraryStatusService.instance;
|
||||
final key = libraryItemKey;
|
||||
if (!service.isInLibrary(key) && widget.album.inLibrary) {
|
||||
service.setLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
|
||||
service.setFavoriteStatus(key, true);
|
||||
}
|
||||
_loadTracks();
|
||||
_loadAlbumDescription();
|
||||
|
||||
@@ -97,8 +111,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
if (freshAlbum != null && mounted) {
|
||||
setState(() {
|
||||
_freshAlbum = freshAlbum;
|
||||
_isFavorite = freshAlbum.favorite ?? false;
|
||||
});
|
||||
// Sync fresh data to centralized service
|
||||
final service = LibraryStatusService.instance;
|
||||
service.syncSingleItem(
|
||||
key: libraryItemKey,
|
||||
inLibrary: freshAlbum.inLibrary,
|
||||
favorite: freshAlbum.favorite ?? false,
|
||||
);
|
||||
// Re-extract colors now that we have fresh album with images
|
||||
_extractColors();
|
||||
}
|
||||
@@ -134,9 +154,13 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
|
||||
Future<void> _toggleFavorite() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final currentFavorite = isFavorite;
|
||||
final newState = !currentFavorite;
|
||||
|
||||
// Optimistic update via centralized service
|
||||
setFavoriteStatus(newState);
|
||||
|
||||
try {
|
||||
final newState = !_isFavorite;
|
||||
bool success;
|
||||
|
||||
if (newState) {
|
||||
@@ -182,6 +206,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
|
||||
if (libraryItemId == null) {
|
||||
_logger.log('Error: Could not determine library_item_id for removal');
|
||||
rollbackFavoriteOperation();
|
||||
throw Exception('Could not determine library ID for this album');
|
||||
}
|
||||
|
||||
@@ -192,11 +217,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// Optimistically update UI regardless of online/offline state
|
||||
setState(() {
|
||||
_isFavorite = newState;
|
||||
});
|
||||
|
||||
completeFavoriteOperation();
|
||||
// Invalidate home cache so the home screen shows updated favorite status
|
||||
maProvider.invalidateHomeCache();
|
||||
|
||||
@@ -207,14 +228,17 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
content: Text(
|
||||
isOffline
|
||||
? S.of(context)!.actionQueuedForSync
|
||||
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||
: (newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||
),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
rollbackFavoriteOperation();
|
||||
}
|
||||
} catch (e) {
|
||||
rollbackFavoriteOperation();
|
||||
_logger.log('Error toggling favorite: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -230,10 +254,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
/// Toggle library status
|
||||
Future<void> _toggleLibrary() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final currentInLibrary = isInLibrary;
|
||||
final newState = !currentInLibrary;
|
||||
|
||||
try {
|
||||
final newState = !_isInLibrary;
|
||||
|
||||
if (newState) {
|
||||
// Add to library - MUST use non-library provider
|
||||
String? actualProvider;
|
||||
@@ -263,10 +287,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
// OPTIMISTIC UPDATE via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -278,20 +300,17 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
}
|
||||
|
||||
_logger.log('Adding album to library: provider=$actualProvider, itemId=$actualItemId');
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.addToLibrary(
|
||||
final success = await maProvider.addToLibrary(
|
||||
mediaType: 'album',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to add album to library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
if (success) {
|
||||
completeLibraryOperation();
|
||||
} else {
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
} else {
|
||||
// Remove from library
|
||||
int? libraryItemId;
|
||||
@@ -312,10 +331,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
return;
|
||||
}
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
// OPTIMISTIC UPDATE via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -326,21 +343,19 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
);
|
||||
}
|
||||
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.removeFromLibrary(
|
||||
final success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'album',
|
||||
libraryItemId: libraryItemId,
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to remove album from library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
if (success) {
|
||||
completeLibraryOperation();
|
||||
} else {
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
rollbackLibraryOperation();
|
||||
_logger.log('Error toggling album library: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -1019,8 +1034,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
child: Icon(
|
||||
Symbols.book_2,
|
||||
size: 25,
|
||||
fill: _isInLibrary ? 1 : 0,
|
||||
color: _isInLibrary
|
||||
fill: isInLibrary ? 1 : 0,
|
||||
color: isInLibrary
|
||||
? colorScheme.primary
|
||||
: Colors.white70,
|
||||
),
|
||||
@@ -1044,7 +1059,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
child: Icon(
|
||||
Icons.favorite,
|
||||
size: 25,
|
||||
color: _isFavorite
|
||||
color: isFavorite
|
||||
? colorScheme.error
|
||||
: Colors.white70,
|
||||
),
|
||||
@@ -1068,8 +1083,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
position: position,
|
||||
mediaType: ContextMenuMediaType.album,
|
||||
item: widget.album,
|
||||
isFavorite: _isFavorite,
|
||||
isInLibrary: _isInLibrary,
|
||||
isFavorite: isFavorite,
|
||||
isInLibrary: isInLibrary,
|
||||
onToggleFavorite: _toggleFavorite,
|
||||
onToggleLibrary: _toggleLibrary,
|
||||
adaptiveColorScheme: adaptiveScheme,
|
||||
|
||||
@@ -18,6 +18,8 @@ import '../services/debug_logger.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import '../services/library_status_service.dart';
|
||||
import '../widgets/library_status_builder.dart';
|
||||
|
||||
class ArtistDetailsScreen extends StatefulWidget {
|
||||
final Artist artist;
|
||||
@@ -35,13 +37,11 @@ class ArtistDetailsScreen extends StatefulWidget {
|
||||
State<ArtistDetailsScreen> createState() => _ArtistDetailsScreenState();
|
||||
}
|
||||
|
||||
class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryStatusMixin {
|
||||
final _logger = DebugLogger();
|
||||
List<Album> _albums = [];
|
||||
List<Album> _providerAlbums = [];
|
||||
bool _isLoading = true;
|
||||
bool _isFavorite = false;
|
||||
bool _isInLibrary = false;
|
||||
ColorScheme? _lightColorScheme;
|
||||
ColorScheme? _darkColorScheme;
|
||||
bool _isDescriptionExpanded = false;
|
||||
@@ -49,6 +49,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
String? _artistImageUrl;
|
||||
MusicAssistantProvider? _maProvider;
|
||||
|
||||
@override
|
||||
String get libraryItemKey => LibraryStatusService.makeKey(
|
||||
'artist',
|
||||
widget.artist.provider,
|
||||
widget.artist.itemId,
|
||||
);
|
||||
|
||||
// View preferences
|
||||
String _sortOrder = 'alpha'; // 'alpha' or 'year'
|
||||
String _viewMode = 'grid2'; // 'grid2', 'grid3', 'list'
|
||||
@@ -58,8 +65,15 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isFavorite = widget.artist.favorite ?? false;
|
||||
_isInLibrary = _checkIfInLibrary(widget.artist);
|
||||
// Initialize status in centralized service from widget data
|
||||
final service = LibraryStatusService.instance;
|
||||
final key = libraryItemKey;
|
||||
if (!service.isInLibrary(key) && _checkIfInLibrary(widget.artist)) {
|
||||
service.setLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
|
||||
service.setFavoriteStatus(key, true);
|
||||
}
|
||||
// Use initial image URL immediately for smooth hero animation
|
||||
_artistImageUrl = widget.initialImageUrl;
|
||||
_loadViewPreferences();
|
||||
@@ -96,12 +110,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
|
||||
void _onProviderChanged() {
|
||||
if (!mounted) return;
|
||||
// Re-check library status when provider data changes
|
||||
// Re-check library status when provider data changes and sync to service
|
||||
final newIsInLibrary = _checkIfInLibraryFromProvider();
|
||||
if (newIsInLibrary != _isInLibrary) {
|
||||
setState(() {
|
||||
_isInLibrary = newIsInLibrary;
|
||||
});
|
||||
final service = LibraryStatusService.instance;
|
||||
final key = libraryItemKey;
|
||||
if (newIsInLibrary != service.isInLibrary(key)) {
|
||||
service.setLibraryStatus(key, newIsInLibrary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,9 +228,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
try {
|
||||
final freshArtist = await maProvider.api!.getArtistByUri(artistUri);
|
||||
if (freshArtist != null && mounted) {
|
||||
setState(() {
|
||||
_isFavorite = freshArtist.favorite ?? false;
|
||||
});
|
||||
// Sync with centralized service
|
||||
final service = LibraryStatusService.instance;
|
||||
final key = libraryItemKey;
|
||||
final newFavorite = freshArtist.favorite ?? false;
|
||||
if (service.isFavorite(key) != newFavorite) {
|
||||
service.setFavoriteStatus(key, newFavorite);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error refreshing artist favorite status: $e');
|
||||
@@ -225,9 +243,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
|
||||
Future<void> _toggleFavorite() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final currentFavorite = isFavorite;
|
||||
final newState = !currentFavorite;
|
||||
|
||||
// Optimistic update via centralized service
|
||||
setFavoriteStatus(newState);
|
||||
|
||||
try {
|
||||
final newState = !_isFavorite;
|
||||
bool success;
|
||||
|
||||
if (newState) {
|
||||
@@ -270,6 +292,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
|
||||
if (libraryItemId == null) {
|
||||
_logger.log('Error: Could not determine library_item_id for removal');
|
||||
rollbackFavoriteOperation();
|
||||
throw Exception('Could not determine library ID for this artist');
|
||||
}
|
||||
|
||||
@@ -280,10 +303,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
}
|
||||
|
||||
if (success) {
|
||||
setState(() {
|
||||
_isFavorite = newState;
|
||||
});
|
||||
|
||||
completeFavoriteOperation();
|
||||
maProvider.invalidateHomeCache();
|
||||
|
||||
if (mounted) {
|
||||
@@ -293,14 +313,17 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
content: Text(
|
||||
isOffline
|
||||
? S.of(context)!.actionQueuedForSync
|
||||
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||
: (newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||
),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
rollbackFavoriteOperation();
|
||||
}
|
||||
} catch (e) {
|
||||
rollbackFavoriteOperation();
|
||||
_logger.log('Error toggling artist favorite: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -322,10 +345,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
/// Toggle library status
|
||||
Future<void> _toggleLibrary() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final currentInLibrary = isInLibrary;
|
||||
final newState = !currentInLibrary;
|
||||
|
||||
try {
|
||||
final newState = !_isInLibrary;
|
||||
|
||||
if (newState) {
|
||||
// Add to library - MUST use non-library provider
|
||||
String? actualProvider;
|
||||
@@ -355,10 +378,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -370,21 +391,17 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
}
|
||||
|
||||
_logger.log('Adding artist to library: provider=$actualProvider, itemId=$actualItemId');
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.addToLibrary(
|
||||
final success = await maProvider.addToLibrary(
|
||||
mediaType: 'artist',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to add artist to library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
);
|
||||
|
||||
if (success) {
|
||||
completeLibraryOperation();
|
||||
} else {
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
} else {
|
||||
// Remove from library
|
||||
int? libraryItemId;
|
||||
@@ -405,10 +422,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -419,22 +434,19 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.removeFromLibrary(
|
||||
final success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'artist',
|
||||
libraryItemId: libraryItemId,
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to remove artist from library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
);
|
||||
|
||||
if (success) {
|
||||
completeLibraryOperation();
|
||||
} else {
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
rollbackLibraryOperation();
|
||||
_logger.log('Error toggling artist library: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -1009,8 +1021,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
child: Icon(
|
||||
Symbols.book_2,
|
||||
size: 25,
|
||||
fill: _isInLibrary ? 1 : 0,
|
||||
color: _isInLibrary
|
||||
fill: isInLibrary ? 1 : 0,
|
||||
color: isInLibrary
|
||||
? colorScheme.primary
|
||||
: Colors.white70,
|
||||
),
|
||||
@@ -1034,7 +1046,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
child: Icon(
|
||||
Icons.favorite,
|
||||
size: 25,
|
||||
color: _isFavorite
|
||||
color: isFavorite
|
||||
? colorScheme.error
|
||||
: Colors.white70,
|
||||
),
|
||||
@@ -1057,8 +1069,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
position: position,
|
||||
mediaType: ContextMenuMediaType.artist,
|
||||
item: widget.artist,
|
||||
isFavorite: _isFavorite,
|
||||
isInLibrary: _isInLibrary,
|
||||
isFavorite: isFavorite,
|
||||
isInLibrary: isInLibrary,
|
||||
onToggleFavorite: _toggleFavorite,
|
||||
onToggleLibrary: _toggleLibrary,
|
||||
adaptiveColorScheme: adaptiveScheme,
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../widgets/series_row.dart';
|
||||
import '../widgets/playlist_row.dart';
|
||||
import '../widgets/radio_station_row.dart';
|
||||
import '../widgets/podcast_row.dart';
|
||||
import '../widgets/mixes_row.dart';
|
||||
import '../widgets/common/disconnected_state.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
@@ -34,6 +35,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
bool _showRecentAlbums = true;
|
||||
bool _showDiscoverArtists = true;
|
||||
bool _showDiscoverAlbums = true;
|
||||
bool _showExternalMixes = false;
|
||||
// Favorites rows (default off)
|
||||
bool _showFavoriteAlbums = false;
|
||||
bool _showFavoriteArtists = false;
|
||||
@@ -146,6 +148,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
final showRecent = await SettingsService.getShowRecentAlbums();
|
||||
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
|
||||
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
|
||||
final showExternalMixes = await SettingsService.getShowExternalMixes();
|
||||
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||
@@ -161,6 +164,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
_showRecentAlbums = showRecent;
|
||||
_showDiscoverArtists = showDiscArtists;
|
||||
_showDiscoverAlbums = showDiscAlbums;
|
||||
_showExternalMixes = showExternalMixes;
|
||||
_showFavoriteAlbums = showFavAlbums;
|
||||
_showFavoriteArtists = showFavArtists;
|
||||
_showFavoriteTracks = showFavTracks;
|
||||
@@ -335,6 +339,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
case 'recent-albums': return _showRecentAlbums;
|
||||
case 'discover-artists': return _showDiscoverArtists;
|
||||
case 'discover-albums': return _showDiscoverAlbums;
|
||||
case 'external-mixes': return _showExternalMixes;
|
||||
case 'continue-listening': return _showContinueListeningAudiobooks;
|
||||
case 'discover-audiobooks': return _showDiscoverAudiobooks;
|
||||
case 'discover-series': return _showDiscoverSeries;
|
||||
@@ -355,7 +360,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
builder: (context, constraints) {
|
||||
// Each row is always 1/3 of screen height
|
||||
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll
|
||||
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
|
||||
final maxHeight = constraints.maxHeight.isFinite
|
||||
? constraints.maxHeight
|
||||
: MediaQuery.of(context).size.height;
|
||||
final availableHeight = (maxHeight - BottomSpacing.withMiniPlayer).clamp(0.0, double.infinity);
|
||||
|
||||
// Account for margins between rows (2px each)
|
||||
// Only adjust for margins when ≤3 rows (so they fit exactly without scroll)
|
||||
@@ -363,7 +371,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
const marginSize = 2.0;
|
||||
final enabledRows = _countEnabledRows();
|
||||
final marginsInView = enabledRows > 1 && enabledRows <= 3 ? (enabledRows - 1) * marginSize : 0.0;
|
||||
final rowHeight = (availableHeight - marginsInView) / 3;
|
||||
final rawRowHeight = (availableHeight - marginsInView) / 3;
|
||||
final rowHeight = rawRowHeight.isFinite && rawRowHeight > 0 ? rawRowHeight : 237.0;
|
||||
|
||||
// Use Android 12+ stretch overscroll effect
|
||||
return NotificationListener<ScrollNotification>(
|
||||
@@ -446,6 +455,16 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
getCachedAlbums: () => provider.getCachedDiscoverAlbums(),
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'external-mixes':
|
||||
if (!_showExternalMixes) return null;
|
||||
return MixesRow(
|
||||
key: const ValueKey('external-mixes'),
|
||||
title: S.of(context)!.playlistMixes,
|
||||
loadMixes: () => provider.getExternalMixesWithCache(),
|
||||
getCachedMixes: () => provider.getCachedExternalMixes(),
|
||||
heroTagSuffix: 'home',
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'continue-listening':
|
||||
if (!_showContinueListeningAudiobooks) return null;
|
||||
return AudiobookRow(
|
||||
|
||||
@@ -1506,7 +1506,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
itemCount: _tabCount,
|
||||
// Use default physics - custom physics may cause issues with Flutter 3.38
|
||||
physics: const PageScrollPhysics(),
|
||||
itemBuilder: (context, index) => _buildTabAtIndex(context, l10n, index),
|
||||
itemBuilder: (context, index) => Builder(
|
||||
builder: (context) => _buildTabAtIndex(context, l10n, index),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -570,35 +570,47 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Library-only toggle with circle background
|
||||
Tooltip(
|
||||
message: S.of(context)!.libraryOnly,
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: Material(
|
||||
color: colorScheme.surfaceContainerHighest.withOpacity(0.6),
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_libraryOnly = !_libraryOnly;
|
||||
});
|
||||
if (_searchController.text.isNotEmpty) {
|
||||
_performSearch(_searchController.text);
|
||||
}
|
||||
},
|
||||
customBorder: const CircleBorder(),
|
||||
child: Icon(
|
||||
Symbols.book_2,
|
||||
fill: _libraryOnly ? 1 : 0,
|
||||
size: 22,
|
||||
color: _libraryOnly
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurface,
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final control = SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: Material(
|
||||
color: colorScheme.surfaceContainerHighest.withOpacity(0.6),
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_libraryOnly = !_libraryOnly;
|
||||
});
|
||||
if (_searchController.text.isNotEmpty) {
|
||||
_performSearch(_searchController.text);
|
||||
}
|
||||
},
|
||||
customBorder: const CircleBorder(),
|
||||
child: Icon(
|
||||
Symbols.book_2,
|
||||
fill: _libraryOnly ? 1 : 0,
|
||||
size: 22,
|
||||
color: _libraryOnly
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Avoid crash when Overlay isn't available (e.g., certain overlay contexts).
|
||||
final hasOverlay = Overlay.maybeOf(context) != null;
|
||||
if (!hasOverlay) {
|
||||
return control;
|
||||
}
|
||||
|
||||
return Tooltip(
|
||||
message: S.of(context)!.libraryOnly,
|
||||
child: control,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -26,6 +26,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _showRecentAlbums = true;
|
||||
bool _showDiscoverArtists = true;
|
||||
bool _showDiscoverAlbums = true;
|
||||
bool _showExternalMixes = false;
|
||||
// Favorites rows (default off)
|
||||
bool _showFavoriteAlbums = false;
|
||||
bool _showFavoriteArtists = false;
|
||||
@@ -40,9 +41,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Home row order
|
||||
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
|
||||
// Player settings
|
||||
bool _preferLocalPlayer = false;
|
||||
bool _smartSortPlayers = false;
|
||||
bool _disableAutoSwitch = false;
|
||||
bool _volumePrecisionMode = true;
|
||||
// Hint settings
|
||||
bool _showHints = true;
|
||||
@@ -70,6 +69,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final showRecent = await SettingsService.getShowRecentAlbums();
|
||||
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
|
||||
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
|
||||
final showExternalMixes = await SettingsService.getShowExternalMixes();
|
||||
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||
@@ -86,9 +86,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final rowOrder = await SettingsService.getHomeRowOrder();
|
||||
|
||||
// Load player settings
|
||||
final preferLocal = await SettingsService.getPreferLocalPlayer();
|
||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
|
||||
final volumePrecision = await SettingsService.getVolumePrecisionMode();
|
||||
|
||||
// Load hint settings
|
||||
@@ -102,6 +100,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_showRecentAlbums = showRecent;
|
||||
_showDiscoverArtists = showDiscArtists;
|
||||
_showDiscoverAlbums = showDiscAlbums;
|
||||
_showExternalMixes = showExternalMixes;
|
||||
_showFavoriteAlbums = showFavAlbums;
|
||||
_showFavoriteArtists = showFavArtists;
|
||||
_showFavoriteTracks = showFavTracks;
|
||||
@@ -112,9 +111,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
_homeRowOrder = rowOrder;
|
||||
_preferLocalPlayer = preferLocal;
|
||||
_smartSortPlayers = smartSort;
|
||||
_disableAutoSwitch = disableAutoSwitch;
|
||||
_volumePrecisionMode = volumePrecision;
|
||||
_showHints = showHints;
|
||||
_showProviderIcons = showProviderIcons;
|
||||
@@ -139,6 +136,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return {'title': s.discoverArtists, 'subtitle': s.showRandomArtists};
|
||||
case 'discover-albums':
|
||||
return {'title': s.discoverAlbums, 'subtitle': s.showRandomAlbums};
|
||||
case 'external-mixes':
|
||||
return {'title': s.playlistMixes, 'subtitle': s.showPlaylistMixes};
|
||||
case 'continue-listening':
|
||||
return {'title': s.continueListening, 'subtitle': s.showAudiobooksInProgress};
|
||||
case 'discover-audiobooks':
|
||||
@@ -171,6 +170,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return _showDiscoverArtists;
|
||||
case 'discover-albums':
|
||||
return _showDiscoverAlbums;
|
||||
case 'external-mixes':
|
||||
return _showExternalMixes;
|
||||
case 'continue-listening':
|
||||
return _showContinueListeningAudiobooks;
|
||||
case 'discover-audiobooks':
|
||||
@@ -210,6 +211,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_showDiscoverAlbums = value;
|
||||
SettingsService.setShowDiscoverAlbums(value);
|
||||
break;
|
||||
case 'external-mixes':
|
||||
_showExternalMixes = value;
|
||||
SettingsService.setShowExternalMixes(value);
|
||||
break;
|
||||
case 'continue-listening':
|
||||
_showContinueListeningAudiobooks = value;
|
||||
SettingsService.setShowContinueListeningAudiobooks(value);
|
||||
@@ -703,33 +708,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
title: Text(
|
||||
S.of(context)!.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),
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
@@ -754,32 +732,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
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)!.disableAutoSwitch,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
subtitle: Text(
|
||||
S.of(context)!.disableAutoSwitchDescription,
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||
),
|
||||
value: _disableAutoSwitch,
|
||||
onChanged: (value) {
|
||||
setState(() => _disableAutoSwitch = value);
|
||||
SettingsService.setDisableAutoSwitch(value);
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import '../models/player.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/database_service.dart';
|
||||
@@ -20,12 +21,14 @@ class CacheService {
|
||||
List<Album>? _cachedRecentAlbums;
|
||||
List<Artist>? _cachedDiscoverArtists;
|
||||
List<Album>? _cachedDiscoverAlbums;
|
||||
List<RecommendationFolder>? _cachedExternalMixes;
|
||||
List<Audiobook>? _cachedInProgressAudiobooks;
|
||||
List<Audiobook>? _cachedDiscoverAudiobooks;
|
||||
List<AudiobookSeries>? _cachedDiscoverSeries;
|
||||
DateTime? _recentAlbumsLastFetched;
|
||||
DateTime? _discoverArtistsLastFetched;
|
||||
DateTime? _discoverAlbumsLastFetched;
|
||||
DateTime? _externalMixesLastFetched;
|
||||
DateTime? _inProgressAudiobooksLastFetched;
|
||||
DateTime? _discoverAudiobooksLastFetched;
|
||||
DateTime? _discoverSeriesLastFetched;
|
||||
@@ -118,6 +121,27 @@ class CacheService {
|
||||
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Check if external mixes cache is valid
|
||||
bool isExternalMixesCacheValid({bool forceRefresh = false}) {
|
||||
if (forceRefresh) return false;
|
||||
final now = DateTime.now();
|
||||
return _cachedExternalMixes != null &&
|
||||
_externalMixesLastFetched != null &&
|
||||
now.difference(_externalMixesLastFetched!) < Timings.homeRowCacheDuration;
|
||||
}
|
||||
|
||||
/// Get cached external mixes
|
||||
List<RecommendationFolder>? getCachedExternalMixes() => _cachedExternalMixes;
|
||||
|
||||
/// Set cached external mixes
|
||||
void setCachedExternalMixes(List<RecommendationFolder> mixes) {
|
||||
_cachedExternalMixes = mixes;
|
||||
_externalMixesLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${mixes.length} external mix shelves');
|
||||
// Persist to database for instant load on next launch
|
||||
_persistHomeRowToDatabase('external_mixes', mixes.map((m) => m.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Check if in-progress audiobooks cache is valid
|
||||
bool isInProgressAudiobooksCacheValid({bool forceRefresh = false}) {
|
||||
if (forceRefresh) return false;
|
||||
@@ -191,6 +215,7 @@ class CacheService {
|
||||
_recentAlbumsLastFetched = null;
|
||||
_discoverArtistsLastFetched = null;
|
||||
_discoverAlbumsLastFetched = null;
|
||||
_externalMixesLastFetched = null;
|
||||
_inProgressAudiobooksLastFetched = null;
|
||||
_discoverAudiobooksLastFetched = null;
|
||||
_discoverSeriesLastFetched = null;
|
||||
@@ -220,6 +245,8 @@ class CacheService {
|
||||
_discoverArtistsLastFetched = null;
|
||||
_cachedDiscoverAlbums = null;
|
||||
_discoverAlbumsLastFetched = null;
|
||||
_cachedExternalMixes = null;
|
||||
_externalMixesLastFetched = null;
|
||||
_cachedRecentAlbums = null;
|
||||
_recentAlbumsLastFetched = null;
|
||||
_searchCache.clear();
|
||||
@@ -499,6 +526,21 @@ class CacheService {
|
||||
_logger.log('⚠️ Failed to parse discover albums: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Load external mixes
|
||||
final mixesData = await DatabaseService.instance.getHomeRowCache('external_mixes');
|
||||
if (mixesData != null) {
|
||||
try {
|
||||
final items = (jsonDecode(mixesData.itemsJson) as List)
|
||||
.map((json) => RecommendationFolder.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
_cachedExternalMixes = items;
|
||||
_externalMixesLastFetched = mixesData.lastUpdated;
|
||||
_logger.log('📦 Loaded ${items.length} external mix shelves from database');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse external mixes: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error loading home rows from database: $e');
|
||||
}
|
||||
|
||||
@@ -193,6 +193,33 @@ class DatabaseService {
|
||||
return db.markItemsDeleted(itemType, [itemId]);
|
||||
}
|
||||
|
||||
/// Mark a cached item as deleted by searching for library ID in the data
|
||||
/// This handles cases where itemId is different from library ID
|
||||
Future<void> markCachedItemDeletedByLibraryId(String itemType, String libraryId) async {
|
||||
// First try direct match (for provider=library items)
|
||||
await db.markItemsDeleted(itemType, [libraryId]);
|
||||
|
||||
// Also search through cached items to find any with matching library mapping
|
||||
final items = await db.getCachedItems(itemType);
|
||||
for (final item in items) {
|
||||
try {
|
||||
// Check if this item's data contains a library mapping with this ID
|
||||
if (item.data.contains('"provider_instance":"library"') &&
|
||||
item.data.contains('"item_id":"$libraryId"')) {
|
||||
// Found a match - extract the actual cache key's itemId
|
||||
final cacheKey = item.cacheKey;
|
||||
final parts = cacheKey.split('_');
|
||||
if (parts.length >= 2) {
|
||||
final actualItemId = parts.sublist(1).join('_');
|
||||
await db.markItemsDeleted(itemType, [actualItemId]);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore parse errors, continue checking other items
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch cache multiple items efficiently
|
||||
/// Uses drift's batch API for much better performance on large datasets.
|
||||
/// Items are automatically chunked into batches of 500.
|
||||
|
||||
@@ -12,7 +12,7 @@ class HardwareVolumeService {
|
||||
factory HardwareVolumeService() => _instance;
|
||||
HardwareVolumeService._internal();
|
||||
|
||||
static const _channel = MethodChannel('com.musicassistant.music_assistant/volume_buttons');
|
||||
static const _channel = MethodChannel('com.collotsspot.ensemble/volume_buttons');
|
||||
final _logger = DebugLogger();
|
||||
|
||||
final _volumeUpController = StreamController<void>.broadcast();
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Centralized service for tracking library and favorite status of media items.
|
||||
///
|
||||
/// This service implements the "single source of truth" pattern:
|
||||
/// - All widgets query this service instead of maintaining local state
|
||||
/// - Status changes notify all listeners immediately (optimistic updates)
|
||||
/// - API failures trigger rollback notifications
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // Check status
|
||||
/// final isInLibrary = LibraryStatusService.instance.isInLibrary(itemKey);
|
||||
///
|
||||
/// // Listen to changes
|
||||
/// LibraryStatusService.instance.addListener(() {
|
||||
/// // Rebuild UI
|
||||
/// });
|
||||
///
|
||||
/// // Update status (optimistically)
|
||||
/// LibraryStatusService.instance.setLibraryStatus(itemKey, true);
|
||||
/// ```
|
||||
class LibraryStatusService extends ChangeNotifier {
|
||||
LibraryStatusService._();
|
||||
static final LibraryStatusService instance = LibraryStatusService._();
|
||||
|
||||
// Status maps: itemKey -> status
|
||||
// itemKey format: "mediaType:provider:itemId" for uniqueness
|
||||
final Map<String, bool> _libraryStatus = {};
|
||||
final Map<String, bool> _favoriteStatus = {};
|
||||
|
||||
// Pending operations for rollback on failure
|
||||
final Map<String, bool> _pendingLibraryOps = {};
|
||||
final Map<String, bool> _pendingFavoriteOps = {};
|
||||
|
||||
/// Generate a unique key for an item
|
||||
static String makeKey(String mediaType, String provider, String itemId) {
|
||||
return '$mediaType:$provider:$itemId';
|
||||
}
|
||||
|
||||
/// Check if item is in library
|
||||
bool isInLibrary(String key) => _libraryStatus[key] ?? false;
|
||||
|
||||
/// Check if item is favorite
|
||||
bool isFavorite(String key) => _favoriteStatus[key] ?? false;
|
||||
|
||||
/// Set library status (optimistic update)
|
||||
/// Call this immediately when user taps the button
|
||||
void setLibraryStatus(String key, bool inLibrary) {
|
||||
if (_libraryStatus[key] == inLibrary) return;
|
||||
|
||||
_libraryStatus[key] = inLibrary;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Set favorite status (optimistic update)
|
||||
void setFavoriteStatus(String key, bool favorite) {
|
||||
if (_favoriteStatus[key] == favorite) return;
|
||||
|
||||
_favoriteStatus[key] = favorite;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Mark operation as pending (for rollback tracking)
|
||||
void markLibraryPending(String key, bool previousValue) {
|
||||
_pendingLibraryOps[key] = previousValue;
|
||||
}
|
||||
|
||||
void markFavoritePending(String key, bool previousValue) {
|
||||
_pendingFavoriteOps[key] = previousValue;
|
||||
}
|
||||
|
||||
/// Complete operation successfully (clear pending)
|
||||
void completeLibraryOperation(String key) {
|
||||
_pendingLibraryOps.remove(key);
|
||||
}
|
||||
|
||||
void completeFavoriteOperation(String key) {
|
||||
_pendingFavoriteOps.remove(key);
|
||||
}
|
||||
|
||||
/// Rollback operation on failure
|
||||
void rollbackLibraryOperation(String key) {
|
||||
final previousValue = _pendingLibraryOps.remove(key);
|
||||
if (previousValue != null) {
|
||||
_libraryStatus[key] = previousValue;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void rollbackFavoriteOperation(String key) {
|
||||
final previousValue = _pendingFavoriteOps.remove(key);
|
||||
if (previousValue != null) {
|
||||
_favoriteStatus[key] = previousValue;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Bulk update from provider's library data
|
||||
/// Called when library is loaded/refreshed
|
||||
void syncFromLibrary({
|
||||
required String mediaType,
|
||||
required List<dynamic> items,
|
||||
required bool Function(dynamic item) getInLibrary,
|
||||
required bool Function(dynamic item) getFavorite,
|
||||
required String Function(dynamic item) getProvider,
|
||||
required String Function(dynamic item) getItemId,
|
||||
}) {
|
||||
bool changed = false;
|
||||
|
||||
for (final item in items) {
|
||||
final key = makeKey(mediaType, getProvider(item), getItemId(item));
|
||||
final inLibrary = getInLibrary(item);
|
||||
final favorite = getFavorite(item);
|
||||
|
||||
// Only update if not pending (don't overwrite optimistic updates)
|
||||
if (!_pendingLibraryOps.containsKey(key)) {
|
||||
if (_libraryStatus[key] != inLibrary) {
|
||||
_libraryStatus[key] = inLibrary;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!_pendingFavoriteOps.containsKey(key)) {
|
||||
if (_favoriteStatus[key] != favorite) {
|
||||
_favoriteStatus[key] = favorite;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a single item's status from fresh data
|
||||
void syncSingleItem({
|
||||
required String key,
|
||||
required bool inLibrary,
|
||||
required bool favorite,
|
||||
}) {
|
||||
bool changed = false;
|
||||
|
||||
if (!_pendingLibraryOps.containsKey(key) && _libraryStatus[key] != inLibrary) {
|
||||
_libraryStatus[key] = inLibrary;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!_pendingFavoriteOps.containsKey(key) && _favoriteStatus[key] != favorite) {
|
||||
_favoriteStatus[key] = favorite;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all status (on logout/disconnect)
|
||||
void clear() {
|
||||
_libraryStatus.clear();
|
||||
_favoriteStatus.clear();
|
||||
_pendingLibraryOps.clear();
|
||||
_pendingFavoriteOps.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Get debug info
|
||||
Map<String, dynamic> get debugInfo => {
|
||||
'libraryCount': _libraryStatus.length,
|
||||
'favoriteCount': _favoriteStatus.length,
|
||||
'pendingLibrary': _pendingLibraryOps.length,
|
||||
'pendingFavorite': _pendingFavoriteOps.length,
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../models/provider_instance.dart';
|
||||
import '../models/provider_manifest.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import 'debug_logger.dart';
|
||||
import 'settings_service.dart';
|
||||
import 'device_id_service.dart';
|
||||
@@ -412,7 +413,8 @@ class MusicAssistantAPI {
|
||||
args: args,
|
||||
);
|
||||
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
final items = response['result'] as List<dynamic>? ??
|
||||
response['items'] as List<dynamic>?;
|
||||
if (items == null) {
|
||||
return [];
|
||||
}
|
||||
@@ -592,6 +594,28 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ RECOMMENDATIONS ============
|
||||
|
||||
/// Get recommendation folders (mixes, recommendations, etc.)
|
||||
Future<List<RecommendationFolder>> getRecommendations() async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/recommendations',
|
||||
);
|
||||
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
if (items == null) return [];
|
||||
|
||||
return items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(RecommendationFolder.fromJson)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
_logger.log('Error getting recommendations: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============ PODCASTS ============
|
||||
|
||||
/// Get all podcasts from the library
|
||||
|
||||
@@ -90,12 +90,11 @@ 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 _keyDisableAutoSwitch = 'disable_auto_switch';
|
||||
static const String _keyShowRecentAlbums = 'show_recent_albums';
|
||||
static const String _keyShowDiscoverArtists = 'show_discover_artists';
|
||||
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
|
||||
static const String _keyShowExternalMixes = 'show_external_mixes';
|
||||
static const String _keyShowContinueListeningAudiobooks = 'show_continue_listening_audiobooks';
|
||||
static const String _keyShowDiscoverAudiobooks = 'show_discover_audiobooks';
|
||||
static const String _keyShowDiscoverSeries = 'show_discover_series';
|
||||
@@ -113,6 +112,7 @@ class SettingsService {
|
||||
'recent-albums',
|
||||
'discover-artists',
|
||||
'discover-albums',
|
||||
'external-mixes',
|
||||
'continue-listening',
|
||||
'discover-audiobooks',
|
||||
'discover-series',
|
||||
@@ -355,18 +355,10 @@ class SettingsService {
|
||||
static Future<void> setLastSelectedPlayerId(String? playerId) =>
|
||||
_setString(_keyLastSelectedPlayerId, playerId, removeIfEmpty: true);
|
||||
|
||||
// Prefer Local Player - always select local player first when available
|
||||
static Future<bool> getPreferLocalPlayer() => _getBool(_keyPreferLocalPlayer, defaultValue: false);
|
||||
static Future<void> setPreferLocalPlayer(bool prefer) => _setBool(_keyPreferLocalPlayer, prefer);
|
||||
|
||||
// Smart Sort Players - sort by status (playing > on > off) instead of alphabetically
|
||||
static Future<bool> getSmartSortPlayers() => _getBool(_keySmartSortPlayers, defaultValue: false);
|
||||
static Future<void> setSmartSortPlayers(bool smartSort) => _setBool(_keySmartSortPlayers, smartSort);
|
||||
|
||||
// Disable Auto-Switch - prevents automatic switching to playing players
|
||||
static Future<bool> getDisableAutoSwitch() => _getBool(_keyDisableAutoSwitch, defaultValue: false);
|
||||
static Future<void> setDisableAutoSwitch(bool disable) => _setBool(_keyDisableAutoSwitch, disable);
|
||||
|
||||
// Helper to create player name with possessive apostrophe
|
||||
// Automatically detects Phone vs Tablet based on screen size
|
||||
static String _makePlayerName(String ownerName) {
|
||||
@@ -401,6 +393,9 @@ class SettingsService {
|
||||
static Future<bool> getShowDiscoverAlbums() => _getBool(_keyShowDiscoverAlbums, defaultValue: true);
|
||||
static Future<void> setShowDiscoverAlbums(bool show) => _setBool(_keyShowDiscoverAlbums, show);
|
||||
|
||||
static Future<bool> getShowExternalMixes() => _getBool(_keyShowExternalMixes, defaultValue: false);
|
||||
static Future<void> setShowExternalMixes(bool show) => _setBool(_keyShowExternalMixes, show);
|
||||
|
||||
// Home Screen Audiobook Rows (default off - optional)
|
||||
static Future<bool> getShowContinueListeningAudiobooks() => _getBool(_keyShowContinueListeningAudiobooks, defaultValue: false);
|
||||
static Future<void> setShowContinueListeningAudiobooks(bool show) => _setBool(_keyShowContinueListeningAudiobooks, show);
|
||||
|
||||
@@ -596,4 +596,78 @@ class SyncService with ChangeNotifier {
|
||||
_cachedPlaylists = playlists;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Remove an item from in-memory cache by library ID
|
||||
/// Called when an item is removed from library to keep SyncService in sync
|
||||
void removeFromCacheByLibraryId(String mediaType, int libraryItemId) {
|
||||
final libraryIdStr = libraryItemId.toString();
|
||||
bool updated = false;
|
||||
|
||||
// Helper to check if item matches the library ID being removed
|
||||
bool matchesLibraryId(String? provider, String? itemId, List<ProviderMapping>? mappings) {
|
||||
if (provider == 'library' && itemId == libraryIdStr) return true;
|
||||
if (mappings != null) {
|
||||
for (final m in mappings) {
|
||||
if ((m.providerInstance == 'library' || m.providerDomain == 'library') &&
|
||||
m.itemId == libraryIdStr) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (mediaType) {
|
||||
case 'album':
|
||||
final before = _cachedAlbums.length;
|
||||
_cachedAlbums = _cachedAlbums.where((a) =>
|
||||
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
|
||||
).toList();
|
||||
updated = _cachedAlbums.length != before;
|
||||
// Also remove from source provider tracking
|
||||
if (updated) {
|
||||
_albumSourceProviders.removeWhere((key, _) =>
|
||||
_cachedAlbums.every((a) => a.itemId != key));
|
||||
}
|
||||
break;
|
||||
case 'artist':
|
||||
final before = _cachedArtists.length;
|
||||
_cachedArtists = _cachedArtists.where((a) =>
|
||||
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
|
||||
).toList();
|
||||
updated = _cachedArtists.length != before;
|
||||
if (updated) {
|
||||
_artistSourceProviders.removeWhere((key, _) =>
|
||||
_cachedArtists.every((a) => a.itemId != key));
|
||||
}
|
||||
break;
|
||||
case 'audiobook':
|
||||
final before = _cachedAudiobooks.length;
|
||||
_cachedAudiobooks = _cachedAudiobooks.where((a) =>
|
||||
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
|
||||
).toList();
|
||||
updated = _cachedAudiobooks.length != before;
|
||||
if (updated) {
|
||||
_audiobookSourceProviders.removeWhere((key, _) =>
|
||||
_cachedAudiobooks.every((a) => a.itemId != key));
|
||||
}
|
||||
break;
|
||||
case 'playlist':
|
||||
final before = _cachedPlaylists.length;
|
||||
_cachedPlaylists = _cachedPlaylists.where((p) =>
|
||||
!matchesLibraryId(p.provider, p.itemId, p.providerMappings)
|
||||
).toList();
|
||||
updated = _cachedPlaylists.length != before;
|
||||
if (updated) {
|
||||
_playlistSourceProviders.removeWhere((key, _) =>
|
||||
_cachedPlaylists.every((p) => p.itemId != key));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
_logger.log('🗑️ Removed $mediaType with libraryId=$libraryItemId from SyncService cache');
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_color_utilities/material_color_utilities.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -8,6 +10,16 @@ import '../services/debug_logger.dart';
|
||||
|
||||
final _logger = DebugLogger();
|
||||
|
||||
/// Minimum contrast ratio for UI components (WCAG 2.1 Level AA)
|
||||
const double _minContrastRatio = 3.0;
|
||||
|
||||
/// Minimum chroma for primary colors to ensure they're visually distinct
|
||||
/// Material Design 3 uses 48 for vibrant, but we use 36 as a balance
|
||||
const double _minPrimaryChroma = 36.0;
|
||||
|
||||
/// Minimum chroma for surface/container colors (lower is fine for backgrounds)
|
||||
const double _minSurfaceChroma = 6.0;
|
||||
|
||||
/// Extracted adaptive colors from album art
|
||||
class AdaptiveColors {
|
||||
final Color primary; // Main accent color (vibrant)
|
||||
@@ -50,11 +62,96 @@ class _ExtractedColors {
|
||||
});
|
||||
}
|
||||
|
||||
/// Helper to get contrasting text color based on background luminance
|
||||
/// Calculate WCAG 2.1 contrast ratio between two colors
|
||||
double _contrastRatio(Color c1, Color c2) {
|
||||
final l1 = c1.computeLuminance();
|
||||
final l2 = c2.computeLuminance();
|
||||
final lighter = math.max(l1, l2);
|
||||
final darker = math.min(l1, l2);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/// Helper to get contrasting text color based on WCAG contrast ratio
|
||||
Color getContrastingTextColor(Color backgroundColor) {
|
||||
// Use relative luminance to determine if we need light or dark text
|
||||
// Standard threshold is 0.179 based on WCAG guidelines
|
||||
return backgroundColor.computeLuminance() > 0.4 ? Colors.black : Colors.white;
|
||||
// Calculate contrast with both black and white, pick the one with better contrast
|
||||
final contrastWithWhite = _contrastRatio(backgroundColor, Colors.white);
|
||||
final contrastWithBlack = _contrastRatio(backgroundColor, Colors.black);
|
||||
return contrastWithWhite >= contrastWithBlack ? Colors.white : Colors.black;
|
||||
}
|
||||
|
||||
/// Adjust a color's tone using HCT to ensure minimum contrast against a background
|
||||
/// Uses binary search to find the MINIMUM tone adjustment needed (preserves color identity)
|
||||
Color _ensureContrastHct(Color foreground, Color background, {double minRatio = _minContrastRatio}) {
|
||||
final fgHct = Hct.fromInt(foreground.value);
|
||||
final bgHct = Hct.fromInt(background.value);
|
||||
|
||||
// Ensure foreground has enough chroma to be visually distinct
|
||||
final chroma = math.max(fgHct.chroma, _minPrimaryChroma);
|
||||
|
||||
final currentRatio = _contrastRatio(foreground, background);
|
||||
if (currentRatio >= minRatio) {
|
||||
// Already has sufficient contrast, just ensure chroma
|
||||
if (fgHct.chroma < _minPrimaryChroma) {
|
||||
return Color(Hct.from(fgHct.hue, chroma, fgHct.tone).toInt());
|
||||
}
|
||||
return foreground;
|
||||
}
|
||||
|
||||
// For dark backgrounds (tone < 50), we need lighter foreground
|
||||
// For light backgrounds (tone >= 50), we need darker foreground
|
||||
final bgIsDark = bgHct.tone < 50;
|
||||
|
||||
// Binary search for the minimum tone that achieves target contrast
|
||||
double lo, hi;
|
||||
if (bgIsDark) {
|
||||
// Search upward from current tone toward white
|
||||
lo = fgHct.tone;
|
||||
hi = 100.0;
|
||||
} else {
|
||||
// Search downward from current tone toward black
|
||||
lo = 0.0;
|
||||
hi = fgHct.tone;
|
||||
}
|
||||
|
||||
double bestTone = bgIsDark ? hi : lo; // Fallback to extreme if needed
|
||||
|
||||
for (int i = 0; i < 20; i++) {
|
||||
final mid = (lo + hi) / 2;
|
||||
final testColor = Hct.from(fgHct.hue, chroma, mid);
|
||||
final testRatio = _contrastRatio(Color(testColor.toInt()), background);
|
||||
|
||||
if (bgIsDark) {
|
||||
// We want lighter - if mid works, try lower (closer to original)
|
||||
if (testRatio >= minRatio) {
|
||||
bestTone = mid;
|
||||
hi = mid; // Try to find a lower tone that still works
|
||||
} else {
|
||||
lo = mid; // Need to go lighter
|
||||
}
|
||||
} else {
|
||||
// We want darker - if mid works, try higher (closer to original)
|
||||
if (testRatio >= minRatio) {
|
||||
bestTone = mid;
|
||||
lo = mid; // Try to find a higher tone that still works
|
||||
} else {
|
||||
hi = mid; // Need to go darker
|
||||
}
|
||||
}
|
||||
|
||||
if (hi - lo < 0.5) break; // Precision reached
|
||||
}
|
||||
|
||||
return Color(Hct.from(fgHct.hue, chroma, bestTone).toInt());
|
||||
}
|
||||
|
||||
/// Ensure a color has minimum chroma (colorfulness) for primary use
|
||||
Color _ensureMinChroma(Color color) {
|
||||
final hct = Hct.fromInt(color.value);
|
||||
if (hct.chroma >= _minPrimaryChroma) {
|
||||
return color;
|
||||
}
|
||||
final adjusted = Hct.from(hct.hue, _minPrimaryChroma, hct.tone);
|
||||
return Color(adjusted.toInt());
|
||||
}
|
||||
|
||||
/// Top-level function for isolate-based color extraction
|
||||
@@ -156,11 +253,22 @@ class PaletteHelper {
|
||||
static final Map<String, Uint8List> _imageCache = {};
|
||||
static const int _maxCacheSize = 20;
|
||||
|
||||
/// Cache for extracted color schemes to avoid re-processing
|
||||
static final Map<String, (ColorScheme, ColorScheme)> _colorSchemeCache = {};
|
||||
static const int _maxColorSchemeCacheSize = 30;
|
||||
|
||||
/// Extract color schemes from a URL using isolate-based processing
|
||||
/// This is the preferred method as it doesn't block the main thread
|
||||
/// Results are cached for instant retrieval on subsequent calls
|
||||
static Future<(ColorScheme, ColorScheme)?> extractColorSchemesFromUrl(String imageUrl) async {
|
||||
try {
|
||||
// Check cache first
|
||||
// Check color scheme cache first for instant return
|
||||
final cachedSchemes = _colorSchemeCache[imageUrl];
|
||||
if (cachedSchemes != null) {
|
||||
return cachedSchemes;
|
||||
}
|
||||
|
||||
// Check image bytes cache
|
||||
Uint8List? imageBytes = _imageCache[imageUrl];
|
||||
|
||||
if (imageBytes == null) {
|
||||
@@ -184,14 +292,22 @@ class PaletteHelper {
|
||||
if (extractedColors == null) return null;
|
||||
|
||||
// Convert to ColorSchemes on main thread
|
||||
return _buildColorSchemes(extractedColors);
|
||||
final colorSchemes = _buildColorSchemes(extractedColors);
|
||||
|
||||
// Cache the color schemes for instant access next time
|
||||
if (_colorSchemeCache.length >= _maxColorSchemeCacheSize) {
|
||||
_colorSchemeCache.remove(_colorSchemeCache.keys.first);
|
||||
}
|
||||
_colorSchemeCache[imageUrl] = colorSchemes;
|
||||
|
||||
return colorSchemes;
|
||||
} catch (e) {
|
||||
_logger.warning('Failed to extract colors from URL: $e', context: 'Palette');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Build ColorSchemes from extracted color data
|
||||
/// Build ColorSchemes from extracted color data using HCT color space
|
||||
static (ColorScheme, ColorScheme) _buildColorSchemes(_ExtractedColors colors) {
|
||||
// Convert int colors to Color objects
|
||||
final vibrant = Color(colors.vibrantColor);
|
||||
@@ -201,69 +317,85 @@ class PaletteHelper {
|
||||
final muted = Color(colors.mutedColor);
|
||||
final lightMuted = Color(colors.lightMutedColor);
|
||||
|
||||
// Get primary color - prefer vibrant, then dominant
|
||||
// Get primary color - prefer vibrant, then lightVibrant, then dominant
|
||||
Color primary = vibrant;
|
||||
if (vibrant.computeLuminance() < 0.1) {
|
||||
final vibrantHct = Hct.fromInt(vibrant.value);
|
||||
if (vibrantHct.tone < 20 || vibrantHct.chroma < 8) {
|
||||
primary = lightVibrant;
|
||||
}
|
||||
if (primary.computeLuminance() < 0.1) {
|
||||
final primaryHct = Hct.fromInt(primary.value);
|
||||
if (primaryHct.tone < 20 || primaryHct.chroma < 8) {
|
||||
primary = dominant;
|
||||
}
|
||||
|
||||
// Ensure primary has enough saturation
|
||||
final hslPrimary = HSLColor.fromColor(primary);
|
||||
Color adjustedPrimary = primary;
|
||||
if (hslPrimary.saturation < 0.3) {
|
||||
adjustedPrimary = hslPrimary.withSaturation(0.5).toColor();
|
||||
}
|
||||
if (adjustedPrimary.computeLuminance() < 0.15) {
|
||||
final hsl = HSLColor.fromColor(adjustedPrimary);
|
||||
adjustedPrimary = hsl.withLightness((hsl.lightness + 0.25).clamp(0.3, 0.6)).toColor();
|
||||
}
|
||||
// Use HCT for perceptually accurate color adjustment
|
||||
// Ensure minimum chroma (colorfulness) so colors aren't muddy
|
||||
Color adjustedPrimary = _ensureMinChroma(primary);
|
||||
|
||||
// Build dark mode colors
|
||||
final hslDarkSurface = HSLColor.fromColor(darkMuted);
|
||||
final darkSurface = hslDarkSurface
|
||||
.withLightness((hslDarkSurface.lightness * 0.3).clamp(0.05, 0.15))
|
||||
.toColor();
|
||||
final darkMiniPlayer = hslDarkSurface
|
||||
.withLightness(0.3.clamp(0.25, 0.38))
|
||||
.withSaturation((hslDarkSurface.saturation * 1.2).clamp(0.15, 0.5))
|
||||
.toColor();
|
||||
// Build dark mode colors using HCT
|
||||
final darkMutedHct = Hct.fromInt(darkMuted.value);
|
||||
final darkSurface = Color(Hct.from(
|
||||
darkMutedHct.hue,
|
||||
math.min(darkMutedHct.chroma, _minSurfaceChroma), // Low chroma for surface
|
||||
math.max(darkMutedHct.tone * 0.3, 5.0).clamp(5.0, 15.0), // Very dark
|
||||
).toInt());
|
||||
final darkMiniPlayer = Color(Hct.from(
|
||||
darkMutedHct.hue,
|
||||
(darkMutedHct.chroma * 1.2).clamp(4, 16),
|
||||
30, // Fixed tone for consistency
|
||||
).toInt());
|
||||
|
||||
// Build light mode colors
|
||||
final hslLightSurface = HSLColor.fromColor(lightMuted);
|
||||
final lightSurface = hslLightSurface
|
||||
.withLightness(hslLightSurface.lightness.clamp(0.92, 0.98))
|
||||
.toColor();
|
||||
final lightMiniPlayer = hslLightSurface
|
||||
.withLightness(0.75.clamp(0.65, 0.8))
|
||||
.withSaturation((hslLightSurface.saturation * 1.2).clamp(0.15, 0.5))
|
||||
.toColor();
|
||||
// Build light mode colors using HCT
|
||||
final lightMutedHct = Hct.fromInt(lightMuted.value);
|
||||
final lightSurface = Color(Hct.from(
|
||||
lightMutedHct.hue,
|
||||
math.min(lightMutedHct.chroma, _minSurfaceChroma),
|
||||
95, // Very light
|
||||
).toInt());
|
||||
final lightMiniPlayer = Color(Hct.from(
|
||||
lightMutedHct.hue,
|
||||
(lightMutedHct.chroma * 1.2).clamp(4, 16),
|
||||
75,
|
||||
).toInt());
|
||||
|
||||
// Generate secondary container colors using HCT
|
||||
// Dark secondary container at tone 25 for tonal buttons
|
||||
final primaryHctFinal = Hct.fromInt(adjustedPrimary.value);
|
||||
final darkSecondaryContainer = Color(Hct.from(
|
||||
primaryHctFinal.hue,
|
||||
(primaryHctFinal.chroma * 0.4).clamp(4, 16),
|
||||
25, // Fixed tone for dark mode tonal buttons
|
||||
).toInt());
|
||||
final lightSecondaryContainer = Color(Hct.from(
|
||||
primaryHctFinal.hue,
|
||||
(primaryHctFinal.chroma * 0.3).clamp(4, 12),
|
||||
90, // Fixed tone for light mode tonal buttons
|
||||
).toInt());
|
||||
|
||||
// CRITICAL: Ensure primary has sufficient contrast against secondaryContainer
|
||||
// This guarantees highlighted icons are visible on tonal buttons
|
||||
final darkAdjustedPrimary = _ensureContrastHct(
|
||||
adjustedPrimary,
|
||||
darkSecondaryContainer,
|
||||
minRatio: _minContrastRatio,
|
||||
);
|
||||
final lightAdjustedPrimary = _ensureContrastHct(
|
||||
adjustedPrimary,
|
||||
lightSecondaryContainer,
|
||||
minRatio: _minContrastRatio,
|
||||
);
|
||||
|
||||
// Determine contrasting text colors
|
||||
final lightOnPrimary = getContrastingTextColor(adjustedPrimary);
|
||||
final darkOnPrimary = getContrastingTextColor(adjustedPrimary);
|
||||
final lightOnPrimary = getContrastingTextColor(lightAdjustedPrimary);
|
||||
final darkOnPrimary = getContrastingTextColor(darkAdjustedPrimary);
|
||||
final lightOnMiniPlayer = getContrastingTextColor(lightMiniPlayer);
|
||||
final darkOnMiniPlayer = getContrastingTextColor(darkMiniPlayer);
|
||||
|
||||
// Generate secondary container colors for FilledButton.tonal
|
||||
// These must be explicitly set or Flutter auto-generates them unpredictably
|
||||
final hslForSecondary = HSLColor.fromColor(adjustedPrimary);
|
||||
final lightSecondaryContainer = hslForSecondary
|
||||
.withLightness(0.9)
|
||||
.withSaturation((hslForSecondary.saturation * 0.5).clamp(0.1, 0.3))
|
||||
.toColor();
|
||||
final darkSecondaryContainer = hslForSecondary
|
||||
.withLightness(0.25)
|
||||
.withSaturation((hslForSecondary.saturation * 0.6).clamp(0.15, 0.4))
|
||||
.toColor();
|
||||
|
||||
final lightScheme = ColorScheme(
|
||||
brightness: Brightness.light,
|
||||
primary: adjustedPrimary,
|
||||
primary: lightAdjustedPrimary,
|
||||
onPrimary: lightOnPrimary,
|
||||
secondary: adjustedPrimary.withOpacity(0.8),
|
||||
secondary: lightAdjustedPrimary.withOpacity(0.8),
|
||||
onSecondary: lightOnPrimary,
|
||||
secondaryContainer: lightSecondaryContainer,
|
||||
onSecondaryContainer: Colors.black87,
|
||||
@@ -277,9 +409,9 @@ class PaletteHelper {
|
||||
|
||||
final darkScheme = ColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
primary: adjustedPrimary,
|
||||
primary: darkAdjustedPrimary,
|
||||
onPrimary: darkOnPrimary,
|
||||
secondary: adjustedPrimary.withOpacity(0.8),
|
||||
secondary: darkAdjustedPrimary.withOpacity(0.8),
|
||||
onSecondary: darkOnPrimary,
|
||||
secondaryContainer: darkSecondaryContainer,
|
||||
onSecondaryContainer: Colors.white,
|
||||
@@ -294,9 +426,10 @@ class PaletteHelper {
|
||||
return (lightScheme, darkScheme);
|
||||
}
|
||||
|
||||
/// Clear the image cache
|
||||
/// Clear all caches (image bytes and color schemes)
|
||||
static void clearCache() {
|
||||
_imageCache.clear();
|
||||
_colorSchemeCache.clear();
|
||||
}
|
||||
|
||||
/// Extract a color palette from an image with higher color count for better variety
|
||||
@@ -314,7 +447,7 @@ class PaletteHelper {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract actual colors from the palette (not seed-based)
|
||||
/// Extract actual colors from the palette using HCT color space
|
||||
static AdaptiveColors? extractAdaptiveColors(PaletteGenerator? palette, {required bool isDark}) {
|
||||
if (palette == null) return null;
|
||||
|
||||
@@ -324,22 +457,9 @@ class PaletteHelper {
|
||||
palette.dominantColor?.color ??
|
||||
const Color(0xFF604CEC);
|
||||
|
||||
// Ensure primary has enough saturation to be visually distinct
|
||||
// AND enough lightness for white text to be readable on it
|
||||
final HSLColor hslPrimary = HSLColor.fromColor(primary);
|
||||
Color adjustedPrimary = primary;
|
||||
|
||||
// Adjust saturation if too low
|
||||
if (hslPrimary.saturation < 0.3) {
|
||||
adjustedPrimary = hslPrimary.withSaturation(0.5).toColor();
|
||||
}
|
||||
|
||||
// Ensure minimum lightness for contrast with white text
|
||||
// If primary is too dark (luminance < 0.15), lighten it
|
||||
if (adjustedPrimary.computeLuminance() < 0.15) {
|
||||
final hsl = HSLColor.fromColor(adjustedPrimary);
|
||||
adjustedPrimary = hsl.withLightness((hsl.lightness + 0.25).clamp(0.3, 0.6)).toColor();
|
||||
}
|
||||
// Use HCT for perceptually accurate color adjustment
|
||||
// Ensure minimum chroma (colorfulness) so colors aren't muddy
|
||||
Color adjustedPrimary = _ensureMinChroma(primary);
|
||||
|
||||
if (isDark) {
|
||||
// Dark mode: use dark muted colors for background
|
||||
@@ -347,18 +467,19 @@ class PaletteHelper {
|
||||
palette.mutedColor?.color ??
|
||||
const Color(0xFF121212);
|
||||
|
||||
// Darken the surface color significantly for the expanded background
|
||||
final HSLColor hslSurface = HSLColor.fromColor(surfaceBase);
|
||||
final Color surface = hslSurface
|
||||
.withLightness((hslSurface.lightness * 0.3).clamp(0.05, 0.15))
|
||||
.toColor();
|
||||
// Build surface colors using HCT
|
||||
final surfaceHct = Hct.fromInt(surfaceBase.value);
|
||||
final Color surface = Color(Hct.from(
|
||||
surfaceHct.hue,
|
||||
math.min(surfaceHct.chroma, _minSurfaceChroma),
|
||||
math.max(surfaceHct.tone * 0.3, 5.0).clamp(5.0, 15.0),
|
||||
).toInt());
|
||||
|
||||
// Mini player should be medium brightness - noticeably tinted but readable
|
||||
// Use the muted color but at medium lightness (0.25-0.35 range)
|
||||
final Color miniPlayer = hslSurface
|
||||
.withLightness(0.3.clamp(0.25, 0.38))
|
||||
.withSaturation((hslSurface.saturation * 1.2).clamp(0.15, 0.5))
|
||||
.toColor();
|
||||
final Color miniPlayer = Color(Hct.from(
|
||||
surfaceHct.hue,
|
||||
(surfaceHct.chroma * 1.2).clamp(4, 16),
|
||||
30,
|
||||
).toInt());
|
||||
|
||||
return AdaptiveColors(
|
||||
primary: adjustedPrimary,
|
||||
@@ -372,16 +493,18 @@ class PaletteHelper {
|
||||
palette.mutedColor?.color ??
|
||||
Colors.white;
|
||||
|
||||
final HSLColor hslSurface = HSLColor.fromColor(surfaceBase);
|
||||
final Color surface = hslSurface
|
||||
.withLightness((hslSurface.lightness).clamp(0.92, 0.98))
|
||||
.toColor();
|
||||
final surfaceHct = Hct.fromInt(surfaceBase.value);
|
||||
final Color surface = Color(Hct.from(
|
||||
surfaceHct.hue,
|
||||
math.min(surfaceHct.chroma, _minSurfaceChroma),
|
||||
95,
|
||||
).toInt());
|
||||
|
||||
// Mini player in light mode - medium tinted
|
||||
final Color miniPlayer = hslSurface
|
||||
.withLightness(0.75.clamp(0.65, 0.8))
|
||||
.withSaturation((hslSurface.saturation * 1.2).clamp(0.15, 0.5))
|
||||
.toColor();
|
||||
final Color miniPlayer = Color(Hct.from(
|
||||
surfaceHct.hue,
|
||||
(surfaceHct.chroma * 1.2).clamp(4, 16),
|
||||
75,
|
||||
).toInt());
|
||||
|
||||
return AdaptiveColors(
|
||||
primary: adjustedPrimary,
|
||||
@@ -392,7 +515,7 @@ class PaletteHelper {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate color schemes from a palette (kept for backward compatibility)
|
||||
/// Generate color schemes from a palette using HCT color space
|
||||
static (ColorScheme, ColorScheme)? generateColorSchemes(PaletteGenerator? palette) {
|
||||
if (palette == null) return null;
|
||||
|
||||
@@ -401,29 +524,44 @@ class PaletteHelper {
|
||||
|
||||
if (lightColors == null || darkColors == null) return null;
|
||||
|
||||
// Determine contrasting text colors based on primary color luminance
|
||||
final lightOnPrimary = getContrastingTextColor(lightColors.primary);
|
||||
final darkOnPrimary = getContrastingTextColor(darkColors.primary);
|
||||
// Generate secondary container colors using HCT
|
||||
final lightPrimaryHct = Hct.fromInt(lightColors.primary.value);
|
||||
final lightSecondaryContainer = Color(Hct.from(
|
||||
lightPrimaryHct.hue,
|
||||
(lightPrimaryHct.chroma * 0.3).clamp(4, 12),
|
||||
90, // Fixed tone for light mode tonal buttons
|
||||
).toInt());
|
||||
|
||||
final darkPrimaryHct = Hct.fromInt(darkColors.primary.value);
|
||||
final darkSecondaryContainer = Color(Hct.from(
|
||||
darkPrimaryHct.hue,
|
||||
(darkPrimaryHct.chroma * 0.4).clamp(4, 16),
|
||||
25, // Fixed tone for dark mode tonal buttons
|
||||
).toInt());
|
||||
|
||||
// CRITICAL: Ensure primary has sufficient contrast against secondaryContainer
|
||||
final lightAdjustedPrimary = _ensureContrastHct(
|
||||
lightColors.primary,
|
||||
lightSecondaryContainer,
|
||||
minRatio: _minContrastRatio,
|
||||
);
|
||||
final darkAdjustedPrimary = _ensureContrastHct(
|
||||
darkColors.primary,
|
||||
darkSecondaryContainer,
|
||||
minRatio: _minContrastRatio,
|
||||
);
|
||||
|
||||
// Determine contrasting text colors
|
||||
final lightOnPrimary = getContrastingTextColor(lightAdjustedPrimary);
|
||||
final darkOnPrimary = getContrastingTextColor(darkAdjustedPrimary);
|
||||
final lightOnMiniPlayer = getContrastingTextColor(lightColors.miniPlayer);
|
||||
final darkOnMiniPlayer = getContrastingTextColor(darkColors.miniPlayer);
|
||||
|
||||
// Generate secondary container colors for FilledButton.tonal
|
||||
final hslLightPrimary = HSLColor.fromColor(lightColors.primary);
|
||||
final lightSecondaryContainer = hslLightPrimary
|
||||
.withLightness(0.9)
|
||||
.withSaturation((hslLightPrimary.saturation * 0.5).clamp(0.1, 0.3))
|
||||
.toColor();
|
||||
final hslDarkPrimary = HSLColor.fromColor(darkColors.primary);
|
||||
final darkSecondaryContainer = hslDarkPrimary
|
||||
.withLightness(0.25)
|
||||
.withSaturation((hslDarkPrimary.saturation * 0.6).clamp(0.15, 0.4))
|
||||
.toColor();
|
||||
|
||||
final lightScheme = ColorScheme(
|
||||
brightness: Brightness.light,
|
||||
primary: lightColors.primary,
|
||||
primary: lightAdjustedPrimary,
|
||||
onPrimary: lightOnPrimary,
|
||||
secondary: lightColors.primary.withOpacity(0.8),
|
||||
secondary: lightAdjustedPrimary.withOpacity(0.8),
|
||||
onSecondary: lightOnPrimary,
|
||||
secondaryContainer: lightSecondaryContainer,
|
||||
onSecondaryContainer: Colors.black87,
|
||||
@@ -437,9 +575,9 @@ class PaletteHelper {
|
||||
|
||||
final darkScheme = ColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
primary: darkColors.primary,
|
||||
primary: darkAdjustedPrimary,
|
||||
onPrimary: darkOnPrimary,
|
||||
secondary: darkColors.primary.withOpacity(0.8),
|
||||
secondary: darkAdjustedPrimary.withOpacity(0.8),
|
||||
onSecondary: darkOnPrimary,
|
||||
secondaryContainer: darkSecondaryContainer,
|
||||
onSecondaryContainer: Colors.white,
|
||||
|
||||
@@ -11,9 +11,11 @@ import '../constants/timings.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
import '../services/library_status_service.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'provider_icon.dart';
|
||||
import 'media_context_menu.dart';
|
||||
import 'library_status_builder.dart';
|
||||
|
||||
class AlbumCard extends StatefulWidget {
|
||||
final Album album;
|
||||
@@ -35,25 +37,37 @@ class AlbumCard extends StatefulWidget {
|
||||
State<AlbumCard> createState() => _AlbumCardState();
|
||||
}
|
||||
|
||||
class _AlbumCardState extends State<AlbumCard> {
|
||||
class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
|
||||
String? _fallbackImageUrl;
|
||||
bool _triedFallback = false;
|
||||
bool _maImageFailed = false;
|
||||
String? _cachedMaImageUrl;
|
||||
Timer? _fallbackTimer;
|
||||
bool _isNavigating = false;
|
||||
late bool _isFavorite;
|
||||
late bool _isInLibrary;
|
||||
|
||||
/// Delay before fetching fallback images to avoid requests during fast scroll
|
||||
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
|
||||
static const _fallbackDelay = Duration(milliseconds: 400);
|
||||
|
||||
@override
|
||||
String get libraryItemKey => LibraryStatusService.makeKey(
|
||||
'album',
|
||||
widget.album.provider,
|
||||
widget.album.itemId,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isFavorite = widget.album.favorite ?? false;
|
||||
_isInLibrary = widget.album.inLibrary;
|
||||
// Initialize status in centralized service from widget data
|
||||
final service = LibraryStatusService.instance;
|
||||
final key = libraryItemKey;
|
||||
if (!service.isInLibrary(key) && widget.album.inLibrary) {
|
||||
service.setLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
|
||||
service.setFavoriteStatus(key, true);
|
||||
}
|
||||
_initFallbackImage();
|
||||
}
|
||||
|
||||
@@ -111,7 +125,11 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
|
||||
Future<void> _toggleFavorite() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final newState = !_isFavorite;
|
||||
final currentFavorite = isFavorite;
|
||||
final newState = !currentFavorite;
|
||||
|
||||
// Optimistic update via centralized service
|
||||
setFavoriteStatus(newState);
|
||||
|
||||
try {
|
||||
bool success;
|
||||
@@ -150,7 +168,10 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId == null) return;
|
||||
if (libraryItemId == null) {
|
||||
rollbackFavoriteOperation();
|
||||
return;
|
||||
}
|
||||
|
||||
success = await maProvider.removeFromFavorites(
|
||||
mediaType: 'album',
|
||||
@@ -159,23 +180,26 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
}
|
||||
|
||||
if (success && mounted) {
|
||||
setState(() => _isFavorite = newState);
|
||||
completeFavoriteOperation();
|
||||
maProvider.invalidateHomeCache();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
rollbackFavoriteOperation();
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure
|
||||
rollbackFavoriteOperation();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleLibrary() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final newState = !_isInLibrary;
|
||||
final currentInLibrary = isInLibrary;
|
||||
final newState = !currentInLibrary;
|
||||
|
||||
try {
|
||||
if (newState) {
|
||||
@@ -202,21 +226,29 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _isInLibrary = newState);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
maProvider.addToLibrary(
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final success = await maProvider.addToLibrary(
|
||||
mediaType: 'album',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
).catchError((e) {
|
||||
if (mounted) setState(() => _isInLibrary = !newState);
|
||||
});
|
||||
);
|
||||
|
||||
if (success) {
|
||||
completeLibraryOperation();
|
||||
} else {
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
} else {
|
||||
int? libraryItemId;
|
||||
if (widget.album.provider == 'library') {
|
||||
@@ -233,23 +265,31 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
|
||||
if (libraryItemId == null) return;
|
||||
|
||||
setState(() => _isInLibrary = newState);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
maProvider.removeFromLibrary(
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'album',
|
||||
libraryItemId: libraryItemId,
|
||||
).catchError((e) {
|
||||
if (mounted) setState(() => _isInLibrary = !newState);
|
||||
});
|
||||
);
|
||||
|
||||
if (success) {
|
||||
completeLibraryOperation();
|
||||
} else {
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,8 +316,13 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
if (_isNavigating) return;
|
||||
_isNavigating = true;
|
||||
|
||||
// PERF: Color extraction deferred to detail screen's initState
|
||||
// to avoid competing with Hero animation for GPU resources
|
||||
// Start color extraction immediately (fire-and-forget)
|
||||
// Runs in isolate so won't block Hero animation
|
||||
// Results are cached, so detail screen gets them instantly
|
||||
if (imageUrl != null) {
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
}
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
@@ -300,8 +345,8 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
position: details.globalPosition,
|
||||
mediaType: ContextMenuMediaType.album,
|
||||
item: widget.album,
|
||||
isFavorite: _isFavorite,
|
||||
isInLibrary: _isInLibrary,
|
||||
isFavorite: isFavorite,
|
||||
isInLibrary: isInLibrary,
|
||||
onToggleFavorite: _toggleFavorite,
|
||||
onToggleLibrary: _toggleLibrary,
|
||||
);
|
||||
|
||||
@@ -114,7 +114,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
||||
|
||||
// Card layout: square artwork + text below
|
||||
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
|
||||
const textAreaHeight = 44.0;
|
||||
const textAreaHeight = 52.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||
@@ -158,7 +158,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
||||
|
||||
// Total row height includes title + content
|
||||
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
|
||||
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
|
||||
@@ -11,8 +11,10 @@ import '../constants/timings.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/library_status_service.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'media_context_menu.dart';
|
||||
import 'library_status_builder.dart';
|
||||
|
||||
class ArtistCard extends StatefulWidget {
|
||||
final Artist artist;
|
||||
@@ -34,7 +36,7 @@ class ArtistCard extends StatefulWidget {
|
||||
State<ArtistCard> createState() => _ArtistCardState();
|
||||
}
|
||||
|
||||
class _ArtistCardState extends State<ArtistCard> {
|
||||
class _ArtistCardState extends State<ArtistCard> with LibraryStatusMixin {
|
||||
static final _logger = DebugLogger();
|
||||
String? _fallbackImageUrl;
|
||||
bool _triedFallback = false;
|
||||
@@ -42,18 +44,30 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
String? _cachedMaImageUrl;
|
||||
Timer? _fallbackTimer;
|
||||
bool _isNavigating = false;
|
||||
late bool _isFavorite;
|
||||
late bool _isInLibrary;
|
||||
|
||||
/// Delay before fetching fallback images to avoid requests during fast scroll
|
||||
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
|
||||
static const _fallbackDelay = Duration(milliseconds: 400);
|
||||
|
||||
@override
|
||||
String get libraryItemKey => LibraryStatusService.makeKey(
|
||||
'artist',
|
||||
widget.artist.provider,
|
||||
widget.artist.itemId,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isFavorite = widget.artist.favorite ?? false;
|
||||
_isInLibrary = widget.artist.inLibrary;
|
||||
// Initialize status in centralized service from widget data
|
||||
final service = LibraryStatusService.instance;
|
||||
final key = libraryItemKey;
|
||||
if (!service.isInLibrary(key) && widget.artist.inLibrary) {
|
||||
service.setLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
|
||||
service.setFavoriteStatus(key, true);
|
||||
}
|
||||
// Fetch fallback image once in initState, not during build
|
||||
_initFallbackImage();
|
||||
}
|
||||
@@ -102,7 +116,11 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
|
||||
Future<void> _toggleFavorite() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final newState = !_isFavorite;
|
||||
final currentFavorite = isFavorite;
|
||||
final newState = !currentFavorite;
|
||||
|
||||
// Optimistic update via centralized service
|
||||
setFavoriteStatus(newState);
|
||||
|
||||
try {
|
||||
bool success;
|
||||
@@ -141,7 +159,10 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId == null) return;
|
||||
if (libraryItemId == null) {
|
||||
rollbackFavoriteOperation();
|
||||
return;
|
||||
}
|
||||
|
||||
success = await maProvider.removeFromFavorites(
|
||||
mediaType: 'artist',
|
||||
@@ -150,23 +171,26 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
}
|
||||
|
||||
if (success && mounted) {
|
||||
setState(() => _isFavorite = newState);
|
||||
completeFavoriteOperation();
|
||||
maProvider.invalidateHomeCache();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
rollbackFavoriteOperation();
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure
|
||||
rollbackFavoriteOperation();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleLibrary() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final newState = !_isInLibrary;
|
||||
final currentInLibrary = isInLibrary;
|
||||
final newState = !currentInLibrary;
|
||||
|
||||
try {
|
||||
if (newState) {
|
||||
@@ -193,22 +217,29 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _isInLibrary = newState);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
maProvider.addToLibrary(
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final success = await maProvider.addToLibrary(
|
||||
mediaType: 'artist',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
).catchError((e) {
|
||||
if (mounted) setState(() => _isInLibrary = !newState);
|
||||
return false;
|
||||
});
|
||||
);
|
||||
|
||||
if (success) {
|
||||
completeLibraryOperation();
|
||||
} else {
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
} else {
|
||||
int? libraryItemId;
|
||||
if (widget.artist.provider == 'library') {
|
||||
@@ -225,24 +256,31 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
|
||||
if (libraryItemId == null) return;
|
||||
|
||||
setState(() => _isInLibrary = newState);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
maProvider.removeFromLibrary(
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'artist',
|
||||
libraryItemId: libraryItemId,
|
||||
).catchError((e) {
|
||||
if (mounted) setState(() => _isInLibrary = !newState);
|
||||
return false;
|
||||
});
|
||||
);
|
||||
|
||||
if (success) {
|
||||
completeLibraryOperation();
|
||||
} else {
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,8 +331,8 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
position: details.globalPosition,
|
||||
mediaType: ContextMenuMediaType.artist,
|
||||
item: widget.artist,
|
||||
isFavorite: _isFavorite,
|
||||
isInLibrary: _isInLibrary,
|
||||
isFavorite: isFavorite,
|
||||
isInLibrary: isInLibrary,
|
||||
onToggleFavorite: _toggleFavorite,
|
||||
onToggleLibrary: _toggleLibrary,
|
||||
);
|
||||
|
||||
@@ -114,7 +114,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
|
||||
|
||||
// Card layout: circle image + name below
|
||||
// Text area: 8px gap + ~36px for 2-line name = ~44px
|
||||
const textAreaHeight = 44.0;
|
||||
const textAreaHeight = 52.0;
|
||||
final imageSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = imageSize; // Card width = image width (circle)
|
||||
final itemExtent = cardWidth + 16; // width + horizontal margins
|
||||
@@ -154,7 +154,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
|
||||
|
||||
// Total row height includes title + content
|
||||
final totalHeight = widget.rowHeight ?? 207.0; // Default: 44 title + 163 content
|
||||
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
|
||||
@@ -10,8 +10,10 @@ import '../services/debug_logger.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../screens/audiobook_detail_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../services/library_status_service.dart';
|
||||
import 'provider_icon.dart';
|
||||
import 'media_context_menu.dart';
|
||||
import 'library_status_builder.dart';
|
||||
|
||||
class AudiobookRow extends StatefulWidget {
|
||||
final String title;
|
||||
@@ -119,7 +121,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
|
||||
);
|
||||
}
|
||||
|
||||
const textAreaHeight = 44.0;
|
||||
const textAreaHeight = 52.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize;
|
||||
final itemExtent = cardWidth + 12;
|
||||
@@ -160,7 +162,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
final totalHeight = widget.rowHeight ?? 237.0;
|
||||
const titleHeight = 44.0;
|
||||
const titleHeight = 52.0;
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
@@ -206,20 +208,35 @@ class _AudiobookCard extends StatefulWidget {
|
||||
State<_AudiobookCard> createState() => _AudiobookCardState();
|
||||
}
|
||||
|
||||
class _AudiobookCardState extends State<_AudiobookCard> {
|
||||
late bool _isFavorite;
|
||||
late bool _isInLibrary;
|
||||
class _AudiobookCardState extends State<_AudiobookCard> with LibraryStatusMixin {
|
||||
@override
|
||||
String get libraryItemKey => LibraryStatusService.makeKey(
|
||||
'audiobook',
|
||||
widget.audiobook.provider,
|
||||
widget.audiobook.itemId,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isFavorite = widget.audiobook.favorite ?? false;
|
||||
_isInLibrary = widget.audiobook.inLibrary;
|
||||
// Initialize status in centralized service from widget data
|
||||
final service = LibraryStatusService.instance;
|
||||
final key = libraryItemKey;
|
||||
if (!service.isInLibrary(key) && widget.audiobook.inLibrary) {
|
||||
service.setLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.audiobook.favorite ?? false)) {
|
||||
service.setFavoriteStatus(key, true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final newState = !_isFavorite;
|
||||
final currentFavorite = isFavorite;
|
||||
final newState = !currentFavorite;
|
||||
|
||||
// Optimistic update via centralized service
|
||||
setFavoriteStatus(newState);
|
||||
|
||||
try {
|
||||
bool success;
|
||||
@@ -258,7 +275,10 @@ class _AudiobookCardState extends State<_AudiobookCard> {
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId == null) return;
|
||||
if (libraryItemId == null) {
|
||||
rollbackFavoriteOperation();
|
||||
return;
|
||||
}
|
||||
|
||||
success = await maProvider.removeFromFavorites(
|
||||
mediaType: 'audiobook',
|
||||
@@ -267,23 +287,26 @@ class _AudiobookCardState extends State<_AudiobookCard> {
|
||||
}
|
||||
|
||||
if (success && mounted) {
|
||||
setState(() => _isFavorite = newState);
|
||||
completeFavoriteOperation();
|
||||
maProvider.invalidateHomeCache();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
rollbackFavoriteOperation();
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure
|
||||
rollbackFavoriteOperation();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleLibrary() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final newState = !_isInLibrary;
|
||||
final currentInLibrary = isInLibrary;
|
||||
final newState = !currentInLibrary;
|
||||
|
||||
try {
|
||||
if (newState) {
|
||||
@@ -310,21 +333,29 @@ class _AudiobookCardState extends State<_AudiobookCard> {
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _isInLibrary = newState);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
maProvider.addToLibrary(
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final success = await maProvider.addToLibrary(
|
||||
mediaType: 'audiobook',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
).catchError((e) {
|
||||
if (mounted) setState(() => _isInLibrary = !newState);
|
||||
});
|
||||
);
|
||||
|
||||
if (success) {
|
||||
completeLibraryOperation();
|
||||
} else {
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
} else {
|
||||
int? libraryItemId;
|
||||
if (widget.audiobook.provider == 'library') {
|
||||
@@ -341,23 +372,31 @@ class _AudiobookCardState extends State<_AudiobookCard> {
|
||||
|
||||
if (libraryItemId == null) return;
|
||||
|
||||
setState(() => _isInLibrary = newState);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
maProvider.removeFromLibrary(
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'audiobook',
|
||||
libraryItemId: libraryItemId,
|
||||
).catchError((e) {
|
||||
if (mounted) setState(() => _isInLibrary = !newState);
|
||||
});
|
||||
);
|
||||
|
||||
if (success) {
|
||||
completeLibraryOperation();
|
||||
} else {
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,8 +425,8 @@ class _AudiobookCardState extends State<_AudiobookCard> {
|
||||
position: details.globalPosition,
|
||||
mediaType: ContextMenuMediaType.audiobook,
|
||||
item: widget.audiobook,
|
||||
isFavorite: _isFavorite,
|
||||
isInLibrary: _isInLibrary,
|
||||
isFavorite: isFavorite,
|
||||
isInLibrary: isInLibrary,
|
||||
onToggleFavorite: _toggleFavorite,
|
||||
onToggleLibrary: _toggleLibrary,
|
||||
);
|
||||
|
||||
@@ -69,46 +69,54 @@ class HiResBadge extends StatelessWidget {
|
||||
final baseColor = primaryColor ?? colorScheme.primary;
|
||||
final color = Color.lerp(baseColor, Colors.white, 0.5)!;
|
||||
|
||||
return Tooltip(
|
||||
message: tooltip ?? 'Hi-Res Audio',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: color, width: 1.5),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Hi-Res',
|
||||
final content = Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: color, width: 1.5),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Hi-Res',
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 1),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: Text(
|
||||
'AUDIO',
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.surface,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 1),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: Text(
|
||||
'AUDIO',
|
||||
style: TextStyle(
|
||||
color: colorScheme.surface,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Avoid crash when Overlay isn't available (e.g., certain overlay contexts).
|
||||
final hasOverlay = Overlay.maybeOf(context) != null;
|
||||
if (!hasOverlay) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return Tooltip(
|
||||
message: tooltip ?? 'Hi-Res Audio',
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/library_status_service.dart';
|
||||
|
||||
/// A widget that rebuilds when the library/favorite status of an item changes.
|
||||
///
|
||||
/// This widget listens to the centralized [LibraryStatusService] and only
|
||||
/// rebuilds when the status of the specified item changes.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// LibraryStatusBuilder(
|
||||
/// itemKey: LibraryStatusService.makeKey('album', album.provider, album.itemId),
|
||||
/// builder: (context, isInLibrary, isFavorite) {
|
||||
/// return IconButton(
|
||||
/// icon: Icon(isInLibrary ? Icons.library_add_check : Icons.library_add),
|
||||
/// onPressed: () => toggleLibrary(),
|
||||
/// );
|
||||
/// },
|
||||
/// )
|
||||
/// ```
|
||||
class LibraryStatusBuilder extends StatefulWidget {
|
||||
final String itemKey;
|
||||
final Widget Function(BuildContext context, bool isInLibrary, bool isFavorite) builder;
|
||||
|
||||
const LibraryStatusBuilder({
|
||||
super.key,
|
||||
required this.itemKey,
|
||||
required this.builder,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LibraryStatusBuilder> createState() => _LibraryStatusBuilderState();
|
||||
}
|
||||
|
||||
class _LibraryStatusBuilderState extends State<LibraryStatusBuilder> {
|
||||
late bool _isInLibrary;
|
||||
late bool _isFavorite;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_updateStatus();
|
||||
LibraryStatusService.instance.addListener(_onStatusChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LibraryStatusBuilder oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.itemKey != widget.itemKey) {
|
||||
_updateStatus();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
LibraryStatusService.instance.removeListener(_onStatusChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateStatus() {
|
||||
_isInLibrary = LibraryStatusService.instance.isInLibrary(widget.itemKey);
|
||||
_isFavorite = LibraryStatusService.instance.isFavorite(widget.itemKey);
|
||||
}
|
||||
|
||||
void _onStatusChanged() {
|
||||
final newInLibrary = LibraryStatusService.instance.isInLibrary(widget.itemKey);
|
||||
final newFavorite = LibraryStatusService.instance.isFavorite(widget.itemKey);
|
||||
|
||||
if (newInLibrary != _isInLibrary || newFavorite != _isFavorite) {
|
||||
setState(() {
|
||||
_isInLibrary = newInLibrary;
|
||||
_isFavorite = newFavorite;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.builder(context, _isInLibrary, _isFavorite);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mixin for StatefulWidgets that need to track library/favorite status.
|
||||
///
|
||||
/// This provides a simpler API for widgets that already maintain other state.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// class _MyWidgetState extends State<MyWidget> with LibraryStatusMixin {
|
||||
/// @override
|
||||
/// String get libraryItemKey => LibraryStatusService.makeKey('album', widget.album.provider, widget.album.itemId);
|
||||
///
|
||||
/// @override
|
||||
/// Widget build(BuildContext context) {
|
||||
/// return IconButton(
|
||||
/// icon: Icon(isInLibrary ? Icons.check : Icons.add),
|
||||
/// onPressed: toggleLibrary,
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
mixin LibraryStatusMixin<T extends StatefulWidget> on State<T> {
|
||||
/// Override to provide the item key
|
||||
String get libraryItemKey;
|
||||
|
||||
/// Whether the item is currently in the library
|
||||
bool get isInLibrary => LibraryStatusService.instance.isInLibrary(libraryItemKey);
|
||||
|
||||
/// Whether the item is currently a favorite
|
||||
bool get isFavorite => LibraryStatusService.instance.isFavorite(libraryItemKey);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
LibraryStatusService.instance.addListener(_onLibraryStatusChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
LibraryStatusService.instance.removeListener(_onLibraryStatusChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onLibraryStatusChanged() {
|
||||
// Trigger rebuild to pick up new status values
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
/// Set library status optimistically and return previous value for rollback
|
||||
bool setLibraryStatus(bool value) {
|
||||
final previous = isInLibrary;
|
||||
LibraryStatusService.instance.markLibraryPending(libraryItemKey, previous);
|
||||
LibraryStatusService.instance.setLibraryStatus(libraryItemKey, value);
|
||||
return previous;
|
||||
}
|
||||
|
||||
/// Set favorite status optimistically and return previous value for rollback
|
||||
bool setFavoriteStatus(bool value) {
|
||||
final previous = isFavorite;
|
||||
LibraryStatusService.instance.markFavoritePending(libraryItemKey, previous);
|
||||
LibraryStatusService.instance.setFavoriteStatus(libraryItemKey, value);
|
||||
return previous;
|
||||
}
|
||||
|
||||
/// Complete a successful library operation
|
||||
void completeLibraryOperation() {
|
||||
LibraryStatusService.instance.completeLibraryOperation(libraryItemKey);
|
||||
}
|
||||
|
||||
/// Complete a successful favorite operation
|
||||
void completeFavoriteOperation() {
|
||||
LibraryStatusService.instance.completeFavoriteOperation(libraryItemKey);
|
||||
}
|
||||
|
||||
/// Rollback a failed library operation
|
||||
void rollbackLibraryOperation() {
|
||||
LibraryStatusService.instance.rollbackLibraryOperation(libraryItemKey);
|
||||
}
|
||||
|
||||
/// Rollback a failed favorite operation
|
||||
void rollbackFavoriteOperation() {
|
||||
LibraryStatusService.instance.rollbackFavoriteOperation(libraryItemKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import 'playlist_card.dart';
|
||||
import 'radio_station_card.dart';
|
||||
|
||||
class MixesRow extends StatefulWidget {
|
||||
final String title;
|
||||
final Future<List<RecommendationFolder>> Function() loadMixes;
|
||||
final List<RecommendationFolder>? Function()? getCachedMixes;
|
||||
final String? heroTagSuffix;
|
||||
final double? rowHeight;
|
||||
|
||||
const MixesRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.loadMixes,
|
||||
this.getCachedMixes,
|
||||
this.heroTagSuffix,
|
||||
this.rowHeight,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MixesRow> createState() => _MixesRowState();
|
||||
}
|
||||
|
||||
class _MixesRowState extends State<MixesRow> with AutomaticKeepAliveClientMixin {
|
||||
List<RecommendationFolder> _mixes = [];
|
||||
bool _isLoading = true;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
static final _logger = DebugLogger();
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final cached = widget.getCachedMixes?.call();
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
_mixes = cached;
|
||||
_isLoading = false;
|
||||
}
|
||||
_loadMixes();
|
||||
}
|
||||
|
||||
Future<void> _loadMixes() async {
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
try {
|
||||
final freshMixes = await widget.loadMixes();
|
||||
if (mounted && freshMixes.isNotEmpty) {
|
||||
setState(() {
|
||||
_mixes = freshMixes;
|
||||
_isLoading = false;
|
||||
});
|
||||
_precacheMixImages(freshMixes);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure - keep showing cached data
|
||||
}
|
||||
|
||||
if (mounted && _isLoading) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _precacheMixImages(List<RecommendationFolder> mixes) {
|
||||
if (!mounted) return;
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
final itemsToCache = <MediaItem>[];
|
||||
for (final folder in mixes) {
|
||||
itemsToCache.addAll(folder.items);
|
||||
if (itemsToCache.length >= 10) break;
|
||||
}
|
||||
|
||||
for (final mix in itemsToCache.take(10)) {
|
||||
final imageUrl = maProvider.api?.getImageUrl(mix, size: 256);
|
||||
if (imageUrl != null) {
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
context,
|
||||
).catchError((_) => false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildShelfContent(List<MediaItem> items, double contentHeight) {
|
||||
const textAreaHeight = 52.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize;
|
||||
final itemExtent = cardWidth + 12;
|
||||
|
||||
return ScrollConfiguration(
|
||||
behavior: const _StretchScrollBehavior(),
|
||||
child: ListView.builder(
|
||||
clipBehavior: Clip.none,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: items.length,
|
||||
itemExtent: itemExtent,
|
||||
cacheExtent: 500,
|
||||
addAutomaticKeepAlives: false,
|
||||
addRepaintBoundaries: false,
|
||||
itemBuilder: (context, index) {
|
||||
final mix = items[index];
|
||||
final key = ValueKey(mix.uri ?? mix.itemId);
|
||||
|
||||
Widget card;
|
||||
if (mix is Playlist) {
|
||||
card = PlaylistCard(
|
||||
playlist: mix,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
imageCacheSize: 256,
|
||||
);
|
||||
} else if (mix.mediaType == MediaType.radio) {
|
||||
card = RadioStationCard(
|
||||
radioStation: mix,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
imageCacheSize: 256,
|
||||
);
|
||||
} else {
|
||||
card = const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Container(
|
||||
key: key,
|
||||
width: cardWidth,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||
child: card,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_logger.startBuild('MixesRow:${widget.title}');
|
||||
super.build(context);
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final baseRowHeight = widget.rowHeight ?? 237.0;
|
||||
const titleHeight = 52.0;
|
||||
final contentHeight = baseRowHeight - titleHeight;
|
||||
|
||||
final visibleFolders = _mixes.where((folder) {
|
||||
final name = folder.name.trim().toLowerCase();
|
||||
final id = folder.itemId.trim().toLowerCase();
|
||||
final isRecentlyPlayed = name == 'recently played' || id.contains('recently_played');
|
||||
return !isRecentlyPlayed;
|
||||
}).toList();
|
||||
|
||||
final result = RepaintBoundary(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_mixes.isEmpty && _isLoading)
|
||||
SizedBox(
|
||||
height: baseRowHeight - titleHeight,
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_mixes.isEmpty || visibleFolders.isEmpty)
|
||||
SizedBox(
|
||||
height: baseRowHeight - titleHeight,
|
||||
child: Center(
|
||||
child: Text(
|
||||
S.of(context)!.noPlaylistMixesFound,
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
...List<Widget>.generate(visibleFolders.length * 2 - 1, (index) {
|
||||
if (index.isOdd) return const SizedBox(height: 8.0);
|
||||
final folder = visibleFolders[index ~/ 2];
|
||||
return SizedBox(
|
||||
height: baseRowHeight,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||
child: Text(
|
||||
folder.name,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildShelfContent(folder.items, contentHeight),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
_logger.endBuild('MixesRow:${widget.title}');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class _StretchScrollBehavior extends ScrollBehavior {
|
||||
const _StretchScrollBehavior();
|
||||
|
||||
@override
|
||||
Widget buildOverscrollIndicator(
|
||||
BuildContext context, Widget child, ScrollableDetails details) {
|
||||
return StretchingOverscrollIndicator(
|
||||
axisDirection: details.direction,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,11 @@ import '../screens/playlist_details_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../services/library_status_service.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'provider_icon.dart';
|
||||
import 'media_context_menu.dart';
|
||||
import 'library_status_builder.dart';
|
||||
|
||||
class PlaylistCard extends StatefulWidget {
|
||||
final Playlist playlist;
|
||||
@@ -31,21 +33,37 @@ class PlaylistCard extends StatefulWidget {
|
||||
State<PlaylistCard> createState() => _PlaylistCardState();
|
||||
}
|
||||
|
||||
class _PlaylistCardState extends State<PlaylistCard> {
|
||||
class _PlaylistCardState extends State<PlaylistCard> with LibraryStatusMixin {
|
||||
bool _isNavigating = false;
|
||||
late bool _isFavorite;
|
||||
late bool _isInLibrary;
|
||||
|
||||
@override
|
||||
String get libraryItemKey => LibraryStatusService.makeKey(
|
||||
'playlist',
|
||||
widget.playlist.provider,
|
||||
widget.playlist.itemId,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isFavorite = widget.playlist.favorite ?? false;
|
||||
_isInLibrary = widget.playlist.inLibrary;
|
||||
// Initialize status in centralized service from widget data
|
||||
final service = LibraryStatusService.instance;
|
||||
final key = libraryItemKey;
|
||||
if (!service.isInLibrary(key) && widget.playlist.inLibrary) {
|
||||
service.setLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.playlist.favorite ?? false)) {
|
||||
service.setFavoriteStatus(key, true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final newState = !_isFavorite;
|
||||
final currentFavorite = isFavorite;
|
||||
final newState = !currentFavorite;
|
||||
|
||||
// Optimistic update via centralized service
|
||||
setFavoriteStatus(newState);
|
||||
|
||||
try {
|
||||
bool success;
|
||||
@@ -84,7 +102,10 @@ class _PlaylistCardState extends State<PlaylistCard> {
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId == null) return;
|
||||
if (libraryItemId == null) {
|
||||
rollbackFavoriteOperation();
|
||||
return;
|
||||
}
|
||||
|
||||
success = await maProvider.removeFromFavorites(
|
||||
mediaType: 'playlist',
|
||||
@@ -93,23 +114,26 @@ class _PlaylistCardState extends State<PlaylistCard> {
|
||||
}
|
||||
|
||||
if (success && mounted) {
|
||||
setState(() => _isFavorite = newState);
|
||||
completeFavoriteOperation();
|
||||
maProvider.invalidateHomeCache();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
rollbackFavoriteOperation();
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure
|
||||
rollbackFavoriteOperation();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleLibrary() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final newState = !_isInLibrary;
|
||||
final currentInLibrary = isInLibrary;
|
||||
final newState = !currentInLibrary;
|
||||
|
||||
try {
|
||||
if (newState) {
|
||||
@@ -136,22 +160,29 @@ class _PlaylistCardState extends State<PlaylistCard> {
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _isInLibrary = newState);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
maProvider.addToLibrary(
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final success = await maProvider.addToLibrary(
|
||||
mediaType: 'playlist',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
).catchError((e) {
|
||||
if (mounted) setState(() => _isInLibrary = !newState);
|
||||
return false;
|
||||
});
|
||||
);
|
||||
|
||||
if (success) {
|
||||
completeLibraryOperation();
|
||||
} else {
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
} else {
|
||||
int? libraryItemId;
|
||||
if (widget.playlist.provider == 'library') {
|
||||
@@ -168,24 +199,31 @@ class _PlaylistCardState extends State<PlaylistCard> {
|
||||
|
||||
if (libraryItemId == null) return;
|
||||
|
||||
setState(() => _isInLibrary = newState);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
maProvider.removeFromLibrary(
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'playlist',
|
||||
libraryItemId: libraryItemId,
|
||||
).catchError((e) {
|
||||
if (mounted) setState(() => _isInLibrary = !newState);
|
||||
return false;
|
||||
});
|
||||
);
|
||||
|
||||
if (success) {
|
||||
completeLibraryOperation();
|
||||
} else {
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,8 +268,8 @@ class _PlaylistCardState extends State<PlaylistCard> {
|
||||
position: details.globalPosition,
|
||||
mediaType: ContextMenuMediaType.playlist,
|
||||
item: widget.playlist,
|
||||
isFavorite: _isFavorite,
|
||||
isInLibrary: _isInLibrary,
|
||||
isFavorite: isFavorite,
|
||||
isInLibrary: isInLibrary,
|
||||
onToggleFavorite: _toggleFavorite,
|
||||
onToggleLibrary: _toggleLibrary,
|
||||
);
|
||||
|
||||
@@ -107,7 +107,7 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
|
||||
|
||||
// Card layout: square artwork + text below (same as AlbumRow)
|
||||
// Text area: 8px gap + ~18px title + ~18px owner = ~44px
|
||||
const textAreaHeight = 44.0;
|
||||
const textAreaHeight = 52.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||
@@ -152,7 +152,7 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
|
||||
|
||||
// Total row height includes title + content (same as AlbumRow)
|
||||
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
|
||||
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
|
||||
@@ -11,8 +11,10 @@ import '../constants/hero_tags.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../services/library_status_service.dart';
|
||||
import 'provider_icon.dart';
|
||||
import 'media_context_menu.dart';
|
||||
import 'library_status_builder.dart';
|
||||
|
||||
class PodcastCard extends StatefulWidget {
|
||||
final MediaItem podcast;
|
||||
@@ -34,21 +36,37 @@ class PodcastCard extends StatefulWidget {
|
||||
State<PodcastCard> createState() => _PodcastCardState();
|
||||
}
|
||||
|
||||
class _PodcastCardState extends State<PodcastCard> {
|
||||
class _PodcastCardState extends State<PodcastCard> with LibraryStatusMixin {
|
||||
bool _isNavigating = false;
|
||||
late bool _isFavorite;
|
||||
late bool _isInLibrary;
|
||||
|
||||
@override
|
||||
String get libraryItemKey => LibraryStatusService.makeKey(
|
||||
'podcast',
|
||||
widget.podcast.provider,
|
||||
widget.podcast.itemId,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isFavorite = widget.podcast.favorite ?? false;
|
||||
_isInLibrary = widget.podcast.inLibrary;
|
||||
// Initialize status in centralized service from widget data
|
||||
final service = LibraryStatusService.instance;
|
||||
final key = libraryItemKey;
|
||||
if (!service.isInLibrary(key) && widget.podcast.inLibrary) {
|
||||
service.setLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.podcast.favorite ?? false)) {
|
||||
service.setFavoriteStatus(key, true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final newState = !_isFavorite;
|
||||
final currentFavorite = isFavorite;
|
||||
final newState = !currentFavorite;
|
||||
|
||||
// Optimistic update via centralized service
|
||||
setFavoriteStatus(newState);
|
||||
|
||||
try {
|
||||
bool success;
|
||||
@@ -87,7 +105,10 @@ class _PodcastCardState extends State<PodcastCard> {
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId == null) return;
|
||||
if (libraryItemId == null) {
|
||||
rollbackFavoriteOperation();
|
||||
return;
|
||||
}
|
||||
|
||||
success = await maProvider.removeFromFavorites(
|
||||
mediaType: 'podcast',
|
||||
@@ -96,23 +117,26 @@ class _PodcastCardState extends State<PodcastCard> {
|
||||
}
|
||||
|
||||
if (success && mounted) {
|
||||
setState(() => _isFavorite = newState);
|
||||
completeFavoriteOperation();
|
||||
maProvider.invalidateHomeCache();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
rollbackFavoriteOperation();
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure
|
||||
rollbackFavoriteOperation();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleLibrary() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final newState = !_isInLibrary;
|
||||
final currentInLibrary = isInLibrary;
|
||||
final newState = !currentInLibrary;
|
||||
|
||||
try {
|
||||
if (newState) {
|
||||
@@ -139,21 +163,29 @@ class _PodcastCardState extends State<PodcastCard> {
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _isInLibrary = newState);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
maProvider.addToLibrary(
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final success = await maProvider.addToLibrary(
|
||||
mediaType: 'podcast',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
).catchError((e) {
|
||||
if (mounted) setState(() => _isInLibrary = !newState);
|
||||
});
|
||||
);
|
||||
|
||||
if (success) {
|
||||
completeLibraryOperation();
|
||||
} else {
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
} else {
|
||||
int? libraryItemId;
|
||||
if (widget.podcast.provider == 'library') {
|
||||
@@ -170,23 +202,31 @@ class _PodcastCardState extends State<PodcastCard> {
|
||||
|
||||
if (libraryItemId == null) return;
|
||||
|
||||
setState(() => _isInLibrary = newState);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
maProvider.removeFromLibrary(
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'podcast',
|
||||
libraryItemId: libraryItemId,
|
||||
).catchError((e) {
|
||||
if (mounted) setState(() => _isInLibrary = !newState);
|
||||
});
|
||||
);
|
||||
|
||||
if (success) {
|
||||
completeLibraryOperation();
|
||||
} else {
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,8 +274,8 @@ class _PodcastCardState extends State<PodcastCard> {
|
||||
position: details.globalPosition,
|
||||
mediaType: ContextMenuMediaType.podcast,
|
||||
item: widget.podcast,
|
||||
isFavorite: _isFavorite,
|
||||
isInLibrary: _isInLibrary,
|
||||
isFavorite: isFavorite,
|
||||
isInLibrary: isInLibrary,
|
||||
onToggleFavorite: _toggleFavorite,
|
||||
onToggleLibrary: _toggleLibrary,
|
||||
);
|
||||
|
||||
@@ -115,7 +115,7 @@ class _PodcastRowState extends State<PodcastRow> with AutomaticKeepAliveClientMi
|
||||
|
||||
// Card layout: square artwork + text below (same as AlbumRow)
|
||||
// Text area: 8px gap + ~18px title + ~18px author = ~44px
|
||||
const textAreaHeight = 44.0;
|
||||
const textAreaHeight = 52.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||
@@ -160,7 +160,7 @@ class _PodcastRowState extends State<PodcastRow> with AutomaticKeepAliveClientMi
|
||||
|
||||
// Total row height includes title + content (same as AlbumRow)
|
||||
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
|
||||
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
|
||||
@@ -7,8 +7,10 @@ import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../services/library_status_service.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'media_context_menu.dart';
|
||||
import 'library_status_builder.dart';
|
||||
|
||||
class RadioStationCard extends StatefulWidget {
|
||||
final MediaItem radioStation;
|
||||
@@ -28,21 +30,37 @@ class RadioStationCard extends StatefulWidget {
|
||||
State<RadioStationCard> createState() => _RadioStationCardState();
|
||||
}
|
||||
|
||||
class _RadioStationCardState extends State<RadioStationCard> {
|
||||
class _RadioStationCardState extends State<RadioStationCard> with LibraryStatusMixin {
|
||||
bool _isTapping = false;
|
||||
late bool _isFavorite;
|
||||
late bool _isInLibrary;
|
||||
|
||||
@override
|
||||
String get libraryItemKey => LibraryStatusService.makeKey(
|
||||
'radio',
|
||||
widget.radioStation.provider,
|
||||
widget.radioStation.itemId,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isFavorite = widget.radioStation.favorite ?? false;
|
||||
_isInLibrary = widget.radioStation.inLibrary;
|
||||
// Initialize status in centralized service from widget data
|
||||
final service = LibraryStatusService.instance;
|
||||
final key = libraryItemKey;
|
||||
if (!service.isInLibrary(key) && widget.radioStation.inLibrary) {
|
||||
service.setLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.radioStation.favorite ?? false)) {
|
||||
service.setFavoriteStatus(key, true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final newState = !_isFavorite;
|
||||
final currentFavorite = isFavorite;
|
||||
final newState = !currentFavorite;
|
||||
|
||||
// Optimistic update via centralized service
|
||||
setFavoriteStatus(newState);
|
||||
|
||||
try {
|
||||
bool success;
|
||||
@@ -81,7 +99,10 @@ class _RadioStationCardState extends State<RadioStationCard> {
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId == null) return;
|
||||
if (libraryItemId == null) {
|
||||
rollbackFavoriteOperation();
|
||||
return;
|
||||
}
|
||||
|
||||
success = await maProvider.removeFromFavorites(
|
||||
mediaType: 'radio',
|
||||
@@ -90,23 +111,26 @@ class _RadioStationCardState extends State<RadioStationCard> {
|
||||
}
|
||||
|
||||
if (success && mounted) {
|
||||
setState(() => _isFavorite = newState);
|
||||
completeFavoriteOperation();
|
||||
maProvider.invalidateHomeCache();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
rollbackFavoriteOperation();
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure
|
||||
rollbackFavoriteOperation();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleLibrary() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final newState = !_isInLibrary;
|
||||
final currentInLibrary = isInLibrary;
|
||||
final newState = !currentInLibrary;
|
||||
|
||||
try {
|
||||
if (newState) {
|
||||
@@ -133,21 +157,29 @@ class _RadioStationCardState extends State<RadioStationCard> {
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _isInLibrary = newState);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
maProvider.addToLibrary(
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final success = await maProvider.addToLibrary(
|
||||
mediaType: 'radio',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
).catchError((e) {
|
||||
if (mounted) setState(() => _isInLibrary = !newState);
|
||||
});
|
||||
);
|
||||
|
||||
if (success) {
|
||||
completeLibraryOperation();
|
||||
} else {
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
} else {
|
||||
int? libraryItemId;
|
||||
if (widget.radioStation.provider == 'library') {
|
||||
@@ -164,23 +196,31 @@ class _RadioStationCardState extends State<RadioStationCard> {
|
||||
|
||||
if (libraryItemId == null) return;
|
||||
|
||||
setState(() => _isInLibrary = newState);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
maProvider.removeFromLibrary(
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'radio',
|
||||
libraryItemId: libraryItemId,
|
||||
).catchError((e) {
|
||||
if (mounted) setState(() => _isInLibrary = !newState);
|
||||
});
|
||||
);
|
||||
|
||||
if (success) {
|
||||
completeLibraryOperation();
|
||||
} else {
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,8 +259,8 @@ class _RadioStationCardState extends State<RadioStationCard> {
|
||||
position: details.globalPosition,
|
||||
mediaType: ContextMenuMediaType.radio,
|
||||
item: widget.radioStation,
|
||||
isFavorite: _isFavorite,
|
||||
isInLibrary: _isInLibrary,
|
||||
isFavorite: isFavorite,
|
||||
isInLibrary: isInLibrary,
|
||||
onToggleFavorite: _toggleFavorite,
|
||||
onToggleLibrary: _toggleLibrary,
|
||||
);
|
||||
|
||||
@@ -107,7 +107,7 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
|
||||
|
||||
// Card layout: circle image + name below (same as ArtistRow)
|
||||
// Text area: 8px gap + ~36px for 2-line name = ~44px
|
||||
const textAreaHeight = 44.0;
|
||||
const textAreaHeight = 52.0;
|
||||
final imageSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = imageSize; // Card width = image width (circle)
|
||||
final itemExtent = cardWidth + 16; // width + horizontal margins
|
||||
@@ -148,7 +148,7 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
|
||||
|
||||
// Total row height includes title + content (same as ArtistRow)
|
||||
final totalHeight = widget.rowHeight ?? 207.0; // Default: 44 title + 163 content
|
||||
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
|
||||
@@ -179,7 +179,7 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
||||
);
|
||||
}
|
||||
|
||||
const textAreaHeight = 44.0;
|
||||
const textAreaHeight = 52.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize;
|
||||
final itemExtent = cardWidth + 12;
|
||||
@@ -231,7 +231,7 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
final totalHeight = widget.rowHeight ?? 237.0;
|
||||
const titleHeight = 44.0;
|
||||
const titleHeight = 52.0;
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
|
||||
@@ -6,7 +6,9 @@ import '../l10n/app_localizations.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/library_status_service.dart';
|
||||
import 'media_context_menu.dart';
|
||||
import 'library_status_builder.dart';
|
||||
|
||||
class TrackRow extends StatefulWidget {
|
||||
final String title;
|
||||
@@ -107,7 +109,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
|
||||
|
||||
// Card layout: square artwork + text below
|
||||
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
|
||||
const textAreaHeight = 44.0;
|
||||
const textAreaHeight = 52.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||
final itemExtent = cardWidth + 12;
|
||||
@@ -148,7 +150,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
|
||||
|
||||
// Total row height includes title + content
|
||||
final totalHeight = widget.rowHeight ?? 224.0; // Default: 44 title + 180 content
|
||||
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
@@ -194,20 +196,35 @@ class _TrackCard extends StatefulWidget {
|
||||
State<_TrackCard> createState() => _TrackCardState();
|
||||
}
|
||||
|
||||
class _TrackCardState extends State<_TrackCard> {
|
||||
late bool _isFavorite;
|
||||
late bool _isInLibrary;
|
||||
class _TrackCardState extends State<_TrackCard> with LibraryStatusMixin {
|
||||
@override
|
||||
String get libraryItemKey => LibraryStatusService.makeKey(
|
||||
'track',
|
||||
widget.track.provider,
|
||||
widget.track.itemId,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isFavorite = widget.track.favorite ?? false;
|
||||
_isInLibrary = widget.track.inLibrary;
|
||||
// Initialize status in centralized service from widget data
|
||||
final service = LibraryStatusService.instance;
|
||||
final key = libraryItemKey;
|
||||
if (!service.isInLibrary(key) && widget.track.inLibrary) {
|
||||
service.setLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.track.favorite ?? false)) {
|
||||
service.setFavoriteStatus(key, true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final newState = !_isFavorite;
|
||||
final currentFavorite = isFavorite;
|
||||
final newState = !currentFavorite;
|
||||
|
||||
// Optimistic update via centralized service
|
||||
setFavoriteStatus(newState);
|
||||
|
||||
try {
|
||||
bool success;
|
||||
@@ -246,7 +263,10 @@ class _TrackCardState extends State<_TrackCard> {
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId == null) return;
|
||||
if (libraryItemId == null) {
|
||||
rollbackFavoriteOperation();
|
||||
return;
|
||||
}
|
||||
|
||||
success = await maProvider.removeFromFavorites(
|
||||
mediaType: 'track',
|
||||
@@ -255,23 +275,26 @@ class _TrackCardState extends State<_TrackCard> {
|
||||
}
|
||||
|
||||
if (success && mounted) {
|
||||
setState(() => _isFavorite = newState);
|
||||
completeFavoriteOperation();
|
||||
maProvider.invalidateHomeCache();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
rollbackFavoriteOperation();
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure
|
||||
rollbackFavoriteOperation();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleLibrary() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final newState = !_isInLibrary;
|
||||
final currentInLibrary = isInLibrary;
|
||||
final newState = !currentInLibrary;
|
||||
|
||||
try {
|
||||
if (newState) {
|
||||
@@ -298,21 +321,29 @@ class _TrackCardState extends State<_TrackCard> {
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _isInLibrary = newState);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
maProvider.addToLibrary(
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final success = await maProvider.addToLibrary(
|
||||
mediaType: 'track',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
).catchError((e) {
|
||||
if (mounted) setState(() => _isInLibrary = !newState);
|
||||
});
|
||||
);
|
||||
|
||||
if (success) {
|
||||
completeLibraryOperation();
|
||||
} else {
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
} else {
|
||||
int? libraryItemId;
|
||||
if (widget.track.provider == 'library') {
|
||||
@@ -329,23 +360,31 @@ class _TrackCardState extends State<_TrackCard> {
|
||||
|
||||
if (libraryItemId == null) return;
|
||||
|
||||
setState(() => _isInLibrary = newState);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
maProvider.removeFromLibrary(
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'track',
|
||||
libraryItemId: libraryItemId,
|
||||
).catchError((e) {
|
||||
if (mounted) setState(() => _isInLibrary = !newState);
|
||||
});
|
||||
);
|
||||
|
||||
if (success) {
|
||||
completeLibraryOperation();
|
||||
} else {
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,8 +409,8 @@ class _TrackCardState extends State<_TrackCard> {
|
||||
position: details.globalPosition,
|
||||
mediaType: ContextMenuMediaType.track,
|
||||
item: widget.track,
|
||||
isFavorite: _isFavorite,
|
||||
isInLibrary: _isInLibrary,
|
||||
isFavorite: isFavorite,
|
||||
isInLibrary: isInLibrary,
|
||||
onToggleFavorite: _toggleFavorite,
|
||||
onToggleLibrary: _toggleLibrary,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 2.9.2-beta+50
|
||||
version: 3.0.2+54
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@@ -42,6 +42,7 @@ dependencies:
|
||||
package_info_plus: ^8.0.0
|
||||
device_info_plus: ^10.1.0
|
||||
share_plus: ^10.1.4
|
||||
url_launcher: ^6.3.0
|
||||
|
||||
# Audio - Local Playback
|
||||
just_audio: ^0.9.36
|
||||
|
||||