Compare commits

...
20 Commits
Author SHA1 Message Date
Chris e5cefca71f WIP on feat/mixes-local: a4554ef reverted build file 2026-02-05 07:31:10 +00:00
Chris f658393d30 index on feat/mixes-local: a4554ef reverted build file 2026-02-05 07:31:10 +00:00
relic664 a4554ef7d7 reverted build file 2026-02-03 15:43:10 -05:00
relic664 a078eb55e8 fixed library screen 2026-02-02 22:24:30 -05:00
relic664 dfe3f26515 removed redundant shelf, header 2026-02-02 22:08:36 -05:00
relic664 d3d87dceda shelf refinement 2026-02-02 21:55:32 -05:00
relic664 73bb12fc03 overlay again 2026-02-02 21:24:16 -05:00
relic664 e975e24939 adjusted overlay 2026-02-02 20:58:25 -05:00
relic664 707768e524 added support for provider mixes 2026-02-02 14:43:16 -05:00
Chris e6349c309b Add link to Ensemble TV repo 2026-01-31 09:03:22 +00:00
Chris 00f065fbe9 v3.0.2: Disable dependency metadata for F-Droid 2026-01-28 05:00:57 +00:00
ChrisandClaude Opus 4.5 e440f7b1d7 Bump version to 3.0.1
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:21:16 +00:00
ChrisandClaude Opus 4.5 5a73d2d65b Add fastlane metadata for F-Droid
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:18:43 +00:00
ChrisandClaude Opus 4.5 3b5a8631a8 Change package ID to com.collotsspot.ensemble
BREAKING CHANGE: Users must uninstall and reinstall the app.
The previous package ID (com.musicassistant.music_assistant) could be
confused with an official Music Assistant app.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:55:23 +00:00
ChrisandClaude Opus 4.5 1f3a01c22f Bump version to 2.9.3-beta
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:20:19 +00:00
ChrisandClaude Opus 4.5 27e1b8b746 Fix local player track data disappearing when selected
When selecting the builtin/local player from the player selector overlay,
the track info would disappear because the queue API returns null for
local players. The code was clearing _currentTrack unconditionally.

Now for the builtin player:
- Preserve cached track data instead of clearing it
- Restore from cache if _currentTrack is null
- Update notification with actual track info when available

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:14:36 +00:00
ChrisandClaude Opus 4.5 9be3737a9d Add centralized LibraryStatusService for consistent library/favorite state
- Create LibraryStatusService singleton for single source of truth
- Add LibraryStatusMixin for reactive status in StatefulWidgets
- Convert all card widgets to use centralized service:
  - album_card, artist_card, playlist_card, podcast_card
  - radio_station_card, track_row, audiobook_row
- Convert detail screens: album_details_screen, artist_details_screen
- Fix album stuck in library after removal:
  - Add removeFromCacheByLibraryId() to SyncService
  - Add markCachedItemDeletedByLibraryId() to DatabaseService
  - Update removeFromLibrary() to sync all caches

This ensures library/favorite status changes are immediately reflected
across all instances of the same item throughout the app.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:14:36 +00:00
ChrisandClaude Opus 4.5 882e8b2ea4 Simplify player selection logic
Remove confusing auto-switch behavior that could change the selected
player unexpectedly right before the user taps play.

Removed settings:
- "Prefer Local Player"
- "Disable Auto-Switch"

New simplified logic:
1. User's manual selection is always respected
2. On app reopen: restore last selected player
3. If selected player goes offline: switch to local player
4. First launch (no history): select local player
5. Local player unavailable: select first available

Kept "Smart Sort" setting for display order only.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:14:36 +00:00
ChrisandClaude Opus 4.5 aef9423b42 Improve adaptive color contrast using HCT color space
- Replace HSL-based color adjustments with HCT (Material Design 3 standard)
- Use proper WCAG contrast ratio calculations instead of luminance threshold
- Implement binary search to find minimum tone adjustment for contrast
- Ensure minimum chroma of 36 for primary colors to prevent washed-out highlights
- Add color scheme caching for instant loading on revisits
- Pre-extract colors on album tap for faster initial load

Fixes issue where highlighted library button was darker than unhighlighted
state on albums with dark/muted covers (e.g., Portishead, NIHT).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:14:36 +00:00
Chris c0138828a7 Revise reverse proxy details and remove auth methods
Updated reverse proxy options and removed authentication section.
2026-01-27 08:01:56 +00:00
68 changed files with 2205 additions and 760 deletions
+7 -15
View File
@@ -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
+7 -2
View File
@@ -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
Binary file not shown.
Binary file not shown.
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 909 KiB

@@ -0,0 +1 @@
Unofficial mobile client for Music Assistant
@@ -0,0 +1 @@
Ensemble
+3
View File
@@ -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",
+11
View File
@@ -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",
+3
View File
@@ -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",
+11
View File
@@ -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",
+18
View File
@@ -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:
+10
View File
@@ -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';
+9
View File
@@ -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';
+10
View File
@@ -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';
+10
View File
@@ -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';
+11 -2
View File
@@ -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(),
),
+32
View File
@@ -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 {
+43
View File
@@ -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(),
};
}
}
+192 -106
View File
@@ -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');
+65 -50
View File
@@ -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,
+70 -58
View File
@@ -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,
+21 -2
View File
@@ -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(
+3 -1
View File
@@ -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),
),
),
),
),
+39 -27
View File
@@ -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,
);
},
),
],
),
+12 -59
View File
@@ -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(
+42
View File
@@ -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');
}
+27
View File
@@ -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.
+1 -1
View File
@@ -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();
+177
View File
@@ -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,
};
}
+25 -1
View File
@@ -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
+5 -10
View File
@@ -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);
+74
View File
@@ -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();
}
}
}
+252 -114
View File
@@ -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,
+83 -38
View File
@@ -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,
);
+2 -2
View File
@@ -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(
+76 -38
View File
@@ -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,
);
+2 -2
View File
@@ -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(
+77 -38
View File
@@ -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,
);
+43 -35
View File
@@ -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,
);
}
}
+166
View File
@@ -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);
}
}
+230
View File
@@ -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,
);
}
}
+76 -38
View File
@@ -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,
);
+2 -2
View File
@@ -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(
+76 -36
View File
@@ -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,
);
+2 -2
View File
@@ -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(
+76 -36
View File
@@ -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,
);
+2 -2
View File
@@ -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(
+2 -2
View File
@@ -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(
+77 -38
View File
@@ -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,
);
+2 -1
View File
@@ -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