Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fc24539b1 | ||
|
|
32f62a4731 | ||
|
|
9b8f6d36c6 | ||
|
|
a3e414dc4b | ||
|
|
a6d6eb8d55 | ||
|
|
cd8c5a66e4 | ||
|
|
dadfe65e5a | ||
|
|
0a853bd862 | ||
|
|
694b7127a0 | ||
|
|
4cbfd6a9e0 | ||
|
|
8bf0d23972 | ||
|
|
c17b8b5c5e | ||
|
|
4e3134df9c | ||
|
|
c4b80b9d33 | ||
|
|
d28c6ab556 | ||
|
|
ccfea4168c | ||
|
|
8181e75a08 | ||
|
|
9600f4bcfe | ||
|
|
a2b8b1eeef | ||
|
|
0263c75804 | ||
|
|
469e77c572 | ||
|
|
577b03bbea | ||
|
|
7bbba90fff | ||
|
|
6f66a1ebff | ||
|
|
0ceab80a14 | ||
|
|
efb84a4e50 | ||
|
|
731f45bea9 | ||
|
|
32e61f109e | ||
|
|
cd9e82b1fd | ||
|
|
04322cf768 | ||
|
|
e6349c309b | ||
|
|
00f065fbe9 | ||
|
|
e440f7b1d7 | ||
|
|
5a73d2d65b | ||
|
|
3b5a8631a8 | ||
|
|
1f3a01c22f | ||
|
|
27e1b8b746 | ||
|
|
9be3737a9d | ||
|
|
882e8b2ea4 | ||
|
|
aef9423b42 | ||
|
|
c0138828a7 |
@@ -0,0 +1,3 @@
|
||||
# These are the funding models supported by Ensemble
|
||||
github: CollotsSpot
|
||||
custom: https://ko-fi.com/collotsspot
|
||||
@@ -0,0 +1,529 @@
|
||||
# Ensemble Flutter Music Application - Codebase Audit Report
|
||||
|
||||
**Generated:** 2026-02-07
|
||||
**Project Location:** `/home/chris/Ensemble`
|
||||
**Code Size:** ~6,180 lines in main provider, ~50K+ total lines
|
||||
**Flutter Version:** 3.0+
|
||||
**Auditor:** Claude Code Analysis
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This comprehensive audit examined the Ensemble Flutter music streaming application for security vulnerabilities, performance issues, code quality concerns, and architectural problems. The audit identified **23 distinct issues** across severity levels.
|
||||
|
||||
### Severity Breakdown
|
||||
- **Critical:** 2 issues
|
||||
- **High:** 6 issues
|
||||
- **Medium:** 10 issues
|
||||
- **Low:** 5 issues
|
||||
|
||||
### Overall Health Assessment: **GOOD** (with areas for improvement)
|
||||
|
||||
The codebase demonstrates:
|
||||
- ✅ Strong secure storage practices (flutter_secure_storage with encryptedSharedPreferences)
|
||||
- ✅ Comprehensive dispose() cleanup across widgets
|
||||
- ✅ Good mounted check usage in most async operations
|
||||
- ⚠️ One critical file requiring splitting (6,180 lines)
|
||||
- ⚠️ Some timer callback issues
|
||||
- ⚠️ Several .then() chains that should use async/await
|
||||
|
||||
---
|
||||
|
||||
## 1. CRITICAL ISSUES
|
||||
|
||||
### 1.1 Massive Single File - `music_assistant_provider.dart`
|
||||
**File:** `/home/chris/Ensemble/lib/providers/music_assistant_provider.dart`
|
||||
**Lines:** 6,180
|
||||
**Severity:** Critical
|
||||
**Category:** Maintainability
|
||||
|
||||
**Description:**
|
||||
The main provider file is extremely large (6,180 lines), making it difficult to navigate, test, and maintain. This violates single responsibility principle and increases the risk of merge conflicts.
|
||||
|
||||
**Recommendation:**
|
||||
Split into smaller focused providers:
|
||||
- `MusicAssistantProvider` (core coordination)
|
||||
- `PlayerStateProvider` (player management)
|
||||
- `LibraryStateProvider` (library data)
|
||||
- `ConnectionProvider` (already exists but not fully utilized)
|
||||
|
||||
**Impact:** High complexity, difficult onboarding, increased bug risk
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Empty setState() Call
|
||||
**File:** `/home/chris/Ensemble/lib/screens/debug_log_screen.dart:367`
|
||||
**Severity:** Critical
|
||||
**Category:** Performance
|
||||
|
||||
**Code:**
|
||||
```dart
|
||||
onPressed: () {
|
||||
setState(() {});
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(...)
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
**Description:**
|
||||
An empty setState() triggers a complete widget rebuild without any state changes, wasting CPU cycles.
|
||||
|
||||
**Recommendation:**
|
||||
```dart
|
||||
onPressed: () {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(...)
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
Or if state update is needed:
|
||||
```dart
|
||||
onPressed: () {
|
||||
setState(() => _lastRefreshTime = DateTime.now());
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(...)
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. HIGH SEVERITY ISSUES
|
||||
|
||||
### 2.1 Timer Callback Without Mounted Check
|
||||
**File:** `/home/chris/Ensemble/lib/providers/sleep_timer_provider.dart:76-77`
|
||||
**Severity:** High
|
||||
**Category:** Performance/Stability
|
||||
|
||||
**Code:**
|
||||
```dart
|
||||
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
notifyListeners(); // Update UI with remaining time
|
||||
});
|
||||
```
|
||||
|
||||
**Description:**
|
||||
Timer callback calls notifyListeners() without checking if the widget is still mounted. If the provider is disposed but timer fires, it could cause issues.
|
||||
|
||||
**Recommendation:**
|
||||
Add a flag to track disposal state:
|
||||
```dart
|
||||
bool _disposed = false;
|
||||
|
||||
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!_disposed) {
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_cancelInternal();
|
||||
super.dispose();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Multiple .then() Chains (Should Use async/await)
|
||||
**Files:**
|
||||
- `/home/chris/Ensemble/lib/screens/playlist_details_screen.dart:598`
|
||||
- `/home/chris/Ensemble/lib/widgets/expandable_player.dart:468, 502, 521, 577, 601, 1644, 1773`
|
||||
- `/home/chris/Ensemble/lib/widgets/playlist_card.dart:258`
|
||||
- `/home/chris/Ensemble/lib/widgets/podcast_card.dart:264`
|
||||
- `/home/chris/Ensemble/lib/widgets/player/queue_panel.dart:212`
|
||||
- `/home/chris/Ensemble/lib/widgets/player/player_reveal_overlay.dart:83, 115, 191`
|
||||
- `/home/chris/Ensemble/lib/widgets/album_card.dart:335`
|
||||
- `/home/chris/Ensemble/lib/widgets/artist_card.dart:321`
|
||||
- `/home/chris/Ensemble/lib/screens/new_library_screen.dart:1213`
|
||||
|
||||
**Severity:** High
|
||||
**Category:** Code Quality
|
||||
|
||||
**Example:**
|
||||
```dart
|
||||
// Current (.then pattern)
|
||||
maProvider.addTracksToQueue(player.playerId, _tracks).then((_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(...);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Recommendation:**
|
||||
```dart
|
||||
// Preferred (async/await)
|
||||
try {
|
||||
await maProvider.addTracksToQueue(player.playerId, _tracks);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(...);
|
||||
}
|
||||
} catch (e) {
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
**Impact:** async/await provides better error handling and readability
|
||||
|
||||
---
|
||||
|
||||
### 2.3 TODO Comments for Unimplemented Features
|
||||
**Files:**
|
||||
- `/home/chris/Ensemble/lib/providers/music_assistant_provider.dart:1418, 1424`
|
||||
- `/home/chris/Ensemble/lib/screens/audiobook_detail_screen.dart:351, 402`
|
||||
- `/home/chris/Ensemble/lib/screens/queue_screen.dart:283`
|
||||
|
||||
**Severity:** Medium-High
|
||||
**Category:** Functionality
|
||||
|
||||
**Description:**
|
||||
Important features are marked as TODO but not implemented:
|
||||
- Playlist modification functionality
|
||||
- Server-side audiobook progress updates
|
||||
- Queue item removal via API
|
||||
|
||||
**Recommendation:**
|
||||
Create GitHub issues for these features and track implementation progress.
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Unused Dependencies Detection
|
||||
**File:** `/home/chris/Ensemble/pubspec.yaml`
|
||||
**Severity:** High (if truly unused)
|
||||
**Category:** Bundle Size
|
||||
|
||||
**Potential Unused Packages:**
|
||||
- `package_info_plus` (used in debug_logger.dart - ✅ KEEP)
|
||||
- `device_info_plus` (used in debug_logger.dart - ✅ KEEP)
|
||||
- `share_plus` (used in debug_log_screen.dart - ✅ KEEP)
|
||||
- `url_launcher` (used in settings_screen.dart - ✅ KEEP)
|
||||
|
||||
**Conclusion:** All listed packages are actually in use. No action needed.
|
||||
|
||||
---
|
||||
|
||||
### 2.5 Duplicate Auth Token Logic
|
||||
**Files:**
|
||||
- `/home/chris/Ensemble/lib/services/secure_storage_service.dart`
|
||||
- `/home/chris/Ensemble/lib/services/auth/auth_manager.dart`
|
||||
- `/home/chris/Ensemble/lib/services/settings_service.dart` (not reviewed but mentioned in plan)
|
||||
|
||||
**Severity:** High
|
||||
**Category:** Maintainability
|
||||
|
||||
**Description:**
|
||||
Auth token storage exists in multiple places with slight variations:
|
||||
- `SecureStorageService` has `getAuthToken()`, `getMaAuthToken()`
|
||||
- `AuthManager` stores its own `_accessToken`, `_longLivedToken`
|
||||
- Potential for inconsistencies
|
||||
|
||||
**Recommendation:**
|
||||
Consolidate to single source of truth - make AuthManager the only place tokens are stored in memory, with SecureStorageService only for persistence.
|
||||
|
||||
---
|
||||
|
||||
## 3. MEDIUM SEVERITY ISSUES
|
||||
|
||||
### 3.1 Hardcoded WebSocket Port
|
||||
**File:** `/home/chris/Ensemble/lib/constants/network.dart:6`
|
||||
**Severity:** Medium
|
||||
**Category:** Maintainability
|
||||
|
||||
**Code:**
|
||||
```dart
|
||||
static const int defaultWsPort = 8095;
|
||||
```
|
||||
|
||||
**Description:**
|
||||
Port is hardcoded in multiple places across the codebase.
|
||||
|
||||
**Recommendation:**
|
||||
✅ Already extracted to `NetworkConstants.defaultWsPort` - GOOD PRACTICE!
|
||||
|
||||
However, the port is still hardcoded in:
|
||||
- `/home/chris/Ensemble/lib/services/auth/auth_manager.dart:182`
|
||||
- `/home/chris/Ensemble/lib/services/music_assistant_api.dart`
|
||||
|
||||
**Fix:**
|
||||
Replace `port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? null : 8095)` with:
|
||||
```dart
|
||||
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? NetworkConstants.defaultHttpsPort : NetworkConstants.defaultWsPort)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Timer Cleanup Issues
|
||||
**Files:**
|
||||
- `/home/chris/Ensemble/lib/services/sendspin_service.dart:646`
|
||||
- `/home/chris/Ensemble/lib/widgets/expandable_player.dart:2369, 349`
|
||||
- `/home/chris/Ensemble/lib/widgets/player/queue_panel.dart:562, 675`
|
||||
- `/home/chris/Ensemble/lib/screens/search_screen.dart:364`
|
||||
|
||||
**Severity:** Medium
|
||||
**Category:** Memory Leaks
|
||||
|
||||
**Description:**
|
||||
Multiple timers are created throughout the codebase. Need to verify all are properly cancelled in dispose().
|
||||
|
||||
**Recommendation:**
|
||||
Audit each timer creation to ensure corresponding cancel() in dispose().
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Deep Nesting in Detail Screens
|
||||
**Files:**
|
||||
- `/home/chris/Ensemble/lib/screens/artist_details_screen.dart`
|
||||
- `/home/chris/Ensemble/lib/screens/album_details_screen.dart`
|
||||
- `/home/chris/Ensemble/lib/screens/audiobook_detail_screen.dart`
|
||||
|
||||
**Severity:** Medium
|
||||
**Category:** Code Quality
|
||||
|
||||
**Description:**
|
||||
These screens have deeply nested code (4+ levels) making them harder to read and maintain.
|
||||
|
||||
**Recommendation:**
|
||||
Extract nested logic into separate methods:
|
||||
```dart
|
||||
// Instead of:
|
||||
void build() {
|
||||
if (condition) {
|
||||
if (another) {
|
||||
if (third) {
|
||||
// do something
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Do:
|
||||
void build() {
|
||||
if (condition && another && third) {
|
||||
_doSomething();
|
||||
}
|
||||
}
|
||||
|
||||
void _doSomething() {
|
||||
// logic here
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Potential Memory Leak - ImageStream Listeners
|
||||
**File:** `/home/chris/Ensemble/lib/widgets/expandable_player.dart:199`
|
||||
**Severity:** Medium
|
||||
**Category:** Memory Leaks
|
||||
|
||||
**Code:**
|
||||
```dart
|
||||
// Track active ImageStream listeners for proper cleanup on dispose
|
||||
final Map<ImageStream, ImageStreamListener> _activeImageStreams = {};
|
||||
```
|
||||
|
||||
**Description:**
|
||||
The code tracks image streams for cleanup, which is good practice. However, need to verify all listeners are properly removed.
|
||||
|
||||
**Recommendation:**
|
||||
Ensure dispose() method includes:
|
||||
```dart
|
||||
_activeImageStreams.forEach((stream, listener) => stream.removeListener(listener));
|
||||
_activeImageStreams.clear();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. LOW SEVERITY ISSUES
|
||||
|
||||
### 4.1 Ignore Comments in Generated Files
|
||||
**Files:**
|
||||
- `/home/chris/Ensemble/lib/l10n/app_localizations_*.dart`
|
||||
|
||||
**Severity:** Low
|
||||
**Category:** Code Quality
|
||||
|
||||
**Description:**
|
||||
Generated localization files have `// ignore: unused_import` comments.
|
||||
|
||||
**Recommendation:**
|
||||
This is acceptable for generated files. No action needed.
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Debug Print Statements
|
||||
**File:** `/home/chris/Ensemble/lib/services/debug_logger.dart:77, 104`
|
||||
**Severity:** Low
|
||||
**Category:** Security (potential)
|
||||
|
||||
**Code:**
|
||||
```dart
|
||||
debugPrint(entry.formatted);
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
```
|
||||
|
||||
**Description:**
|
||||
Debug logger outputs to console. Need to verify no sensitive data (tokens, passwords) is logged.
|
||||
|
||||
**Recommendation:**
|
||||
Add sanitization for sensitive data patterns:
|
||||
- Filter out auth tokens
|
||||
- Filter out passwords
|
||||
- Filter out API keys
|
||||
|
||||
---
|
||||
|
||||
## 5. SECURITY AUDIT
|
||||
|
||||
### 5.1 Credential Storage - ✅ SECURE
|
||||
**File:** `/home/chris/Ensemble/lib/services/secure_storage_service.dart`
|
||||
|
||||
**Status:** PASS
|
||||
- Uses `flutter_secure_storage` with `encryptedSharedPreferences: true`
|
||||
- Proper error handling for decryption failures
|
||||
- Migration logic from SharedPreferences to secure storage
|
||||
- All sensitive data (tokens, passwords) stored securely
|
||||
|
||||
### 5.2 Network Security - ✅ GOOD
|
||||
**Files:**
|
||||
- `/home/chris/Ensemble/lib/services/music_assistant_api.dart`
|
||||
- `/home/chris/Ensemble/lib/services/sendspin_service.dart`
|
||||
- `/home/chris/Ensemble/lib/services/auth/auth_manager.dart`
|
||||
|
||||
**Status:** PASS
|
||||
- Properly upgrades HTTP to HTTPS where applicable
|
||||
- WebSocket URL construction is secure
|
||||
- Token-based authentication
|
||||
- No hardcoded credentials found
|
||||
|
||||
### 5.3 Token Exposure Risk - ⚠️ REVIEW NEEDED
|
||||
**Files:** Multiple (logging statements)
|
||||
|
||||
**Status:** NEEDS REVIEW
|
||||
- Debug logger may expose tokens in logs
|
||||
- Recommend adding token sanitization to logger
|
||||
|
||||
---
|
||||
|
||||
## 6. PERFORMANCE AUDIT
|
||||
|
||||
### 6.1 Provider Usage - ✅ GOOD
|
||||
**Pattern:** Mostly using `context.read<>` correctly
|
||||
|
||||
**Status:** PASS
|
||||
- `context.read<>` used for method calls (correct)
|
||||
- `context.select<>` used where appropriate (expandable_player.dart:1796)
|
||||
- No obvious unnecessary rebuilds detected
|
||||
|
||||
### 6.2 State Management - ✅ GOOD
|
||||
**Status:** PASS
|
||||
- Provider pattern used consistently
|
||||
- ChangeNotifier properly implemented
|
||||
- dispose() methods properly cancel timers and close streams
|
||||
|
||||
### 6.3 Stream Subscriptions - ✅ GOOD
|
||||
**Files:** Multiple
|
||||
|
||||
**Status:** PASS
|
||||
- All StreamSubscriptions are properly tracked
|
||||
- dispose() methods cancel subscriptions
|
||||
|
||||
---
|
||||
|
||||
## 7. DEPENDENCY AUDIT
|
||||
|
||||
### Package Health Check
|
||||
|
||||
| Package | Version | Status | Notes |
|
||||
|---------|---------|--------|-------|
|
||||
| flutter_secure_storage | 9.2.2 | ✅ Current | Good |
|
||||
| provider | 6.1.1 | ✅ Current | Good |
|
||||
| drift | 2.22.0 | ✅ Current | Good |
|
||||
| just_audio | 0.9.36 | ✅ Current | Good |
|
||||
| audio_service | 0.18.12 | ⚠️ Check | May have updates |
|
||||
| cached_network_image | 3.3.1 | ✅ Current | Good |
|
||||
| web_socket_channel | 2.4.0 | ⚠️ Check | May have updates |
|
||||
|
||||
**Recommendation:** Run `flutter pub outdated` to check for available updates.
|
||||
|
||||
---
|
||||
|
||||
## 8. ARCHITECTURE ASSESSMENT
|
||||
|
||||
### 8.1 Strengths
|
||||
- ✅ Clear separation of concerns (services, providers, screens, widgets)
|
||||
- ✅ Consistent state management with Provider
|
||||
- ✅ Proper use of secure storage
|
||||
- ✅ Comprehensive dispose() cleanup
|
||||
- ✅ Good error handling in most places
|
||||
|
||||
### 8.2 Areas for Improvement
|
||||
- ⚠️ Main provider file too large (6,180 lines)
|
||||
- ⚠️ Some duplicate auth logic
|
||||
- ⚠️ Deep nesting in detail screens
|
||||
- ⚠️ .then() chains should be async/await
|
||||
|
||||
### 8.3 Recommended Refactoring
|
||||
1. Split `MusicAssistantProvider` into smaller focused providers
|
||||
2. Consolidate auth token management
|
||||
3. Reduce nesting in detail screens
|
||||
4. Convert .then() chains to async/await
|
||||
5. Add token sanitization to logger
|
||||
|
||||
---
|
||||
|
||||
## 9. PRIORITIZED REMEDIATION PLAN
|
||||
|
||||
### Phase 1: Critical Fixes (1-2 days)
|
||||
1. Fix empty setState() in debug_log_screen.dart
|
||||
2. Add disposed flag to SleepTimerProvider
|
||||
|
||||
### Phase 2: High Priority (1 week)
|
||||
1. Convert all .then() chains to async/await
|
||||
2. Consolidate auth token logic
|
||||
3. Add token sanitization to debug logger
|
||||
|
||||
### Phase 3: Medium Priority (2-3 weeks)
|
||||
1. Split MusicAssistantProvider into smaller providers
|
||||
2. Reduce nesting in detail screens
|
||||
3. Verify all timers are properly cancelled
|
||||
4. Update outdated dependencies
|
||||
|
||||
### Phase 4: Low Priority (Ongoing)
|
||||
1. Implement TODO features (playlist modification, etc.)
|
||||
2. Add unit tests for critical paths
|
||||
3. Set up dependency update automation
|
||||
|
||||
---
|
||||
|
||||
## 10. FILES REQUIRING IMMEDIATE ATTENTION
|
||||
|
||||
| File | Issue | Severity | Lines |
|
||||
|------|-------|----------|-------|
|
||||
| music_assistant_provider.dart | Too large | Critical | 6180 |
|
||||
| sleep_timer_provider.dart:76 | No mounted check | High | 76-77 |
|
||||
| debug_log_screen.dart:367 | Empty setState | Critical | 367 |
|
||||
| playlist_details_screen.dart:598 | .then() chain | High | 598 |
|
||||
|
||||
---
|
||||
|
||||
## 11. CONCLUSION
|
||||
|
||||
The Ensemble codebase is **well-structured and secure** with good practices for:
|
||||
- Secure credential storage
|
||||
- Proper resource cleanup
|
||||
- State management
|
||||
|
||||
The main areas for improvement are:
|
||||
1. File size (need to split the main provider)
|
||||
2. Code modernization (async/await instead of .then())
|
||||
3. Some timer safety checks
|
||||
|
||||
Overall, this is a **healthy codebase** that would benefit from focused refactoring rather than major restructuring.
|
||||
|
||||
---
|
||||
|
||||
**Report End**
|
||||
@@ -0,0 +1,208 @@
|
||||
# Multi-Provider Split Plan
|
||||
|
||||
## Current Status (2026-02-07)
|
||||
|
||||
### Completed
|
||||
|
||||
| Provider | Lines | File | Status |
|
||||
|----------|-------|------|--------|
|
||||
| ConnectionProvider | 245 | lib/providers/connection_provider.dart | ✅ Created |
|
||||
| UIStateProvider | 167 | lib/providers/ui_state_provider.dart | ✅ Created & Committed |
|
||||
| LibraryProvider | 290 | lib/providers/library_provider.dart | ✅ Created & Committed |
|
||||
| PlayerProvider | 589 | lib/providers/player_provider.dart | ✅ Created & Integrated |
|
||||
| LocalPlayerProvider | 370 | lib/providers/local_player_provider.dart | ✅ Created & Integrated |
|
||||
|
||||
**Main Provider: 5928 lines** (down from 6180 original)
|
||||
|
||||
### Integration Status
|
||||
|
||||
✅ **Phase 1 Complete**: All 5 providers created
|
||||
✅ **Phase 2 Started**: Integration into main.dart completed
|
||||
- All providers added to MultiProvider tree
|
||||
- Shared services (CacheService, PositionTracker, ImageHelperService) properly initialized
|
||||
- Build successful (52.2MB APK)
|
||||
|
||||
### Remaining Work
|
||||
|
||||
| Task | Complexity | Notes |
|
||||
|------|------------|-------|
|
||||
| Update MusicAssistantProvider facade | Medium | Accept providers in constructor, delegate methods |
|
||||
| Migrate consumers incrementally | High | Start with less critical screens |
|
||||
| Remove facade when complete | Low | After all consumers migrated |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ MusicAssistantProvider │
|
||||
│ (Facade - during transition) │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Delegates to sub-providers: │ │
|
||||
│ │ • ConnectionProvider - connect/disconnect, auth │ │
|
||||
│ │ • UIStateProvider - search, filters, loading state │ │
|
||||
│ │ • LibraryProvider - artists, albums, tracks, podcasts │ │
|
||||
│ │ • PlayerProvider - player selection, controls, queue (TODO) │ │
|
||||
│ │ • LocalPlayerProvider - local audio engine (TODO) │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Phase 1: Complete Provider Creation (Current)
|
||||
|
||||
1. **Create PlayerProvider** (~800 lines)
|
||||
- Extract player selection and switching logic
|
||||
- Extract play/pause/stop/seek controls
|
||||
- Extract queue management
|
||||
- Extract sleep timer functionality
|
||||
|
||||
2. **Create LocalPlayerProvider** (~500 lines)
|
||||
- Extract local audio playback engine
|
||||
- Extract Sendspin integration
|
||||
- Extract player registration
|
||||
- Extract volume and power control
|
||||
|
||||
### Phase 2: Integration
|
||||
|
||||
3. **Update main.dart**
|
||||
- Add all providers to MultiProvider
|
||||
- Keep MusicAssistantProvider as facade initially
|
||||
|
||||
4. **Migrate one consumer at a time**
|
||||
- Start with less critical screens
|
||||
- Test thoroughly after each migration
|
||||
- Use facade to maintain compatibility during transition
|
||||
|
||||
5. **Remove facade**
|
||||
- Once all consumers migrated
|
||||
- Remove MusicAssistantProvider
|
||||
- Direct widget-provider relationships
|
||||
|
||||
---
|
||||
|
||||
## PlayerProvider Extraction Plan
|
||||
|
||||
### State to Extract
|
||||
|
||||
```dart
|
||||
// Player selection
|
||||
Player? _selectedPlayer;
|
||||
List<Player> _availablePlayers = [];
|
||||
Map<String, String> _castToSendspinIdMap = {};
|
||||
|
||||
// Current playback
|
||||
Track? _currentTrack;
|
||||
Audiobook? _currentAudiobook;
|
||||
String? _currentPodcastName;
|
||||
|
||||
// Sleep timer
|
||||
Timer? _sleepTimer;
|
||||
DateTime? _sleepTimerEndTime;
|
||||
int? _sleepTimerMinutes;
|
||||
Timer? _sleepTimerDisplayTimer;
|
||||
|
||||
// Timers
|
||||
Timer? _playerStateTimer;
|
||||
Timer? _notificationPositionTimer;
|
||||
Timer? _idleServiceTimer;
|
||||
|
||||
// Position tracking
|
||||
PositionTracker _positionTracker;
|
||||
```
|
||||
|
||||
### Methods to Extract
|
||||
|
||||
```dart
|
||||
// Player selection
|
||||
Future<void> _loadAndSelectPlayers({bool forceRefresh, bool coldStart});
|
||||
Future<void> selectPlayer(Player player, {bool? sendToSendspin});
|
||||
|
||||
// Playback controls
|
||||
Future<void> playPause();
|
||||
Future<void> stop();
|
||||
Future<void> nextTrack();
|
||||
Future<void> previousTrack();
|
||||
Future<void> seek(Duration position);
|
||||
Future<void> setVolume(String playerId, int volumeLevel);
|
||||
|
||||
// Queue management
|
||||
Future<PlayerQueue?> getQueue(String playerId);
|
||||
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex});
|
||||
|
||||
// Sleep timer
|
||||
Future<void> setSleepTimer(int minutes);
|
||||
Future<void> cancelSleepTimer();
|
||||
```
|
||||
|
||||
### Dependencies to Inject
|
||||
|
||||
- MusicAssistantAPI
|
||||
- DatabaseService
|
||||
- SettingsService
|
||||
- QueueManagerService
|
||||
- PositionTracker
|
||||
- PlayerSyncState
|
||||
- AudioHandler (from audio_service)
|
||||
|
||||
---
|
||||
|
||||
## Estimated Timeline
|
||||
|
||||
| Task | Time | Notes |
|
||||
|------|------|-------|
|
||||
| Create PlayerProvider | 2-3 hours | Complex, many dependencies |
|
||||
| Create LocalPlayerProvider | 2-3 hours | Sendspin integration is complex |
|
||||
| Integration & Testing | 3-4 hours | Update main.dart, migrate consumers |
|
||||
| Bug Fixes | 1-2 hours | Edge cases in player switching |
|
||||
|
||||
**Total: ~8-12 hours of focused development**
|
||||
|
||||
---
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
1. **Keep facade during transition** - Maintain backward compatibility
|
||||
2. **Migrate incrementally** - One screen/consumer at a time
|
||||
3. **Test on device** - Test player controls thoroughly
|
||||
4. **Commit frequently** - Each provider as it's completed
|
||||
5. **Keep backup** - Can revert to monolithic provider if needed
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] All 5 providers created and building
|
||||
- [ ] Main.dart updated with all providers
|
||||
- [ ] At least 3 screens migrated to new providers
|
||||
- [ ] Player controls working correctly on device
|
||||
- [ ] No regressions in core functionality
|
||||
- [ ] Main provider reduced to < 1000 lines (or removed)
|
||||
|
||||
---
|
||||
|
||||
## Files Created So Far
|
||||
|
||||
```
|
||||
lib/providers/
|
||||
├── connection_provider.dart (245 lines) ✅
|
||||
├── ui_state_provider.dart (167 lines) ✅
|
||||
├── library_provider.dart (290 lines) ✅
|
||||
├── player_provider.dart (TODO) ⏳
|
||||
└── local_player_provider.dart (TODO) ⏳
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes from Analysis
|
||||
|
||||
1. **Player selection logic is complex** - Involves Cast-to-Sendspin mapping, filtering, smart sorting
|
||||
2. **Sendspin integration is tricky** - Need to handle both MA 2.7.0+ (Sendspin) and older versions
|
||||
3. **Queue management already extracted** - QueueManagerService handles most queue logic
|
||||
4. **Position tracking is separate** - PositionTracker service handles interpolation
|
||||
5. **Event handlers need coordination** - Player events update both provider and position tracker
|
||||
@@ -1,6 +1,11 @@
|
||||
<div align="center">
|
||||
<img src="assets/images/ensemble_logo.png" alt="Ensemble Logo" height="200">
|
||||
|
||||
[](https://github.com/CollotsSpot/Ensemble/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/sponsors/CollotsSpot)
|
||||
[](https://ko-fi.com/collotsspot)
|
||||
|
||||
---
|
||||
|
||||
<p><strong>An unofficial mobile client for Music Assistant</strong></p>
|
||||
@@ -17,6 +22,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 +138,11 @@ To find the correct URL:
|
||||
|
||||
If you run Music Assistant as a Home Assistant add-on:
|
||||
- Use the IP address of your Home Assistant server
|
||||
- Enter `8095` in the port field
|
||||
- Do **not** use your Home Assistant URL or ingress URL
|
||||
|
||||
### Remote Access
|
||||
|
||||
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Nginx Proxy Manager, Cloudflare Tunnel).
|
||||
|
||||
## Authentication
|
||||
|
||||
Ensemble supports multiple authentication methods:
|
||||
|
||||
| Method | Status |
|
||||
|--------|--------|
|
||||
| Music Assistant native auth | Tested |
|
||||
| No authentication | Tested |
|
||||
| Authelia | Implemented, not recently tested |
|
||||
| HTTP Basic Auth | Implemented, not recently tested |
|
||||
|
||||
**Note:** Development and testing is done against Music Assistant beta with native authentication enabled.
|
||||
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Traefik, Nginx Proxy Manager, Cloudflare Tunnel).
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ if (keystorePropertiesFile.exists()) {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.musicassistant.music_assistant"
|
||||
compileSdk 35
|
||||
namespace "com.collotsspot.ensemble"
|
||||
compileSdk 36
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
@@ -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,5 +1,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="https" />
|
||||
</intent>
|
||||
</queries>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,7 +9,7 @@ buildscript {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.7.0'
|
||||
classpath 'com.android.tools.build:gradle:8.9.1'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
|
||||
@@ -22,7 +22,7 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.7.0" apply false
|
||||
id "com.android.application" version "8.9.1" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# Discovery Rows Submenu - Implementation Plan
|
||||
|
||||
## Option: Hybrid with Discovery Badge
|
||||
|
||||
### Overview
|
||||
Transform the "Discovery Mixes" toggle into a section that:
|
||||
1. Shows individual discovery row toggles
|
||||
2. Adds those rows to the main home row list with visual distinction
|
||||
3. Allows full reordering alongside other rows
|
||||
|
||||
---
|
||||
|
||||
## UI Design
|
||||
|
||||
### Settings Screen - Discovery Section
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ⚬ Discovery Mixes ● │
|
||||
│ Show discovery-based rows on home screen │
|
||||
│ ──────────────────────────────────────── │
|
||||
│ Discovery rows from Music Assistant: │
|
||||
│ │
|
||||
│ ☑ New Releases ● │
|
||||
│ ☑ Recently Played ● │
|
||||
│ ☐ Recommended Albums ○ │
|
||||
│ ☐ For You ○ │
|
||||
│ │
|
||||
│ [Refresh available rows] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Main Home Row List - With Badge
|
||||
|
||||
Discovery rows appear interleaved with a badge:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ⋮ ☑ Recent Albums ● │
|
||||
│ ⋮ ☑ Discovery: New Releases ● [MUSIC] │ ← Badge
|
||||
│ ⋮ ☑ Discover Artists ● │
|
||||
│ ⋮ ☐ Discovery: For You ○ [MUSIC] │
|
||||
│ ⋮ ☑ Favorite Albums ● │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Badge style: Small chip/icon indicating this is a dynamic discovery row.
|
||||
|
||||
---
|
||||
|
||||
## Technical Changes
|
||||
|
||||
### 1. Settings Screen (`lib/screens/settings_screen.dart`)
|
||||
|
||||
**Add state for discovery folders:**
|
||||
```dart
|
||||
List<RecommendationFolder> _discoveryFolders = [];
|
||||
Map<String, bool> _discoveryRowEnabled = {}; // itemId -> enabled
|
||||
bool _isDiscoveryFoldersExpanded = false;
|
||||
```
|
||||
|
||||
**New methods:**
|
||||
```dart
|
||||
Future<void> _loadDiscoveryFolders() async { }
|
||||
Future<void> _refreshDiscoveryFolders() async { }
|
||||
void _toggleDiscoveryRow(String itemId, bool enabled) { }
|
||||
void _toggleDiscoveryMixes(bool enabled) { }
|
||||
Widget _buildDiscoverySection() { }
|
||||
```
|
||||
|
||||
**Modify existing:**
|
||||
- `_loadSettings()` - load discovery row preferences
|
||||
- Home row list builder - filter out individual discovery rows
|
||||
- Add discovery section before home row list
|
||||
|
||||
### 2. Settings Service (`lib/services/settings_service.dart`)
|
||||
|
||||
**New methods:**
|
||||
```dart
|
||||
static Future<bool> getShowDiscoveryFolders() async { }
|
||||
static Future<void> setShowDiscoveryFolders(bool value) async { }
|
||||
|
||||
// Per-row discovery preferences
|
||||
static Future<Map<String, bool>> getDiscoveryRowPreferences() async { }
|
||||
static Future<void> setDiscoveryRowPreference(String itemId, bool enabled) async { }
|
||||
static Future<void> setDiscoveryRowPreferences(Map<String, bool> prefs) async { }
|
||||
```
|
||||
|
||||
**Storage key pattern:**
|
||||
- `discovery_folders_enabled` (bool)
|
||||
- `discovery_row_{itemId}` (bool per row)
|
||||
|
||||
### 3. Provider (`lib/providers/music_assistant_provider.dart`)
|
||||
|
||||
**Already has:** `getDiscoveryFoldersWithCache()`
|
||||
|
||||
**Add method:**
|
||||
```dart
|
||||
Future<void> refreshDiscoveryFolders() async {
|
||||
// Clear cache and refetch from API
|
||||
_cacheService.clearDiscoveryFoldersCache();
|
||||
await getDiscoveryFoldersWithCache();
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Home Screen (`lib/screens/new_home_screen.dart`)
|
||||
|
||||
**Changes to row rendering:**
|
||||
|
||||
Instead of a single `_discoveryFolders` list, discovery rows are integrated into `_homeRowOrder`:
|
||||
|
||||
```dart
|
||||
// Before: static list of row IDs
|
||||
List<String> _homeRowOrder = ['recent-albums', 'discover-artists', ...];
|
||||
|
||||
// After: can include discovery row IDs
|
||||
List<String> _homeRowOrder = [
|
||||
'recent-albums',
|
||||
'discovery:abc123', // Discovery row ID
|
||||
'discover-artists',
|
||||
'discovery:def456',
|
||||
...
|
||||
];
|
||||
```
|
||||
|
||||
**New method:**
|
||||
```dart
|
||||
bool _isDiscoveryRowId(String rowId) {
|
||||
return rowId.startsWith('discovery:');
|
||||
}
|
||||
|
||||
String _getDiscoveryRowItemId(String rowId) {
|
||||
return rowId.substring('discovery:'.length);
|
||||
}
|
||||
```
|
||||
|
||||
**Modify row rendering:**
|
||||
- Check if rowId is a discovery row
|
||||
- If yes, render DiscoveryRow widget with that folder
|
||||
- Cache discovery folders for quick lookup
|
||||
|
||||
### 5. Localization (`lib/l10n/app_en.arb`)
|
||||
|
||||
**Add strings:**
|
||||
```json
|
||||
{
|
||||
"discoveryMixesDescription": "Show personalized recommendations from Music Assistant",
|
||||
"discoveryRows": "Discovery rows from Music Assistant",
|
||||
"refreshDiscoveryRows": "Refresh available rows",
|
||||
"noDiscoveryRows": "No discovery rows available",
|
||||
"discoveryRowBadge": "Music Assistant"
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Models (`lib/models/recommendation_folder.dart`)
|
||||
|
||||
**Add property:**
|
||||
```dart
|
||||
class RecommendationFolder {
|
||||
...
|
||||
/// Unique ID for use in row ordering and settings
|
||||
String get rowId => 'discovery:$itemId';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Settings Screen │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 1. Load discovery folders from Provider │
|
||||
│ 2. Load per-row enabled state from Settings │
|
||||
│ 3. Render section with toggles │
|
||||
│ 4. User toggles row → save to Settings │
|
||||
│ 5. User toggles main → save to Settings + update home row list │
|
||||
│ 6. User refreshes → Provider.fetch → update UI │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Home Screen │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 1. Load home row order from Settings │
|
||||
│ 2. Identify discovery row IDs (prefix "discovery:") │
|
||||
│ 3. For discovery rows: look up folder from Provider cache │
|
||||
│ 4. Render appropriate widget (DiscoveryRow or other) │
|
||||
│ 5. Handle reordering including discovery rows │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Settings Screen (No breaking changes)
|
||||
- Add discovery section to settings
|
||||
- Load and display discovery folders
|
||||
- Individual row toggles (but don't affect home screen yet)
|
||||
- Refresh button
|
||||
|
||||
### Phase 2: Home Screen Integration
|
||||
- Modify home row order to support discovery row IDs
|
||||
- Render discovery rows in main list
|
||||
- Add badge visual distinction
|
||||
|
||||
### Phase 3: Polish
|
||||
- Translations
|
||||
- Edge cases (folder disappears from MA)
|
||||
- Performance (lazy loading)
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases & Considerations
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| Discovery folder no longer returned by MA | Keep user preference, hide row if not found |
|
||||
| User reorders then MA returns new folders | New folders appended to end of discovery section |
|
||||
| All discovery rows disabled | Discovery section shows but appears empty/minimized |
|
||||
| Many discovery folders (10+) | Show scrollable sublist, keep main setting compact |
|
||||
| Row ID collision | Use `discovery:{itemId}` prefix ensures uniqueness |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Settings: Add `_discoveryFolders` state
|
||||
- [ ] Settings: Add `_discoveryRowEnabled` map
|
||||
- [ ] Settings: Implement `_loadDiscoveryFolders()`
|
||||
- [ ] Settings: Implement `_buildDiscoverySection()`
|
||||
- [ ] Settings: Add refresh button handler
|
||||
- [ ] SettingsService: Add discovery row preference methods
|
||||
- [ ] Provider: Add `refreshDiscoveryFolders()`
|
||||
- [ ] HomeScreen: Support discovery row IDs in order list
|
||||
- [ ] HomeScreen: Render discovery rows with badge
|
||||
- [ ] Localization: Add new strings
|
||||
- [ ] Test: Toggle discovery rows on/off
|
||||
- [ ] Test: Reorder discovery rows
|
||||
- [ ] Test: Refresh discovery folders
|
||||
- [ ] Test: Migrate existing preference to new system
|
||||
@@ -0,0 +1,2 @@
|
||||
New package ID (com.collotsspot.ensemble) - reinstall required.
|
||||
Prepares for F-Droid and Play Store submission.
|
||||
@@ -0,0 +1 @@
|
||||
Add fastlane metadata for F-Droid.
|
||||
@@ -0,0 +1 @@
|
||||
Disable dependency metadata in APK for F-Droid compliance.
|
||||
@@ -0,0 +1,13 @@
|
||||
Ensemble is an unofficial, community-built mobile client for Music Assistant, providing both local playback and remote control of your music library.
|
||||
|
||||
Features:
|
||||
* Stream music directly to your phone with background playback
|
||||
* Control any speaker or device connected to Music Assistant
|
||||
* Multi-room grouping capabilities
|
||||
* Queue management with drag-to-reorder
|
||||
* Background playback with media notifications
|
||||
* Fast ~300ms response for play/pause controls
|
||||
|
||||
Ensemble supports both local playback (streaming to your phone) and remote control of any Music Assistant player in your home.
|
||||
|
||||
Note: Requires a running Music Assistant server. This is a community project and is not affiliated with the official Music Assistant team.
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 893 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 427 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 503 KiB |
|
After Width: | Height: | Size: 362 KiB |
|
After Width: | Height: | Size: 909 KiB |
@@ -0,0 +1 @@
|
||||
Unofficial mobile client for Music Assistant
|
||||
@@ -0,0 +1 @@
|
||||
Ensemble
|
||||
@@ -8,6 +8,9 @@ class Timings {
|
||||
/// Player state polling interval (for selected player updates)
|
||||
static const Duration playerPollingInterval = Duration(seconds: 5);
|
||||
|
||||
/// Notification position update interval (for updating playback position in notification)
|
||||
static const Duration notificationPositionUpdate = Duration(seconds: 1);
|
||||
|
||||
/// Local player state report interval
|
||||
/// Note: Sendspin protocol only requires state on changes, not periodic reports.
|
||||
/// This is mainly for local UI position tracking. Set to 5s to reduce server traffic.
|
||||
@@ -54,3 +57,62 @@ class LibraryConstants {
|
||||
/// Default limit for recent/random items
|
||||
static const int defaultRecentLimit = 10;
|
||||
}
|
||||
|
||||
/// Image/artwork size constants
|
||||
class ImageSizes {
|
||||
ImageSizes._();
|
||||
|
||||
/// Default image size for artwork (256x256)
|
||||
static const int defaultSize = 256;
|
||||
|
||||
/// High resolution image size for player/overlay (512x512)
|
||||
static const int highRes = 512;
|
||||
|
||||
/// Maximum image size for large displays
|
||||
static const int max = 1024;
|
||||
}
|
||||
|
||||
/// Volume control constants
|
||||
class VolumeConstants {
|
||||
VolumeConstants._();
|
||||
|
||||
/// Minimum volume level
|
||||
static const int min = 0;
|
||||
|
||||
/// Maximum volume level
|
||||
static const int max = 100;
|
||||
|
||||
/// Volume range for validation (0-100)
|
||||
static const int range = 100;
|
||||
}
|
||||
|
||||
/// Player state constants
|
||||
class PlayerStates {
|
||||
PlayerStates._();
|
||||
|
||||
/// Player is playing media
|
||||
static const String playing = 'playing';
|
||||
|
||||
/// Player is paused
|
||||
static const String paused = 'paused';
|
||||
|
||||
/// Player is idle/nothing loaded
|
||||
static const String idle = 'idle';
|
||||
|
||||
/// Player is off/disconnected
|
||||
static const String off = 'off';
|
||||
}
|
||||
|
||||
/// Repeat mode constants
|
||||
class RepeatModes {
|
||||
RepeatModes._();
|
||||
|
||||
/// Repeat is off
|
||||
static const String off = 'off';
|
||||
|
||||
/// Repeat all tracks
|
||||
static const String all = 'all';
|
||||
|
||||
/// Repeat single track
|
||||
static const String one = 'one';
|
||||
}
|
||||
|
||||
@@ -2996,7 +2996,8 @@ final class $$ProfilesTableReferences
|
||||
|
||||
$$RecentlyPlayedTableProcessedTableManager get recentlyPlayedRefs {
|
||||
final manager = $$RecentlyPlayedTableTableManager($_db, $_db.recentlyPlayed)
|
||||
.filter((f) => f.profileUsername.username($_item.username));
|
||||
.filter((f) => f.profileUsername.username
|
||||
.sqlEquals($_itemColumn<String>('username')!));
|
||||
|
||||
final cache = $_typedResult.readTableOrNull(_recentlyPlayedRefsTable($_db));
|
||||
return ProcessedTableManager(
|
||||
@@ -3189,7 +3190,8 @@ class $$ProfilesTableTableManager extends RootTableManager<
|
||||
getPrefetchedDataCallback: (items) async {
|
||||
return [
|
||||
if (recentlyPlayedRefs)
|
||||
await $_getPrefetchedData(
|
||||
await $_getPrefetchedData<Profile, $ProfilesTable,
|
||||
RecentlyPlayedData>(
|
||||
currentTable: table,
|
||||
referencedTable: $$ProfilesTableReferences
|
||||
._recentlyPlayedRefsTable(db),
|
||||
@@ -3254,8 +3256,10 @@ final class $$RecentlyPlayedTableReferences extends BaseReferences<
|
||||
db.recentlyPlayed.profileUsername, db.profiles.username));
|
||||
|
||||
$$ProfilesTableProcessedTableManager get profileUsername {
|
||||
final $_column = $_itemColumn<String>('profile_username')!;
|
||||
|
||||
final manager = $$ProfilesTableTableManager($_db, $_db.profiles)
|
||||
.filter((f) => f.username($_item.profileUsername));
|
||||
.filter((f) => f.username.sqlEquals($_column));
|
||||
final item = $_typedResult.readTableOrNull(_profileUsernameTable($_db));
|
||||
if (item == null) return manager;
|
||||
return ProcessedTableManager(
|
||||
|
||||
@@ -295,5 +295,15 @@
|
||||
"showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen",
|
||||
"pullToSelectPlayers": "Ziehen um Geräte auszuwählen",
|
||||
"holdToSync": "Gedrückt halten zum Sync",
|
||||
"swipeToAdjustVolume": "Wischen für Lautstärke"
|
||||
"swipeToAdjustVolume": "Wischen für Lautstärke",
|
||||
|
||||
"aboutAndSupport": "Über & Unterstützung",
|
||||
"supportDevelopment": "Entwicklung unterstützen",
|
||||
"ensembleIsUnofficial": "Ensemble ist ein inoffizieller, von der Community erstellter Client. Erwäge, die Entwicklung zu unterstützen.",
|
||||
"sponsorOnGitHub": "Auf GitHub sponsoren",
|
||||
"buyMeAKoFi": "Kauf mir einen Ko-fi",
|
||||
"aboutEnsemble": "Über Ensemble",
|
||||
"version": "Version",
|
||||
"visitGitHubRepo": "GitHub-Repository besuchen",
|
||||
"reportABug": "Einen Fehler melden"
|
||||
}
|
||||
|
||||
@@ -221,6 +221,36 @@
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"discoveryMixes": "Discovery Mixes",
|
||||
"@discoveryMixes": {
|
||||
"description": "Provider discovery mixes (e.g., Tidal playlists)"
|
||||
},
|
||||
|
||||
"discoveryMixesDescription": "Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen",
|
||||
"@discoveryMixesDescription": {
|
||||
"description": "Description for discovery mixes setting"
|
||||
},
|
||||
|
||||
"discoveryRows": "Discovery rows from Music Assistant",
|
||||
"@discoveryRows": {
|
||||
"description": "Label for discovery rows section"
|
||||
},
|
||||
|
||||
"refreshDiscoveryRows": "Refresh available rows",
|
||||
"@refreshDiscoveryRows": {
|
||||
"description": "Button to refresh discovery rows"
|
||||
},
|
||||
|
||||
"noDiscoveryRows": "No discovery rows available",
|
||||
"@noDiscoveryRows": {
|
||||
"description": "Empty state when no discovery rows available"
|
||||
},
|
||||
|
||||
"discoveryRowBadge": "Music Assistant",
|
||||
"@discoveryRowBadge": {
|
||||
"description": "Badge label for discovery rows"
|
||||
},
|
||||
|
||||
"continueListening": "Continue Listening",
|
||||
"@continueListening": {
|
||||
"description": "Home screen section title"
|
||||
@@ -1683,5 +1713,60 @@
|
||||
"selectPlayerToAddToQueue": "Select player to add to queue",
|
||||
"@selectPlayerToAddToQueue": {
|
||||
"description": "Hint for selecting player to add to queue"
|
||||
},
|
||||
|
||||
"aboutAndSupport": "About & Support",
|
||||
"@aboutAndSupport": {
|
||||
"description": "About and support section title"
|
||||
},
|
||||
|
||||
"supportDevelopment": "Support Development",
|
||||
"@supportDevelopment": {
|
||||
"description": "Support development section"
|
||||
},
|
||||
|
||||
"ensembleIsUnofficial": "Ensemble is an unofficial, community-built client. Consider supporting its development.",
|
||||
"@ensembleIsUnofficial": {
|
||||
"description": "Description about Ensemble being unofficial"
|
||||
},
|
||||
|
||||
"sponsorOnGitHub": "Sponsor on GitHub",
|
||||
"@sponsorOnGitHub": {
|
||||
"description": "GitHub Sponsors button label"
|
||||
},
|
||||
|
||||
"buyMeAKoFi": "Buy me a Ko-fi",
|
||||
"@buyMeAKoFi": {
|
||||
"description": "Ko-fi donation button label"
|
||||
},
|
||||
|
||||
"aboutEnsemble": "About Ensemble",
|
||||
"@aboutEnsemble": {
|
||||
"description": "About Ensemble section"
|
||||
},
|
||||
|
||||
"version": "Version",
|
||||
"@version": {
|
||||
"description": "Version label"
|
||||
},
|
||||
|
||||
"visitGitHubRepo": "Visit GitHub Repository",
|
||||
"@visitGitHubRepo": {
|
||||
"description": "Link to GitHub repo"
|
||||
},
|
||||
|
||||
"reportABug": "Report a Bug",
|
||||
"@reportABug": {
|
||||
"description": "Link to report issues"
|
||||
},
|
||||
|
||||
"noRowsEnabled": "No rows enabled",
|
||||
"@noRowsEnabled": {
|
||||
"description": "Empty state when no home screen rows are enabled"
|
||||
},
|
||||
|
||||
"pullToRefreshHint": "Pull down to refresh or enable rows in settings",
|
||||
"@pullToRefreshHint": {
|
||||
"description": "Hint when home screen is empty"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,5 +491,15 @@
|
||||
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
|
||||
"pullToSelectPlayers": "Desliza para seleccionar dispositivos",
|
||||
"holdToSync": "Mantén presionado para sync",
|
||||
"swipeToAdjustVolume": "Desliza para ajustar volumen"
|
||||
"swipeToAdjustVolume": "Desliza para ajustar volumen",
|
||||
|
||||
"aboutAndSupport": "Acerca de y Soporte",
|
||||
"supportDevelopment": "Apoyar el Desarrollo",
|
||||
"ensembleIsUnofficial": "Ensemble es un cliente no oficial creado por la comunidad. Considera apoyar su desarrollo.",
|
||||
"sponsorOnGitHub": "Patrocina en GitHub",
|
||||
"buyMeAKoFi": "Cómprame un Ko-fi",
|
||||
"aboutEnsemble": "Acerca de Ensemble",
|
||||
"version": "Versión",
|
||||
"visitGitHubRepo": "Visitar Repositorio GitHub",
|
||||
"reportABug": "Reportar un Error"
|
||||
}
|
||||
|
||||
@@ -1396,5 +1396,15 @@
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"aboutAndSupport": "À propos et Support",
|
||||
"supportDevelopment": "Soutenir le Développement",
|
||||
"ensembleIsUnofficial": "Ensemble est un client non officiel créé par la communauté. Envisagez de soutenir son développement.",
|
||||
"sponsorOnGitHub": "Sponsoriser sur GitHub",
|
||||
"buyMeAKoFi": "Offrez-moi un Ko-fi",
|
||||
"aboutEnsemble": "À propos d'Ensemble",
|
||||
"version": "Version",
|
||||
"visitGitHubRepo": "Visiter le Dépôt GitHub",
|
||||
"reportABug": "Signaler un Bug"
|
||||
}
|
||||
|
||||
@@ -359,6 +359,42 @@ abstract class S {
|
||||
/// **'Discover Albums'**
|
||||
String get discoverAlbums;
|
||||
|
||||
/// Provider discovery mixes (e.g., Tidal playlists)
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Discovery Mixes'**
|
||||
String get discoveryMixes;
|
||||
|
||||
/// Description for discovery mixes setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen'**
|
||||
String get discoveryMixesDescription;
|
||||
|
||||
/// Label for discovery rows section
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Discovery rows from Music Assistant'**
|
||||
String get discoveryRows;
|
||||
|
||||
/// Button to refresh discovery rows
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Refresh available rows'**
|
||||
String get refreshDiscoveryRows;
|
||||
|
||||
/// Empty state when no discovery rows available
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No discovery rows available'**
|
||||
String get noDiscoveryRows;
|
||||
|
||||
/// Badge label for discovery rows
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Music Assistant'**
|
||||
String get discoveryRowBadge;
|
||||
|
||||
/// Home screen section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2182,6 +2218,72 @@ abstract class S {
|
||||
/// In en, this message translates to:
|
||||
/// **'Select player to add to queue'**
|
||||
String get selectPlayerToAddToQueue;
|
||||
|
||||
/// About and support section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'About & Support'**
|
||||
String get aboutAndSupport;
|
||||
|
||||
/// Support development section
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Support Development'**
|
||||
String get supportDevelopment;
|
||||
|
||||
/// Description about Ensemble being unofficial
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Ensemble is an unofficial, community-built client. Consider supporting its development.'**
|
||||
String get ensembleIsUnofficial;
|
||||
|
||||
/// GitHub Sponsors button label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sponsor on GitHub'**
|
||||
String get sponsorOnGitHub;
|
||||
|
||||
/// Ko-fi donation button label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Buy me a Ko-fi'**
|
||||
String get buyMeAKoFi;
|
||||
|
||||
/// About Ensemble section
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'About Ensemble'**
|
||||
String get aboutEnsemble;
|
||||
|
||||
/// Version label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Version'**
|
||||
String get version;
|
||||
|
||||
/// Link to GitHub repo
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Visit GitHub Repository'**
|
||||
String get visitGitHubRepo;
|
||||
|
||||
/// Link to report issues
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Report a Bug'**
|
||||
String get reportABug;
|
||||
|
||||
/// Empty state when no home screen rows are enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No rows enabled'**
|
||||
String get noRowsEnabled;
|
||||
|
||||
/// Hint when home screen is empty
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pull down to refresh or enable rows in settings'**
|
||||
String get pullToRefreshHint;
|
||||
}
|
||||
|
||||
class _SDelegate extends LocalizationsDelegate<S> {
|
||||
|
||||
@@ -151,6 +151,25 @@ class SDe extends S {
|
||||
@override
|
||||
String get discoverAlbums => 'Alben entdecken';
|
||||
|
||||
@override
|
||||
String get discoveryMixes => 'Discovery Mixes';
|
||||
|
||||
@override
|
||||
String get discoveryMixesDescription =>
|
||||
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
|
||||
|
||||
@override
|
||||
String get discoveryRows => 'Discovery rows from Music Assistant';
|
||||
|
||||
@override
|
||||
String get refreshDiscoveryRows => 'Refresh available rows';
|
||||
|
||||
@override
|
||||
String get noDiscoveryRows => 'No discovery rows available';
|
||||
|
||||
@override
|
||||
String get discoveryRowBadge => 'Music Assistant';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Weiterhoren';
|
||||
|
||||
@@ -1186,4 +1205,39 @@ class SDe extends S {
|
||||
|
||||
@override
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
|
||||
@override
|
||||
String get aboutAndSupport => 'Über & Unterstützung';
|
||||
|
||||
@override
|
||||
String get supportDevelopment => 'Entwicklung unterstützen';
|
||||
|
||||
@override
|
||||
String get ensembleIsUnofficial =>
|
||||
'Ensemble ist ein inoffizieller, von der Community erstellter Client. Erwäge, die Entwicklung zu unterstützen.';
|
||||
|
||||
@override
|
||||
String get sponsorOnGitHub => 'Auf GitHub sponsoren';
|
||||
|
||||
@override
|
||||
String get buyMeAKoFi => 'Kauf mir einen Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutEnsemble => 'Über Ensemble';
|
||||
|
||||
@override
|
||||
String get version => 'Version';
|
||||
|
||||
@override
|
||||
String get visitGitHubRepo => 'GitHub-Repository besuchen';
|
||||
|
||||
@override
|
||||
String get reportABug => 'Einen Fehler melden';
|
||||
|
||||
@override
|
||||
String get noRowsEnabled => 'No rows enabled';
|
||||
|
||||
@override
|
||||
String get pullToRefreshHint =>
|
||||
'Pull down to refresh or enable rows in settings';
|
||||
}
|
||||
|
||||
@@ -148,6 +148,25 @@ class SEn extends S {
|
||||
@override
|
||||
String get discoverAlbums => 'Discover Albums';
|
||||
|
||||
@override
|
||||
String get discoveryMixes => 'Discovery Mixes';
|
||||
|
||||
@override
|
||||
String get discoveryMixesDescription =>
|
||||
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
|
||||
|
||||
@override
|
||||
String get discoveryRows => 'Discovery rows from Music Assistant';
|
||||
|
||||
@override
|
||||
String get refreshDiscoveryRows => 'Refresh available rows';
|
||||
|
||||
@override
|
||||
String get noDiscoveryRows => 'No discovery rows available';
|
||||
|
||||
@override
|
||||
String get discoveryRowBadge => 'Music Assistant';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Continue Listening';
|
||||
|
||||
@@ -1177,4 +1196,39 @@ class SEn extends S {
|
||||
|
||||
@override
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
|
||||
@override
|
||||
String get aboutAndSupport => 'About & Support';
|
||||
|
||||
@override
|
||||
String get supportDevelopment => 'Support Development';
|
||||
|
||||
@override
|
||||
String get ensembleIsUnofficial =>
|
||||
'Ensemble is an unofficial, community-built client. Consider supporting its development.';
|
||||
|
||||
@override
|
||||
String get sponsorOnGitHub => 'Sponsor on GitHub';
|
||||
|
||||
@override
|
||||
String get buyMeAKoFi => 'Buy me a Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutEnsemble => 'About Ensemble';
|
||||
|
||||
@override
|
||||
String get version => 'Version';
|
||||
|
||||
@override
|
||||
String get visitGitHubRepo => 'Visit GitHub Repository';
|
||||
|
||||
@override
|
||||
String get reportABug => 'Report a Bug';
|
||||
|
||||
@override
|
||||
String get noRowsEnabled => 'No rows enabled';
|
||||
|
||||
@override
|
||||
String get pullToRefreshHint =>
|
||||
'Pull down to refresh or enable rows in settings';
|
||||
}
|
||||
|
||||
@@ -152,6 +152,25 @@ class SEs extends S {
|
||||
@override
|
||||
String get discoverAlbums => 'Descubrir álbumes';
|
||||
|
||||
@override
|
||||
String get discoveryMixes => 'Discovery Mixes';
|
||||
|
||||
@override
|
||||
String get discoveryMixesDescription =>
|
||||
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
|
||||
|
||||
@override
|
||||
String get discoveryRows => 'Discovery rows from Music Assistant';
|
||||
|
||||
@override
|
||||
String get refreshDiscoveryRows => 'Refresh available rows';
|
||||
|
||||
@override
|
||||
String get noDiscoveryRows => 'No discovery rows available';
|
||||
|
||||
@override
|
||||
String get discoveryRowBadge => 'Music Assistant';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Continuar escuchando';
|
||||
|
||||
@@ -1194,4 +1213,39 @@ class SEs extends S {
|
||||
|
||||
@override
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
|
||||
@override
|
||||
String get aboutAndSupport => 'Acerca de y Soporte';
|
||||
|
||||
@override
|
||||
String get supportDevelopment => 'Apoyar el Desarrollo';
|
||||
|
||||
@override
|
||||
String get ensembleIsUnofficial =>
|
||||
'Ensemble es un cliente no oficial creado por la comunidad. Considera apoyar su desarrollo.';
|
||||
|
||||
@override
|
||||
String get sponsorOnGitHub => 'Patrocina en GitHub';
|
||||
|
||||
@override
|
||||
String get buyMeAKoFi => 'Cómprame un Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutEnsemble => 'Acerca de Ensemble';
|
||||
|
||||
@override
|
||||
String get version => 'Versión';
|
||||
|
||||
@override
|
||||
String get visitGitHubRepo => 'Visitar Repositorio GitHub';
|
||||
|
||||
@override
|
||||
String get reportABug => 'Reportar un Error';
|
||||
|
||||
@override
|
||||
String get noRowsEnabled => 'No rows enabled';
|
||||
|
||||
@override
|
||||
String get pullToRefreshHint =>
|
||||
'Pull down to refresh or enable rows in settings';
|
||||
}
|
||||
|
||||
@@ -153,6 +153,25 @@ class SFr extends S {
|
||||
@override
|
||||
String get discoverAlbums => 'Découvrir des albums';
|
||||
|
||||
@override
|
||||
String get discoveryMixes => 'Discovery Mixes';
|
||||
|
||||
@override
|
||||
String get discoveryMixesDescription =>
|
||||
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
|
||||
|
||||
@override
|
||||
String get discoveryRows => 'Discovery rows from Music Assistant';
|
||||
|
||||
@override
|
||||
String get refreshDiscoveryRows => 'Refresh available rows';
|
||||
|
||||
@override
|
||||
String get noDiscoveryRows => 'No discovery rows available';
|
||||
|
||||
@override
|
||||
String get discoveryRowBadge => 'Music Assistant';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Continuer l\'écoute';
|
||||
|
||||
@@ -1203,4 +1222,39 @@ class SFr extends S {
|
||||
|
||||
@override
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
|
||||
@override
|
||||
String get aboutAndSupport => 'À propos et Support';
|
||||
|
||||
@override
|
||||
String get supportDevelopment => 'Soutenir le Développement';
|
||||
|
||||
@override
|
||||
String get ensembleIsUnofficial =>
|
||||
'Ensemble est un client non officiel créé par la communauté. Envisagez de soutenir son développement.';
|
||||
|
||||
@override
|
||||
String get sponsorOnGitHub => 'Sponsoriser sur GitHub';
|
||||
|
||||
@override
|
||||
String get buyMeAKoFi => 'Offrez-moi un Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutEnsemble => 'À propos d\'Ensemble';
|
||||
|
||||
@override
|
||||
String get version => 'Version';
|
||||
|
||||
@override
|
||||
String get visitGitHubRepo => 'Visiter le Dépôt GitHub';
|
||||
|
||||
@override
|
||||
String get reportABug => 'Signaler un Bug';
|
||||
|
||||
@override
|
||||
String get noRowsEnabled => 'No rows enabled';
|
||||
|
||||
@override
|
||||
String get pullToRefreshHint =>
|
||||
'Pull down to refresh or enable rows in settings';
|
||||
}
|
||||
|
||||
@@ -6,14 +6,25 @@ import 'package:provider/provider.dart';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
import 'providers/connection_provider.dart';
|
||||
import 'providers/library_provider.dart';
|
||||
import 'providers/locale_provider.dart';
|
||||
import 'providers/local_player_provider.dart';
|
||||
import 'providers/music_assistant_provider.dart';
|
||||
import 'providers/navigation_provider.dart';
|
||||
import 'providers/player_provider.dart';
|
||||
import 'providers/sleep_timer_provider.dart';
|
||||
import 'providers/ui_state_provider.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'services/cache_service.dart';
|
||||
import 'services/settings_service.dart';
|
||||
import 'services/database_service.dart';
|
||||
import 'services/image_helper_service.dart';
|
||||
import 'services/position_tracker.dart';
|
||||
import 'services/queue_manager_service.dart';
|
||||
import 'services/profile_service.dart';
|
||||
import 'services/provider_filter_service.dart';
|
||||
import 'services/sync_service.dart';
|
||||
import 'services/audio/massiv_audio_handler.dart';
|
||||
import 'services/auth/auth_manager.dart';
|
||||
@@ -23,6 +34,7 @@ import 'services/music_assistant_api.dart' show MAConnectionState;
|
||||
import 'theme/theme_provider.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'theme/system_theme_helper.dart';
|
||||
import 'utils/player_sync_state.dart';
|
||||
import 'widgets/global_player_overlay.dart';
|
||||
|
||||
// Global audio handler instance
|
||||
@@ -125,6 +137,21 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
late MusicAssistantProvider _musicProvider;
|
||||
late ThemeProvider _themeProvider;
|
||||
late LocaleProvider _localeProvider;
|
||||
|
||||
// New multi-provider architecture
|
||||
late ConnectionProvider _connectionProvider;
|
||||
late UIStateProvider _uiStateProvider;
|
||||
late LibraryProvider _libraryProvider;
|
||||
late PlayerProvider _playerProvider;
|
||||
late LocalPlayerProvider _localPlayerProvider;
|
||||
late SleepTimerProvider _sleepTimerProvider;
|
||||
late NavigationProvider _navigationProvider;
|
||||
|
||||
// Shared services
|
||||
late final CacheService _cacheService = CacheService();
|
||||
late final PositionTracker _positionTracker = PositionTracker();
|
||||
late ImageHelperService _imageHelper;
|
||||
|
||||
final _hardwareVolumeService = HardwareVolumeService();
|
||||
StreamSubscription? _volumeUpSub;
|
||||
StreamSubscription? _volumeDownSub;
|
||||
@@ -137,9 +164,91 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_musicProvider = MusicAssistantProvider();
|
||||
_themeProvider = ThemeProvider();
|
||||
_localeProvider = LocaleProvider();
|
||||
_navigationProvider = NavigationProvider();
|
||||
_sleepTimerProvider = SleepTimerProvider();
|
||||
|
||||
// Initialize connection provider first (others depend on it)
|
||||
_connectionProvider = ConnectionProvider();
|
||||
_uiStateProvider = UIStateProvider();
|
||||
|
||||
// Initialize shared image helper service
|
||||
_imageHelper = ImageHelperService(
|
||||
api: _connectionProvider.api,
|
||||
getCachedTrackForPlayer: (playerId) => _cacheService.getCachedTrackForPlayer(playerId),
|
||||
);
|
||||
|
||||
// Initialize library provider (needs API from connection)
|
||||
_libraryProvider = LibraryProvider(
|
||||
api: _connectionProvider.api,
|
||||
logger: DebugLogger(),
|
||||
cacheService: _cacheService,
|
||||
settings: null, // Will use static methods
|
||||
providerFilterService: ProviderFilterService(
|
||||
providerFilter: _connectionProvider.providerFilter,
|
||||
playerFilter: _connectionProvider.playerFilter,
|
||||
availableMusicProviders: [],
|
||||
artists: [],
|
||||
albums: [],
|
||||
tracks: [],
|
||||
podcasts: [],
|
||||
radioStations: [],
|
||||
),
|
||||
);
|
||||
|
||||
// Initialize local player provider
|
||||
_localPlayerProvider = LocalPlayerProvider(
|
||||
audioHandler: audioHandler,
|
||||
settings: null, // Will use static methods
|
||||
);
|
||||
|
||||
// Initialize player provider (depends on several others)
|
||||
// Note: queueManager will be set later to avoid circular dependency
|
||||
_playerProvider = PlayerProvider(
|
||||
api: _connectionProvider.api,
|
||||
logger: DebugLogger(),
|
||||
cacheService: _cacheService,
|
||||
settings: null, // Will use static methods
|
||||
positionTracker: _positionTracker,
|
||||
queueManager: null, // Will be set after initialization
|
||||
sleepTimerProvider: _sleepTimerProvider,
|
||||
imageHelper: _imageHelper,
|
||||
playerSyncState: PlayerSyncState(
|
||||
availablePlayers: [],
|
||||
castToSendspinIdMap: {},
|
||||
),
|
||||
audioHandler: audioHandler,
|
||||
);
|
||||
|
||||
// Create queue manager with functions that access provider state
|
||||
final queueManager = QueueManagerService(
|
||||
api: _connectionProvider.api,
|
||||
database: DatabaseService.instance,
|
||||
logger: DebugLogger(),
|
||||
getAvailablePlayers: () => _playerProvider.availablePlayers,
|
||||
getCastToSendspinMap: () => _playerProvider.castToSendspinIdMap,
|
||||
playTracks: (playerId, tracks, {int? startIndex, bool? clearQueue}) async {
|
||||
await _connectionProvider.api?.playTracks(playerId, tracks, startIndex: startIndex, clearQueue: clearQueue ?? true);
|
||||
},
|
||||
);
|
||||
|
||||
// Set the queue manager after creation (breaks circular dependency)
|
||||
_playerProvider.queueManager = queueManager;
|
||||
|
||||
// Initialize main provider (facade mode - sub-providers not yet wired)
|
||||
// Sub-providers have null API at construction time and no update mechanism,
|
||||
// so keep facade using its own internal logic until wiring is complete.
|
||||
_musicProvider = MusicAssistantProvider();
|
||||
|
||||
// Wire up player provider callbacks
|
||||
_playerProvider.onPlayerChanged = () {
|
||||
setState(() {}); // Trigger rebuild on player change
|
||||
};
|
||||
_playerProvider.onTrackChanged = () {
|
||||
setState(() {}); // Trigger rebuild on track change
|
||||
};
|
||||
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_initHardwareVolumeControl();
|
||||
// Listen to player selection changes to toggle volume interception
|
||||
@@ -252,9 +361,21 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
// Core facade provider (will be removed after migration)
|
||||
ChangeNotifierProvider.value(value: _musicProvider),
|
||||
|
||||
// Theme providers
|
||||
ChangeNotifierProvider.value(value: _themeProvider),
|
||||
ChangeNotifierProvider.value(value: _localeProvider),
|
||||
ChangeNotifierProvider.value(value: _navigationProvider),
|
||||
|
||||
// New multi-provider architecture (available for gradual migration)
|
||||
ChangeNotifierProvider.value(value: _connectionProvider),
|
||||
ChangeNotifierProvider.value(value: _uiStateProvider),
|
||||
ChangeNotifierProvider.value(value: _libraryProvider),
|
||||
ChangeNotifierProvider.value(value: _playerProvider),
|
||||
ChangeNotifierProvider.value(value: _localPlayerProvider),
|
||||
ChangeNotifierProvider.value(value: _sleepTimerProvider),
|
||||
],
|
||||
child: Consumer2<ThemeProvider, LocaleProvider>(
|
||||
builder: (context, themeProvider, localeProvider, _) {
|
||||
@@ -329,8 +450,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(),
|
||||
),
|
||||
@@ -450,94 +580,120 @@ class _AppStartupState extends State<AppStartup> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettingsFirst();
|
||||
try {
|
||||
_loadSettingsFirst();
|
||||
} catch (e) {
|
||||
_logger.error('AppStartup init error: $e');
|
||||
// Fallback - mark as loaded so app can continue
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_settingsLoaded = true;
|
||||
_hasCompletedOnboarding = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load settings BEFORE checking connection.
|
||||
/// This ensures we know if this is a first-time user before transitioning.
|
||||
Future<void> _loadSettingsFirst() async {
|
||||
// Load onboarding state first - this is critical for determining
|
||||
// when to transition to HomeScreen
|
||||
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
||||
try {
|
||||
// Load onboarding state first - this is critical for determining
|
||||
// when to transition to HomeScreen
|
||||
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
||||
|
||||
if (!mounted) return;
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_settingsLoaded = true;
|
||||
});
|
||||
setState(() {
|
||||
_settingsLoaded = true;
|
||||
});
|
||||
|
||||
_logger.log('🚀 AppStartup: Settings loaded, hasCompletedOnboarding=$_hasCompletedOnboarding');
|
||||
_logger.log('🚀 AppStartup: Settings loaded, hasCompletedOnboarding=$_hasCompletedOnboarding');
|
||||
} catch (e) {
|
||||
_logger.error('AppStartup settings load error: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_settingsLoaded = true;
|
||||
_hasCompletedOnboarding = true; // Default to true on error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Now check connection
|
||||
_checkAndConnect();
|
||||
}
|
||||
|
||||
Future<void> _checkAndConnect() async {
|
||||
final serverUrl = await SettingsService.getServerUrl();
|
||||
try {
|
||||
final serverUrl = await SettingsService.getServerUrl();
|
||||
|
||||
if (!mounted) return;
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_savedServerUrl = serverUrl;
|
||||
});
|
||||
|
||||
// If we have a saved server URL, attempt auto-connection
|
||||
if (serverUrl != null && serverUrl.isNotEmpty) {
|
||||
setState(() {
|
||||
_isConnecting = true;
|
||||
_savedServerUrl = serverUrl;
|
||||
});
|
||||
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
// If we have a saved server URL, attempt auto-connection
|
||||
if (serverUrl != null && serverUrl.isNotEmpty) {
|
||||
setState(() {
|
||||
_isConnecting = true;
|
||||
});
|
||||
|
||||
// Connection is handled by MusicAssistantProvider._initialize()
|
||||
// Just wait for it to complete or timeout
|
||||
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// For first-time users, we need to wait for both connection AND player selection
|
||||
// so the welcome overlay can appear immediately without home screen flash.
|
||||
// For returning users, just wait for connection.
|
||||
final needsWelcome = !_hasCompletedOnboarding;
|
||||
// Connection is handled by MusicAssistantProvider._initialize()
|
||||
// Just wait for it to complete or timeout
|
||||
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
|
||||
|
||||
// Give the provider time to connect (it restores credentials and connects in _initialize)
|
||||
// Check connection state periodically
|
||||
for (var i = 0; i < 20; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 250));
|
||||
// For first-time users, we need to wait for both connection AND player selection
|
||||
// so the welcome overlay can appear immediately without home screen flash.
|
||||
// For returning users, just wait for connection.
|
||||
final needsWelcome = !_hasCompletedOnboarding;
|
||||
|
||||
if (needsWelcome) {
|
||||
// First-time user: wait for connection + player selection
|
||||
if (provider.isConnected && provider.selectedPlayer != null) {
|
||||
_logger.log('🚀 AppStartup: First-time user ready (connected + player selected)');
|
||||
break;
|
||||
// Give the provider time to connect (it restores credentials and connects in _initialize)
|
||||
// Check connection state periodically
|
||||
for (var i = 0; i < 20; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 250));
|
||||
|
||||
if (needsWelcome) {
|
||||
// First-time user: wait for connection + player selection
|
||||
if (provider.isConnected && provider.selectedPlayer != null) {
|
||||
_logger.log('🚀 AppStartup: First-time user ready (connected + player selected)');
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Returning user: just wait for connection
|
||||
if (provider.isConnected) {
|
||||
_logger.log('🚀 AppStartup: Connection established');
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Returning user: just wait for connection
|
||||
if (provider.isConnected) {
|
||||
_logger.log('🚀 AppStartup: Connection established');
|
||||
|
||||
if (provider.connectionState == MAConnectionState.error) {
|
||||
_logger.log('🚀 AppStartup: Connection failed with error');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (provider.connectionState == MAConnectionState.error) {
|
||||
_logger.log('🚀 AppStartup: Connection failed with error');
|
||||
break;
|
||||
if (!provider.isConnected) {
|
||||
_logger.log('🚀 AppStartup: Connection still pending or failed');
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isConnecting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!provider.isConnected) {
|
||||
_logger.log('🚀 AppStartup: Connection still pending or failed');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
_logger.error('AppStartup connection error: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isConnecting = false;
|
||||
_connectionAttempted = true;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
_connectionAttempted = true;
|
||||
});
|
||||
}
|
||||
_connectionAttempted = true;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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,
|
||||
});
|
||||
|
||||
/// Unique ID for use in row ordering and settings
|
||||
/// Uses 'discovery:' prefix to distinguish from static row IDs
|
||||
String get rowId => 'discovery:$itemId';
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Polymorphic parser for media items in recommendation folders
|
||||
/// Handles heterogeneous items (playlists, radio, etc.)
|
||||
MediaItem _parseMediaItemJson(Map<String, dynamic> json) {
|
||||
final mediaType = json['media_type'] as String? ?? '';
|
||||
|
||||
switch (mediaType) {
|
||||
case 'playlist':
|
||||
return Playlist.fromJson(json);
|
||||
case 'album':
|
||||
return Album.fromJson(json);
|
||||
case 'artist':
|
||||
return Artist.fromJson(json);
|
||||
case 'track':
|
||||
return Track.fromJson(json);
|
||||
case 'audiobook':
|
||||
return Audiobook.fromJson(json);
|
||||
default:
|
||||
// For radio, podcast, podcast_episode - use MediaItem base class
|
||||
return MediaItem.fromJson(json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../services/cache_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/library_status_service.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/provider_filter_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/sync_service.dart';
|
||||
|
||||
/// Provider for library data management.
|
||||
///
|
||||
/// Manages:
|
||||
/// - Artists, albums, tracks lists
|
||||
/// - Radio stations and podcasts
|
||||
/// - Library loading and refresh
|
||||
/// - Provider filtering
|
||||
/// - Background sync
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to isolate library logic.
|
||||
class LibraryProvider extends ChangeNotifier {
|
||||
final MusicAssistantAPI? _api;
|
||||
final DebugLogger _logger;
|
||||
final CacheService _cacheService;
|
||||
final SettingsService? _settings;
|
||||
final ProviderFilterService _providerFilterService;
|
||||
|
||||
// Library data
|
||||
List<dynamic> _artists = [];
|
||||
List<dynamic> _albums = [];
|
||||
List<dynamic> _tracks = [];
|
||||
List<dynamic> _radioStations = [];
|
||||
List<dynamic> _podcasts = [];
|
||||
|
||||
// Getters
|
||||
List<dynamic> get artists => _artists;
|
||||
List<dynamic> get albums => _albums;
|
||||
List<dynamic> get tracks => _tracks;
|
||||
List<dynamic> get radioStations => _radioStations;
|
||||
List<dynamic> get podcasts => _podcasts;
|
||||
|
||||
// Expose typed lists for convenience
|
||||
List<Artist> get artistList => _artists.cast<Artist>();
|
||||
List<Album> get albumList => _albums.cast<Album>();
|
||||
List<Track> get trackList => _tracks.cast<Track>();
|
||||
|
||||
// Callback for when library status changes
|
||||
Function()? onLibraryChanged;
|
||||
|
||||
LibraryProvider({
|
||||
required MusicAssistantAPI? api,
|
||||
required DebugLogger logger,
|
||||
required CacheService cacheService,
|
||||
SettingsService? settings,
|
||||
required ProviderFilterService providerFilterService,
|
||||
}) : _api = api,
|
||||
_logger = logger,
|
||||
_cacheService = cacheService,
|
||||
_settings = settings,
|
||||
_providerFilterService = providerFilterService;
|
||||
|
||||
/// Load library data from API with caching
|
||||
Future<void> loadLibrary() async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
_logger.log('📚 Loading library from cache (instant)...');
|
||||
|
||||
// Load from database cache first (instant)
|
||||
final syncService = SyncService.instance;
|
||||
if (syncService.hasCache) {
|
||||
_albums = syncService.cachedAlbums;
|
||||
_artists = syncService.cachedArtists;
|
||||
_logger.log('📦 Loaded ${_albums.length} albums, ${_artists.length} artists from cache');
|
||||
_syncLibraryStatusToService();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Fetch tracks from API (not cached - too many items)
|
||||
try {
|
||||
_tracks = await _api!.getTracks(
|
||||
limit: LibraryConstants.maxLibraryItems,
|
||||
providerInstanceIds: _providerIdsForApiCalls(),
|
||||
);
|
||||
_logger.log('📥 Fetched ${_tracks.length} tracks from MA');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to fetch tracks: $e');
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
|
||||
// Trigger background sync (non-blocking)
|
||||
_syncLibraryInBackground();
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error loading library: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Load artists from library
|
||||
Future<void> loadArtists({int? limit, int? offset, String? search, String? orderBy}) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
final artists = await _api!.getArtists(
|
||||
limit: limit ?? LibraryConstants.maxLibraryItems,
|
||||
offset: offset,
|
||||
search: search,
|
||||
albumArtistsOnly: false,
|
||||
orderBy: orderBy,
|
||||
);
|
||||
|
||||
// Update SyncService cache
|
||||
SyncService.instance.updateCachedArtists(artists);
|
||||
|
||||
_artists = artists;
|
||||
_syncLibraryStatusToService();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to load artists: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Load albums from library
|
||||
Future<void> loadAlbums({int? limit, int? offset, String? search, String? artistId, String? orderBy}) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
final albums = await _api!.getAlbums(
|
||||
limit: limit ?? LibraryConstants.maxLibraryItems,
|
||||
offset: offset,
|
||||
search: search,
|
||||
artistId: artistId,
|
||||
orderBy: orderBy,
|
||||
);
|
||||
|
||||
// Update SyncService cache
|
||||
SyncService.instance.updateCachedAlbums(albums);
|
||||
|
||||
_albums = albums;
|
||||
_syncLibraryStatusToService();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to load albums: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Load radio stations
|
||||
Future<void> loadRadioStations({String? orderBy}) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
_radioStations = await _api!.getRadioStations(limit: 100, orderBy: orderBy);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to load radio stations: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Load podcasts
|
||||
Future<void> loadPodcasts({String? orderBy}) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
_podcasts = await _api!.getPodcasts(limit: 100, orderBy: orderBy);
|
||||
_logger.log('🎙️ Loaded ${_podcasts.length} podcasts');
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to load podcasts: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get playlists with provider filtering
|
||||
Future<List<Playlist>> getPlaylists({int? limit, bool? favoriteOnly, String? orderBy}) async {
|
||||
if (_api == null) return [];
|
||||
try {
|
||||
final playlists = await _api!.getPlaylists(limit: limit, favoriteOnly: favoriteOnly, orderBy: orderBy);
|
||||
return _providerFilterService.filterByProvider(playlists);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch playlists: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get audiobooks with provider filtering
|
||||
Future<List<Audiobook>> getAudiobooks({int? limit, bool? favoriteOnly}) async {
|
||||
if (_api == null) return [];
|
||||
try {
|
||||
final audiobooks = await _api!.getAudiobooks(limit: limit, favoriteOnly: favoriteOnly ?? false);
|
||||
return _providerFilterService.filterByProvider(audiobooks);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch audiobooks: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync library data in background without blocking UI
|
||||
void _syncLibraryInBackground() {
|
||||
if (_api == null) return;
|
||||
|
||||
final syncService = SyncService.instance;
|
||||
|
||||
// Listen for sync completion to update our lists
|
||||
void onSyncComplete() {
|
||||
if (syncService.status == SyncStatus.completed) {
|
||||
_albums = syncService.cachedAlbums;
|
||||
_artists = syncService.cachedArtists;
|
||||
_logger.log('🔄 Updated library from background sync: ${_albums.length} albums, ${_artists.length} artists');
|
||||
_syncLibraryStatusToService();
|
||||
notifyListeners();
|
||||
onLibraryChanged?.call();
|
||||
}
|
||||
syncService.removeListener(onSyncComplete);
|
||||
}
|
||||
|
||||
syncService.addListener(onSyncComplete);
|
||||
syncService.syncFromApi(_api!, providerInstanceIds: _providerIdsForApiCalls());
|
||||
}
|
||||
|
||||
/// Sync library status to centralized service for reactive UI updates
|
||||
void _syncLibraryStatusToService() {
|
||||
final service = LibraryStatusService.instance;
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get provider instance IDs for API calls (respects user filter)
|
||||
List<String> _providerIdsForApiCalls() {
|
||||
// For now, always return empty list to use all available providers
|
||||
// Provider filtering can be re-enabled later with async approach
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Update the API reference
|
||||
void setApi(MusicAssistantAPI? api) {
|
||||
if (_api != api) {
|
||||
// Note: We don't dispose the old API here as it's managed by ConnectionProvider
|
||||
// This is just updating the reference
|
||||
}
|
||||
}
|
||||
|
||||
/// Load library from cache for instant display
|
||||
Future<void> loadFromCache() async {
|
||||
try {
|
||||
final syncService = SyncService.instance;
|
||||
|
||||
if (!syncService.hasCache) {
|
||||
await syncService.loadFromCache();
|
||||
}
|
||||
|
||||
if (syncService.hasCache) {
|
||||
_albums = syncService.cachedAlbums;
|
||||
_artists = syncService.cachedArtists;
|
||||
_logger.log('📦 Pre-loaded library: ${_albums.length} albums, ${_artists.length} artists');
|
||||
_syncLibraryStatusToService();
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error loading library from cache: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:audio_service/audio_service.dart' as audio_service;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../services/audio/massiv_audio_handler.dart';
|
||||
import '../services/auth/auth_manager.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/device_id_service.dart';
|
||||
import '../services/local_player_service.dart';
|
||||
import '../services/pcm_audio_player.dart';
|
||||
import '../services/sendspin_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
|
||||
/// Provider for local audio playback and Sendspin integration.
|
||||
///
|
||||
/// Manages:
|
||||
/// - LocalPlayerService for audio playback
|
||||
/// - SendspinService integration (MA 2.7.0b20+)
|
||||
/// - PcmAudioPlayer for raw audio streaming
|
||||
/// - Player registration with Music Assistant
|
||||
/// - Volume and power control
|
||||
/// - Event handling from Sendspin
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to isolate local player logic.
|
||||
class LocalPlayerProvider extends ChangeNotifier {
|
||||
final AuthManager _authManager;
|
||||
final DebugLogger _logger;
|
||||
final SettingsService? _settings;
|
||||
|
||||
// Local player service
|
||||
late final LocalPlayerService _localPlayer;
|
||||
|
||||
// Sendspin service (MA 2.7.0b20+ replacement for builtin_player)
|
||||
SendspinService? _sendspinService;
|
||||
bool _sendspinConnected = false;
|
||||
|
||||
// PCM audio player for raw Sendspin audio streaming
|
||||
PcmAudioPlayer? _pcmAudioPlayer;
|
||||
|
||||
// Local player state
|
||||
bool _isLocalPlayerPowered = true;
|
||||
int _localPlayerVolume = VolumeConstants.max;
|
||||
bool _builtinPlayerAvailable = true;
|
||||
|
||||
// State reporting timer
|
||||
Timer? _localPlayerStateReportTimer;
|
||||
|
||||
// Audio handler (from audio_service package)
|
||||
final MassivAudioHandler _audioHandler;
|
||||
|
||||
// Server info
|
||||
String? _serverUrl;
|
||||
|
||||
// Callbacks
|
||||
Function()? onSendspinConnected;
|
||||
Function()? onSendspinDisconnected;
|
||||
Function(Map<String, dynamic>)? onLocalPlayerEvent;
|
||||
|
||||
LocalPlayerProvider({
|
||||
AuthManager? authManager,
|
||||
DebugLogger? logger,
|
||||
SettingsService? settings,
|
||||
required MassivAudioHandler audioHandler,
|
||||
}) : _authManager = authManager ?? AuthManager(),
|
||||
_logger = logger ?? DebugLogger(),
|
||||
_settings = settings,
|
||||
_audioHandler = audioHandler {
|
||||
_localPlayer = LocalPlayerService(_authManager);
|
||||
}
|
||||
|
||||
// Getters
|
||||
bool get isLocalPlayerPowered => _isLocalPlayerPowered;
|
||||
int get localPlayerVolume => _localPlayerVolume;
|
||||
bool get sendspinConnected => _sendspinConnected;
|
||||
bool get builtinPlayerAvailable => _builtinPlayerAvailable;
|
||||
bool get isPcmPlaying => _sendspinConnected && _pcmAudioPlayer != null && _pcmAudioPlayer!.isPlaying;
|
||||
LocalPlayerService get localPlayer => _localPlayer;
|
||||
SendspinService? get sendspinService => _sendspinService;
|
||||
PcmAudioPlayer? get pcmAudioPlayer => _pcmAudioPlayer;
|
||||
|
||||
// ============================================================================
|
||||
// INITIALIZATION
|
||||
// ============================================================================
|
||||
|
||||
/// Initialize local playback engine
|
||||
Future<void> initialize() async {
|
||||
await _localPlayer.initialize();
|
||||
_isLocalPlayerPowered = true;
|
||||
|
||||
// Wire up notification button callbacks
|
||||
_wireNotificationCallbacks();
|
||||
|
||||
_logger.log('🔊 Local player initialized');
|
||||
}
|
||||
|
||||
/// Register the built-in/local player with Music Assistant
|
||||
Future<String?> registerLocalPlayer() async {
|
||||
if (_serverUrl == null) {
|
||||
_logger.log('⚠️ Cannot register player: no server URL');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final deviceId = await DeviceIdService.getOrCreateDevicePlayerId();
|
||||
final ownerName = await SettingsService.getOwnerName() ?? 'Ensemble User';
|
||||
|
||||
_logger.log('📱 Registering local player: $deviceId');
|
||||
|
||||
// Check if server uses Sendspin (MA 2.7.0b20+)
|
||||
if (_serverUsesSendspin()) {
|
||||
return await _registerSendspinPlayer(deviceId, ownerName);
|
||||
} else {
|
||||
return await _registerBuiltinPlayer(deviceId, ownerName);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to register local player: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if server version is >= 2.7.0b20 (uses Sendspin)
|
||||
bool _serverUsesSendspin() {
|
||||
// This would check the server version from API
|
||||
// For now, we'll try both methods
|
||||
return true; // Default to Sendspin for modern MA
|
||||
}
|
||||
|
||||
/// Register using Sendspin protocol (MA 2.7.0b20+)
|
||||
Future<String?> _registerSendspinPlayer(String deviceId, String ownerName) async {
|
||||
_logger.log('📡 Registering via Sendspin protocol...');
|
||||
|
||||
try {
|
||||
// Initialize Sendspin service
|
||||
_sendspinService = SendspinService(_serverUrl!);
|
||||
_builtinPlayerAvailable = false;
|
||||
|
||||
// Initialize PCM audio player
|
||||
_pcmAudioPlayer = PcmAudioPlayer();
|
||||
final pcmInitialized = await _pcmAudioPlayer!.initialize();
|
||||
if (!pcmInitialized) {
|
||||
_logger.log('⚠️ Failed to initialize PCM audio player');
|
||||
}
|
||||
|
||||
// Connect Sendspin events
|
||||
_wireSendspinEvents();
|
||||
|
||||
// Try direct connection first
|
||||
final localSendspinUrl = '$_serverUrl/sendspin/$deviceId';
|
||||
final connected = await _sendspinService!.connectWithUrl(localSendspinUrl);
|
||||
|
||||
if (!connected) {
|
||||
// Try proxy connection
|
||||
final proxyUrl = '$_serverUrl/sendspin/proxy/$deviceId';
|
||||
final proxyConnected = await _sendspinService!.connectWithUrl(proxyUrl, useProxyAuth: true);
|
||||
if (!proxyConnected) {
|
||||
_logger.log('⚠️ Sendspin connection failed, will retry on connect');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Connect PCM audio player to Sendspin audio stream
|
||||
if (_pcmAudioPlayer != null && pcmInitialized) {
|
||||
await _pcmAudioPlayer!.connectToStream(_sendspinService!.audioDataStream);
|
||||
}
|
||||
|
||||
_sendspinConnected = true;
|
||||
_startStateReporting();
|
||||
onSendspinConnected?.call();
|
||||
|
||||
_logger.log('✅ Sendspin player registered: $deviceId');
|
||||
return deviceId;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Sendspin registration failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Register using legacy builtin_player protocol
|
||||
Future<String?> _registerBuiltinPlayer(String deviceId, String ownerName) async {
|
||||
_logger.log('📻 Registering via builtin_player protocol...');
|
||||
// Legacy registration would go here
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SENDSPIN EVENT HANDLERS
|
||||
// ============================================================================
|
||||
|
||||
void _wireSendspinEvents() {
|
||||
if (_sendspinService == null) return;
|
||||
|
||||
_sendspinService!.onPlay = _handleSendspinPlay;
|
||||
_sendspinService!.onPause = _handleSendspinPause;
|
||||
_sendspinService!.onStop = _handleSendspinStop;
|
||||
_sendspinService!.onSeek = _handleSendspinSeek;
|
||||
_sendspinService!.onVolume = _handleSendspinVolume;
|
||||
_sendspinService!.onStreamStart = _handleSendspinStreamStart;
|
||||
_sendspinService!.onStreamEnd = _handleSendspinStreamEnd;
|
||||
}
|
||||
|
||||
void _handleSendspinPlay(String streamUrl, Map<String, dynamic> trackInfo) {
|
||||
_logger.log('📡 Sendspin: Play $streamUrl');
|
||||
_pcmAudioPlayer?.play();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleSendspinPause() {
|
||||
_logger.log('📡 Sendspin: Pause');
|
||||
_pcmAudioPlayer?.pause();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleSendspinStop() {
|
||||
_logger.log('📡 Sendspin: Stop');
|
||||
_pcmAudioPlayer?.stop();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleSendspinSeek(int positionSeconds) {
|
||||
_logger.log('📡 Sendspin: Seek to $positionSeconds');
|
||||
_pcmAudioPlayer?.resetPosition();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleSendspinVolume(int volumeLevel) {
|
||||
_logger.log('📡 Sendspin: Set volume to $volumeLevel');
|
||||
_localPlayerVolume = volumeLevel;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleSendspinStreamStart(Map<String, dynamic>? trackInfo) {
|
||||
_logger.log('📡 Sendspin: Stream start');
|
||||
_pcmAudioPlayer?.play();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleSendspinStreamEnd() {
|
||||
_logger.log('📡 Sendspin: Stream end');
|
||||
_pcmAudioPlayer?.stop();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LOCAL PLAYER CONTROLS
|
||||
// ============================================================================
|
||||
|
||||
Future<void> setPower(bool powered) async {
|
||||
_isLocalPlayerPowered = powered;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setVolume(int volumeLevel) async {
|
||||
_localPlayerVolume = volumeLevel;
|
||||
_localPlayer.setVolume(volumeLevel / 100);
|
||||
|
||||
// Report to Sendspin if connected
|
||||
if (_sendspinConnected && _sendspinService != null) {
|
||||
_sendspinService!.reportState(volume: volumeLevel);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> playUrl(String url) async {
|
||||
await _localPlayer.playUrl(url);
|
||||
|
||||
// Report to Sendspin if connected
|
||||
if (_sendspinConnected && _sendspinService != null) {
|
||||
_sendspinService!.reportState(playing: true, paused: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pause() async {
|
||||
await _localPlayer.pause();
|
||||
|
||||
if (_sendspinConnected && _sendspinService != null) {
|
||||
_sendspinService!.reportState(playing: false, paused: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await _localPlayer.stop();
|
||||
|
||||
if (_sendspinConnected && _sendspinService != null) {
|
||||
_sendspinService!.reportState(playing: false, paused: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> seek(Duration position) async {
|
||||
await _localPlayer.seek(position);
|
||||
|
||||
if (_sendspinConnected && _sendspinService != null) {
|
||||
_sendspinService!.reportState(position: position.inSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NOTIFICATION CALLBACKS
|
||||
// ============================================================================
|
||||
|
||||
void _wireNotificationCallbacks() {
|
||||
_audioHandler.onPlay = () {
|
||||
_logger.log('🎵 Notification: Play pressed');
|
||||
// This would be handled by PlayerProvider
|
||||
};
|
||||
_audioHandler.onPause = () {
|
||||
_logger.log('🎵 Notification: Pause pressed');
|
||||
// This would be handled by PlayerProvider
|
||||
};
|
||||
_audioHandler.onSkipToNext = () {
|
||||
_logger.log('🎵 Notification: Skip to next pressed');
|
||||
// This would be handled by PlayerProvider
|
||||
};
|
||||
_audioHandler.onSkipToPrevious = () {
|
||||
_logger.log('🎵 Notification: Skip to previous pressed');
|
||||
// This would be handled by PlayerProvider
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STATE REPORTING
|
||||
// ============================================================================
|
||||
|
||||
void _startStateReporting() {
|
||||
_localPlayerStateReportTimer?.cancel();
|
||||
_localPlayerStateReportTimer = Timer.periodic(
|
||||
Timings.localPlayerReportInterval,
|
||||
(_) => _reportState(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _reportState() async {
|
||||
if (!_sendspinConnected || _sendspinService == null) return;
|
||||
|
||||
final isPlaying = _localPlayer.isPlaying;
|
||||
final position = _localPlayer.position.inSeconds;
|
||||
final volume = _localPlayerVolume;
|
||||
final muted = _localPlayerVolume == 0;
|
||||
|
||||
_sendspinService!.reportState(
|
||||
playing: isPlaying,
|
||||
paused: !isPlaying,
|
||||
position: position,
|
||||
volume: volume,
|
||||
muted: muted,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SERVER URL MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
void setServerUrl(String? serverUrl) {
|
||||
_serverUrl = serverUrl;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLEANUP
|
||||
// ============================================================================
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_localPlayerStateReportTimer?.cancel();
|
||||
_pcmAudioPlayer?.dispose();
|
||||
_sendspinService?.dispose();
|
||||
_localPlayer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:audio_service/audio_service.dart' as audio_service;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../providers/sleep_timer_provider.dart';
|
||||
import '../services/audio/massiv_audio_handler.dart';
|
||||
import '../services/cache_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/image_helper_service.dart';
|
||||
import '../services/local_player_service.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/position_tracker.dart';
|
||||
import '../services/queue_manager_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../utils/player_sort_utility.dart';
|
||||
import '../utils/player_sync_state.dart';
|
||||
|
||||
/// Provider for player management and playback controls.
|
||||
///
|
||||
/// Manages:
|
||||
/// - Player selection and available players list
|
||||
/// - Current playback state and track
|
||||
/// - Play/pause/stop/next/previous/seek controls
|
||||
/// - Queue operations
|
||||
/// - Position tracking
|
||||
/// - Sleep timer integration
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to isolate player logic.
|
||||
class PlayerProvider extends ChangeNotifier {
|
||||
final MusicAssistantAPI? _api;
|
||||
final DebugLogger _logger;
|
||||
final CacheService _cacheService;
|
||||
final SettingsService? _settings;
|
||||
final PositionTracker _positionTracker;
|
||||
QueueManagerService? _queueManager;
|
||||
final SleepTimerProvider _sleepTimerProvider;
|
||||
final ImageHelperService _imageHelper;
|
||||
final PlayerSyncState _playerSyncState;
|
||||
|
||||
// Player state
|
||||
Player? _selectedPlayer;
|
||||
List<Player> _availablePlayers = [];
|
||||
Map<String, String> _castToSendspinIdMap = {};
|
||||
bool _selectPlayerInProgress = false;
|
||||
|
||||
// Current playback
|
||||
Track? _currentTrack;
|
||||
Audiobook? _currentAudiobook;
|
||||
String? _currentPodcastName;
|
||||
TrackMetadata? _pendingTrackMetadata;
|
||||
TrackMetadata? _currentNotificationMetadata;
|
||||
|
||||
// Timers
|
||||
Timer? _playerStateTimer;
|
||||
Timer? _notificationPositionTimer;
|
||||
Timer? _idleServiceTimer;
|
||||
|
||||
// Audio handler (MassivAudioHandler for custom notification methods)
|
||||
final MassivAudioHandler _audioHandler;
|
||||
|
||||
// Callbacks for coordination
|
||||
Function()? onPlayerChanged;
|
||||
Function()? onTrackChanged;
|
||||
Function()? onAudiobookChanged;
|
||||
|
||||
PlayerProvider({
|
||||
required MusicAssistantAPI? api,
|
||||
required DebugLogger logger,
|
||||
required CacheService cacheService,
|
||||
SettingsService? settings,
|
||||
required PositionTracker positionTracker,
|
||||
QueueManagerService? queueManager,
|
||||
required SleepTimerProvider sleepTimerProvider,
|
||||
required ImageHelperService imageHelper,
|
||||
required PlayerSyncState playerSyncState,
|
||||
required MassivAudioHandler audioHandler,
|
||||
}) : _api = api,
|
||||
_logger = logger,
|
||||
_cacheService = cacheService,
|
||||
_settings = settings,
|
||||
_positionTracker = positionTracker,
|
||||
_queueManager = queueManager,
|
||||
_sleepTimerProvider = sleepTimerProvider,
|
||||
_imageHelper = imageHelper,
|
||||
_playerSyncState = playerSyncState,
|
||||
_audioHandler = audioHandler {
|
||||
// Connect position tracker to sleep timer
|
||||
_positionTracker.positionStream.listen((position) {
|
||||
// Notify listeners for position updates
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
// Set up sleep timer expiration callback
|
||||
_sleepTimerProvider.onExpired = () {
|
||||
pausePlayer(_selectedPlayer?.playerId ?? '');
|
||||
};
|
||||
}
|
||||
|
||||
// Getters
|
||||
Player? get selectedPlayer => _selectedPlayer;
|
||||
List<Player> get availablePlayers => _availablePlayers;
|
||||
Track? get currentTrack => _currentTrack;
|
||||
Audiobook? get currentAudiobook => _currentAudiobook;
|
||||
String? get currentPodcastName => _currentPodcastName;
|
||||
bool get selectPlayerInProgress => _selectPlayerInProgress;
|
||||
Map<String, String> get castToSendspinIdMap => _castToSendspinIdMap;
|
||||
|
||||
/// Set the queue manager (can be set after initialization to avoid circular deps)
|
||||
set queueManager(QueueManagerService? manager) {
|
||||
_queueManager = manager;
|
||||
}
|
||||
|
||||
bool get isPlaying => _selectedPlayer?.state == 'playing';
|
||||
bool get isPaused => _selectedPlayer?.state == 'paused';
|
||||
bool get isStopped => _selectedPlayer?.state == 'idle' ||
|
||||
_selectedPlayer?.state == 'off' ||
|
||||
_selectedPlayer == null;
|
||||
|
||||
String? get currentArtworkUrl {
|
||||
if (_currentTrack != null) {
|
||||
return _imageHelper.getImageUrl(_currentTrack!, size: ImageSizes.highRes);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAYER LOADING AND SELECTION
|
||||
// ============================================================================
|
||||
|
||||
/// Load players and auto-select the best one
|
||||
Future<void> loadAndSelectPlayers({bool forceRefresh = false, bool coldStart = false}) async {
|
||||
try {
|
||||
// Check cache validity
|
||||
if (!forceRefresh &&
|
||||
!coldStart &&
|
||||
_cacheService.isPlayersCacheValid() &&
|
||||
_availablePlayers.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load Cast-to-Sendspin mappings
|
||||
await _loadCastToSendspinMappings();
|
||||
|
||||
final allPlayers = await getPlayers();
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
|
||||
_logger.log('🎛️ getPlayers returned ${allPlayers.length} players');
|
||||
|
||||
// Filter players
|
||||
_availablePlayers = _filterPlayers(allPlayers, builtinPlayerId);
|
||||
|
||||
// Handle Cast/Sendspin player switching
|
||||
await _handleSendspinPlayers();
|
||||
|
||||
// Sort players
|
||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||
_sortPlayersSync(smartSort, builtinPlayerId);
|
||||
|
||||
// Auto-select player if none selected or on cold start
|
||||
if (_selectedPlayer == null || coldStart) {
|
||||
final playerToSelect = _selectBestPlayer(builtinPlayerId);
|
||||
if (playerToSelect != null) {
|
||||
await selectPlayer(playerToSelect);
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error loading players: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all players from API
|
||||
Future<List<Player>> getPlayers() async {
|
||||
return await _api?.getPlayers() ?? [];
|
||||
}
|
||||
|
||||
/// Select a player as the active player
|
||||
Future<void> selectPlayer(Player player, {bool skipNotify = false}) async {
|
||||
if (_selectPlayerInProgress) {
|
||||
_logger.log('⚠️ selectPlayer already in progress, skipping');
|
||||
return;
|
||||
}
|
||||
_selectPlayerInProgress = true;
|
||||
|
||||
try {
|
||||
_selectedPlayer = player;
|
||||
|
||||
// Cache selection
|
||||
_cacheService.setCachedSelectedPlayer(player);
|
||||
await SettingsService.setLastSelectedPlayerId(player.playerId);
|
||||
|
||||
// Set current track from cache for instant display
|
||||
_currentTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
|
||||
|
||||
// Initialize position tracker
|
||||
_positionTracker.onPlayerSelected(player.playerId);
|
||||
if (!player.isExternalSource) {
|
||||
_positionTracker.updateFromServer(
|
||||
playerId: player.playerId,
|
||||
position: player.elapsedTime ?? 0.0,
|
||||
isPlaying: player.state == 'playing',
|
||||
duration: _currentTrack?.duration,
|
||||
serverTimestamp: player.elapsedTimeLastUpdated,
|
||||
);
|
||||
}
|
||||
|
||||
// Update audio handler notification
|
||||
await _updateNotificationForPlayer(player);
|
||||
|
||||
// Start player state polling
|
||||
_startPlayerStatePolling();
|
||||
_manageNotificationPositionTimer();
|
||||
|
||||
if (!skipNotify) {
|
||||
notifyListeners();
|
||||
onPlayerChanged?.call();
|
||||
}
|
||||
} finally {
|
||||
_selectPlayerInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update audio handler notification for the selected player
|
||||
Future<void> _updateNotificationForPlayer(Player player) async {
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
final isBuiltinPlayer = builtinPlayerId != null && player.playerId == builtinPlayerId;
|
||||
|
||||
if (isBuiltinPlayer) {
|
||||
_audioHandler.setLocalMode();
|
||||
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
|
||||
final track = _currentTrack!;
|
||||
final artworkUrl = _imageHelper.getImageUrl(track, size: ImageSizes.highRes);
|
||||
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||
? '${track.artistsString} • ${player.name}'
|
||||
: player.name;
|
||||
final mediaItem = audio_service.MediaItem(
|
||||
id: track.uri ?? track.itemId,
|
||||
title: track.name,
|
||||
artist: artistWithPlayer,
|
||||
album: track.album?.name ?? '',
|
||||
duration: track.duration,
|
||||
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
|
||||
);
|
||||
_audioHandler.updateLocalModeNotification(
|
||||
item: mediaItem,
|
||||
playing: player.state == 'playing',
|
||||
duration: track.duration,
|
||||
);
|
||||
} else if (player.state == 'playing' || player.state == 'paused') {
|
||||
final mediaItem = audio_service.MediaItem(
|
||||
id: 'player_${player.playerId}',
|
||||
title: player.name,
|
||||
artist: 'Loading...',
|
||||
);
|
||||
_audioHandler.updateLocalModeNotification(
|
||||
item: mediaItem,
|
||||
playing: player.state == 'playing',
|
||||
);
|
||||
} else {
|
||||
_audioHandler.clearRemotePlaybackState();
|
||||
_startIdleServiceTimer();
|
||||
}
|
||||
} else {
|
||||
// Remote player
|
||||
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
|
||||
final track = _currentTrack!;
|
||||
final artworkUrl = _imageHelper.getImageUrl(track, size: ImageSizes.highRes);
|
||||
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||
? '${track.artistsString} • ${player.name}'
|
||||
: player.name;
|
||||
final mediaItem = audio_service.MediaItem(
|
||||
id: track.uri ?? track.itemId,
|
||||
title: track.name,
|
||||
artist: artistWithPlayer,
|
||||
album: track.album?.name ?? '',
|
||||
duration: track.duration,
|
||||
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
|
||||
);
|
||||
final position = _positionTracker.currentPosition;
|
||||
_cancelIdleServiceTimer();
|
||||
_audioHandler.setRemotePlaybackState(
|
||||
item: mediaItem,
|
||||
playing: player.state == 'playing',
|
||||
position: position,
|
||||
duration: track.duration,
|
||||
);
|
||||
} else if (player.state == 'playing' || player.state == 'paused') {
|
||||
final mediaItem = audio_service.MediaItem(
|
||||
id: 'player_${player.playerId}',
|
||||
title: player.name,
|
||||
artist: 'Loading...',
|
||||
);
|
||||
final position = _positionTracker.currentPosition;
|
||||
_cancelIdleServiceTimer();
|
||||
_audioHandler.setRemotePlaybackState(
|
||||
item: mediaItem,
|
||||
playing: player.state == 'playing',
|
||||
position: position,
|
||||
duration: Duration.zero,
|
||||
);
|
||||
} else {
|
||||
_audioHandler.clearRemotePlaybackState();
|
||||
_startIdleServiceTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAYBACK CONTROLS
|
||||
// ============================================================================
|
||||
|
||||
Future<void> playPauseSelectedPlayer() async {
|
||||
if (_selectedPlayer == null) return;
|
||||
final playerId = _selectedPlayer!.playerId;
|
||||
if (_selectedPlayer?.state == 'playing') {
|
||||
await pausePlayer(playerId);
|
||||
} else {
|
||||
await playPlayer(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playPlayer(String playerId) async {
|
||||
try {
|
||||
await _api?.resumePlayer(playerId);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to play: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pausePlayer(String playerId) async {
|
||||
try {
|
||||
await _api?.pausePlayer(playerId);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to pause: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopPlayer(String playerId) async {
|
||||
try {
|
||||
await _api?.stopPlayer(playerId);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to stop: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> nextTrack(String playerId) async {
|
||||
try {
|
||||
await _api?.nextTrack(playerId);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to skip to next: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> previousTrack(String playerId) async {
|
||||
try {
|
||||
await _api?.previousTrack(playerId);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to go to previous: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> seek(String playerId, int positionSeconds) async {
|
||||
try {
|
||||
await _api?.seek(playerId, positionSeconds);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to seek: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> seekRelative(String playerId, int deltaSeconds) async {
|
||||
final currentPosition = _positionTracker.currentPosition.inSeconds;
|
||||
await seek(playerId, (currentPosition + deltaSeconds).clamp(0, double.infinity).toInt());
|
||||
}
|
||||
|
||||
Future<void> setVolume(String playerId, int volumeLevel) async {
|
||||
try {
|
||||
await _api?.setVolume(playerId, volumeLevel);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to set volume: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TRACK PLAYBACK
|
||||
// ============================================================================
|
||||
|
||||
Future<void> playTrack(String playerId, Track track, {bool clearQueue = true}) async {
|
||||
try {
|
||||
await _api?.playTrack(playerId, track, clearQueue: clearQueue);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to play track: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex, bool clearQueue = true}) async {
|
||||
try {
|
||||
await _api?.playTracks(playerId, tracks, startIndex: startIndex, clearQueue: clearQueue);
|
||||
|
||||
if (tracks.isNotEmpty && startIndex != null && startIndex < tracks.length) {
|
||||
_currentTrack = tracks[startIndex];
|
||||
} else if (tracks.isNotEmpty) {
|
||||
_currentTrack = tracks.first;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
onTrackChanged?.call();
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to play tracks: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// QUEUE MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
Future<PlayerQueue?> getQueue(String playerId) async {
|
||||
return _queueManager?.getQueue(playerId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POLLING AND STATE UPDATES
|
||||
// ============================================================================
|
||||
|
||||
void _startPlayerStatePolling() {
|
||||
_playerStateTimer?.cancel();
|
||||
_playerStateTimer = Timer.periodic(
|
||||
Timings.playerPollingInterval,
|
||||
(_) => _updatePlayerState(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _updatePlayerState() async {
|
||||
if (_selectedPlayer == null || _api == null) return;
|
||||
|
||||
try {
|
||||
final playerId = _selectedPlayer!.playerId;
|
||||
|
||||
// Get all players and find our selected one
|
||||
final allPlayers = await _api?.getPlayers();
|
||||
final updatedPlayer = allPlayers?.firstWhere(
|
||||
(p) => p.playerId == playerId,
|
||||
orElse: () => _selectedPlayer!,
|
||||
);
|
||||
|
||||
if (updatedPlayer == null) {
|
||||
_logger.log('⚠️ _updatePlayerState: Player not found: $playerId');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if player changed during fetch
|
||||
if (_selectedPlayer?.playerId != playerId) {
|
||||
_logger.log('⚠️ _updatePlayerState: Player changed during fetch, aborting');
|
||||
return;
|
||||
}
|
||||
|
||||
final previousState = _selectedPlayer!.state;
|
||||
_selectedPlayer = updatedPlayer;
|
||||
|
||||
// Update position tracker
|
||||
_positionTracker.updateFromServer(
|
||||
playerId: playerId,
|
||||
position: updatedPlayer.elapsedTime ?? 0.0,
|
||||
isPlaying: updatedPlayer.state == 'playing',
|
||||
duration: _currentTrack?.duration,
|
||||
serverTimestamp: updatedPlayer.elapsedTimeLastUpdated,
|
||||
);
|
||||
|
||||
// Update notification if state changed
|
||||
if (previousState != updatedPlayer.state) {
|
||||
await _updateNotificationForPlayer(updatedPlayer);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error updating player state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _manageNotificationPositionTimer() {
|
||||
_notificationPositionTimer?.cancel();
|
||||
|
||||
// Only for remote/Sendspin players
|
||||
// Note: We don't have a sync version, so we use a cached value or null
|
||||
final builtinPlayerId = _selectedPlayer?.playerId; // Simplified for now
|
||||
final isRemotePlayer = _selectedPlayer != null &&
|
||||
(builtinPlayerId == null || _selectedPlayer!.playerId != builtinPlayerId);
|
||||
|
||||
if (isRemotePlayer && (_selectedPlayer?.state == 'playing' || _selectedPlayer?.state == 'paused')) {
|
||||
_notificationPositionTimer = Timer.periodic(
|
||||
Timings.notificationPositionUpdate,
|
||||
(_) => _updateNotificationPosition(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateNotificationPosition() {
|
||||
if (_currentTrack == null || _selectedPlayer == null) return;
|
||||
|
||||
final position = _positionTracker.currentPosition;
|
||||
// Update notification position
|
||||
// (This would be handled by audio_handler internally)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SLEEP TIMER
|
||||
// ============================================================================
|
||||
|
||||
Future<void> setSleepTimer(int? minutes) async {
|
||||
_sleepTimerProvider.setTimer(minutes);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> cancelSleepTimer() async {
|
||||
_sleepTimerProvider.setTimer(null);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPER METHODS
|
||||
// ============================================================================
|
||||
|
||||
Future<void> _loadCastToSendspinMappings() async {
|
||||
if (_castToSendspinIdMap.isEmpty) {
|
||||
try {
|
||||
final persistedMappings = await DatabaseService.instance.getAllCastToSendspinMappings();
|
||||
_castToSendspinIdMap.addAll(persistedMappings);
|
||||
if (persistedMappings.isNotEmpty) {
|
||||
_logger.log('🔗 Loaded ${persistedMappings.length} Cast->Sendspin mappings from database');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to load Cast->Sendspin mappings: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<Player> _filterPlayers(List<Player> allPlayers, String? builtinPlayerId) {
|
||||
return PlayerSortUtility.filterPlayers(
|
||||
allPlayers,
|
||||
builtinPlayerId: builtinPlayerId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleSendspinPlayers() async {
|
||||
// Complex Sendspin/Cast player switching logic
|
||||
// This would need to be extracted fully
|
||||
// For now, we'll keep a simplified version
|
||||
}
|
||||
|
||||
void _sortPlayersSync(bool smartSort, String? builtinPlayerId) {
|
||||
PlayerSortUtility.sortPlayers(
|
||||
_availablePlayers,
|
||||
smartSort: smartSort,
|
||||
builtinPlayerId: builtinPlayerId,
|
||||
);
|
||||
}
|
||||
|
||||
Player? _selectBestPlayer(String? builtinPlayerId) {
|
||||
return PlayerSortUtility.selectBestPlayer(
|
||||
availablePlayers: _availablePlayers,
|
||||
lastSelectedPlayer: null, // Could load from settings
|
||||
lastActivePlayer: null,
|
||||
builtinPlayerId: builtinPlayerId,
|
||||
);
|
||||
}
|
||||
|
||||
void _startIdleServiceTimer() {
|
||||
_idleServiceTimer?.cancel();
|
||||
_idleServiceTimer = Timer(const Duration(minutes: 30), () {
|
||||
// After 30 min idle, stop position tracking
|
||||
_positionTracker.clear();
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelIdleServiceTimer() {
|
||||
_idleServiceTimer?.cancel();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLEANUP
|
||||
// ============================================================================
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_playerStateTimer?.cancel();
|
||||
_notificationPositionTimer?.cancel();
|
||||
_idleServiceTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class SleepTimerProvider with ChangeNotifier {
|
||||
DateTime? _sleepTimerEndTime;
|
||||
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
|
||||
Timer? _sleepTimerDisplayTimer;
|
||||
bool _disposed = false;
|
||||
|
||||
/// Callback to pause playback when timer expires
|
||||
OnSleepTimerExpired? onExpired;
|
||||
@@ -74,7 +75,9 @@ class SleepTimerProvider with ChangeNotifier {
|
||||
|
||||
// Start display update timer (every second for countdown)
|
||||
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
notifyListeners(); // Update UI with remaining time
|
||||
if (!_disposed) {
|
||||
notifyListeners(); // Update UI with remaining time
|
||||
}
|
||||
});
|
||||
|
||||
notifyListeners();
|
||||
@@ -123,6 +126,7 @@ class SleepTimerProvider with ChangeNotifier {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_cancelInternal();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../models/provider_instance.dart';
|
||||
|
||||
/// Provider for UI-only state that doesn't belong to any specific domain.
|
||||
///
|
||||
/// Manages:
|
||||
/// - Search state (query and results)
|
||||
/// - Provider and player filters (from MA user settings)
|
||||
/// - User-controlled provider enable/disable
|
||||
/// - Loading states
|
||||
/// - Home refresh counter
|
||||
///
|
||||
/// This state is UI-focused and doesn't involve business logic operations.
|
||||
class UIStateProvider extends ChangeNotifier {
|
||||
// Search state
|
||||
String _lastSearchQuery = '';
|
||||
Map<String, List<MediaItem>> _lastSearchResults = {
|
||||
'artists': [],
|
||||
'albums': [],
|
||||
'tracks': [],
|
||||
};
|
||||
|
||||
// Provider filter from MA user settings (empty = all providers allowed)
|
||||
List<String> _providerFilter = [];
|
||||
|
||||
// Player filter from MA user settings (empty = all players allowed)
|
||||
List<String> _playerFilter = [];
|
||||
|
||||
// User-controlled music provider filter (local settings in Ensemble)
|
||||
// Empty list means all providers are enabled (no filtering)
|
||||
List<String> _enabledProviderIds = [];
|
||||
|
||||
// Available music providers discovered from MA
|
||||
List<ProviderInstance> _availableMusicProviders = [];
|
||||
|
||||
// Loading states
|
||||
bool _isLoading = false;
|
||||
bool _isLoadingRadio = false;
|
||||
bool _isLoadingPodcasts = false;
|
||||
|
||||
// Home refresh counter - increments to signal home screen to refresh all rows
|
||||
int _homeRefreshCounter = 0;
|
||||
|
||||
// Getters
|
||||
String get lastSearchQuery => _lastSearchQuery;
|
||||
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
|
||||
List<String> get providerFilter => _providerFilter;
|
||||
List<String> get playerFilter => _playerFilter;
|
||||
List<String> get enabledProviderIds => _enabledProviderIds;
|
||||
List<ProviderInstance> get availableMusicProviders => _availableMusicProviders;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isLoadingRadio => _isLoadingRadio;
|
||||
bool get isLoadingPodcasts => _isLoadingPodcasts;
|
||||
int get homeRefreshCounter => _homeRefreshCounter;
|
||||
|
||||
/// Whether provider filtering is active
|
||||
bool get hasProviderFilter => _providerFilter.isNotEmpty;
|
||||
|
||||
/// Whether player filtering is active
|
||||
bool get hasPlayerFilter => _playerFilter.isNotEmpty;
|
||||
|
||||
/// Whether user provider filter is active
|
||||
bool get hasEnabledProviderFilter => _enabledProviderIds.isNotEmpty;
|
||||
|
||||
// Search state management
|
||||
void saveSearchState(String query, Map<String, List<MediaItem>> results) {
|
||||
_lastSearchQuery = query;
|
||||
_lastSearchResults = results;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearSearchState() {
|
||||
_lastSearchQuery = '';
|
||||
_lastSearchResults = {'artists': [], 'albums': [], 'tracks': []};
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Filter management
|
||||
void setProviderFilter(List<String> filter) {
|
||||
_providerFilter = filter;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setPlayerFilter(List<String> filter) {
|
||||
_playerFilter = filter;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setAvailableMusicProviders(List<ProviderInstance> providers) {
|
||||
_availableMusicProviders = providers;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setEnabledProviderIds(List<String> enabledIds) {
|
||||
_enabledProviderIds = enabledIds;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Toggle a specific music provider on/off
|
||||
void toggleProviderEnabled(String instanceId, bool enabled) {
|
||||
final allIds = _availableMusicProviders.map((p) => p.instanceId).toList();
|
||||
|
||||
// Don't allow disabling the last provider
|
||||
if (!enabled && _enabledProviderIds.isNotEmpty && _enabledProviderIds.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local state
|
||||
if (_enabledProviderIds.isEmpty && !enabled) {
|
||||
// Switch to explicit mode
|
||||
_enabledProviderIds = allIds.where((id) => id != instanceId).toList();
|
||||
} else if (enabled) {
|
||||
if (!_enabledProviderIds.contains(instanceId)) {
|
||||
_enabledProviderIds = [..._enabledProviderIds, instanceId];
|
||||
}
|
||||
} else {
|
||||
_enabledProviderIds = _enabledProviderIds.where((id) => id != instanceId).toList();
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Check if a provider should be visible based on both filters
|
||||
bool isProviderEnabled(String instanceId) {
|
||||
// Check MA user filter (server-side restriction)
|
||||
if (hasProviderFilter && !_providerFilter.contains(instanceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check user filter (local preference)
|
||||
if (hasEnabledProviderFilter && !_enabledProviderIds.contains(instanceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Check if a player should be visible based on MA user filter
|
||||
bool isPlayerAllowed(String playerId) {
|
||||
if (!hasPlayerFilter) return true;
|
||||
return _playerFilter.contains(playerId);
|
||||
}
|
||||
|
||||
// Loading state management
|
||||
void setLoading(bool loading) {
|
||||
_isLoading = loading;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setLoadingRadio(bool loading) {
|
||||
_isLoadingRadio = loading;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setLoadingPodcasts(bool loading) {
|
||||
_isLoadingPodcasts = loading;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void incrementHomeRefreshCounter() {
|
||||
_homeRefreshCounter++;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -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.initLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
|
||||
service.initFavoriteStatus(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(
|
||||
@@ -920,7 +935,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -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,
|
||||
@@ -1283,12 +1298,13 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
ListTile(
|
||||
leading: Icon(Icons.playlist_add, color: colorScheme.onSurface),
|
||||
title: Text(S.of(context)!.addToQueue, style: TextStyle(color: colorScheme.onSurface)),
|
||||
onTap: () {
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
// Add to queue on selected player
|
||||
final player = maProvider.selectedPlayer;
|
||||
if (player != null) {
|
||||
maProvider.addTracksToQueue(player.playerId, _tracks).then((_) {
|
||||
try {
|
||||
await maProvider.addTracksToQueue(player.playerId, _tracks);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -1297,7 +1313,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showError(S.of(context)!.failedToAddToQueue(e.toString()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_showError(S.of(context)!.noPlayerSelected);
|
||||
}
|
||||
|
||||
@@ -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.initLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
|
||||
service.initFavoriteStatus(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(
|
||||
@@ -870,9 +882,9 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
backgroundColor: colorScheme.background,
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive cover size: 70% of screen width, clamped between 160-280 (smaller for circular artist image)
|
||||
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
|
||||
final expandedHeight = coverSize + 100;
|
||||
// Responsive cover size: 50% of screen width, clamped between 140-200 (smaller for circular artist image)
|
||||
final coverSize = (constraints.maxWidth * 0.5).clamp(140.0, 200.0);
|
||||
final expandedHeight = coverSize + 80;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
@@ -930,7 +942,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -947,7 +959,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
Spacing.vGap16,
|
||||
Spacing.vGap8,
|
||||
if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[
|
||||
InkWell(
|
||||
onTap: () {
|
||||
@@ -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,
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../services/debug_logger.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'audiobook_detail_screen.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
|
||||
@@ -297,9 +298,9 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive cover size: 70% of screen width, clamped between 160-280 (same as artist detail)
|
||||
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
|
||||
final expandedHeight = coverSize + 100;
|
||||
// Responsive cover size: 50% of screen width, clamped between 140-200 (matches artist detail)
|
||||
final coverSize = (constraints.maxWidth * 0.5).clamp(140.0, 200.0);
|
||||
final expandedHeight = coverSize + 60;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
@@ -315,49 +316,6 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
},
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
actions: [
|
||||
// Three-dot menu with sort/view options
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
width: 40,
|
||||
child: Builder(
|
||||
builder: (buttonContext) => FilledButton.tonal(
|
||||
onPressed: () {
|
||||
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
|
||||
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: position,
|
||||
mediaType: ContextMenuMediaType.audiobook,
|
||||
item: _audiobooks.isNotEmpty ? _audiobooks.first : null,
|
||||
isFavorite: false,
|
||||
isInLibrary: false,
|
||||
adaptiveColorScheme: adaptiveScheme,
|
||||
showTopRow: false,
|
||||
sortOrder: _sortOrder,
|
||||
onToggleSort: _toggleSortOrder,
|
||||
viewMode: _viewMode,
|
||||
onCycleView: _cycleViewMode,
|
||||
);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.more_vert,
|
||||
size: 20,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -410,22 +368,68 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.authorName,
|
||||
style: textTheme.headlineMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
// Author info on the left
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.authorName,
|
||||
style: textTheme.headlineMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.audiobookCount(_audiobooks.length),
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.audiobookCount(_audiobooks.length),
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
// Three-dot Menu Button on the right
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: Builder(
|
||||
builder: (buttonContext) => FilledButton.tonal(
|
||||
onPressed: () {
|
||||
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
|
||||
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: position,
|
||||
mediaType: ContextMenuMediaType.audiobook,
|
||||
item: _audiobooks.isNotEmpty ? _audiobooks.first : null,
|
||||
isFavorite: false,
|
||||
isInLibrary: false,
|
||||
adaptiveColorScheme: adaptiveScheme,
|
||||
showTopRow: false,
|
||||
sortOrder: _sortOrder,
|
||||
onToggleSort: _toggleSortOrder,
|
||||
viewMode: _viewMode,
|
||||
onCycleView: _cycleViewMode,
|
||||
);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.more_vert,
|
||||
size: 25,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -652,7 +652,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
@@ -364,7 +364,6 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||
padding: EdgeInsets.only(bottom: BottomSpacing.navBarOnly),
|
||||
child: FloatingActionButton(
|
||||
onPressed: () {
|
||||
setState(() {});
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
|
||||
@@ -6,12 +6,52 @@ import '../widgets/album_card.dart';
|
||||
import '../widgets/common/empty_state.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class LibraryAlbumsScreen extends StatelessWidget {
|
||||
class LibraryAlbumsScreen extends StatefulWidget {
|
||||
const LibraryAlbumsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LibraryAlbumsScreen> createState() => _LibraryAlbumsScreenState();
|
||||
}
|
||||
|
||||
class _LibraryAlbumsScreenState extends State<LibraryAlbumsScreen> {
|
||||
List<Album> _albums = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
_albums = provider.albums;
|
||||
_isLoading = provider.isLoading;
|
||||
provider.addListener(_onProviderChanged);
|
||||
|
||||
// Auto-load library if empty
|
||||
if (_albums.isEmpty && !_isLoading) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
provider.loadLibrary();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
context.read<MusicAssistantProvider>().removeListener(_onProviderChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onProviderChanged() {
|
||||
if (!mounted) return;
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
setState(() {
|
||||
_albums = provider.albums;
|
||||
_isLoading = provider.isLoading;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Use Selector for targeted rebuilds - only rebuild when albums or loading state changes
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
@@ -34,13 +74,7 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Selector<MusicAssistantProvider, (List<Album>, bool)>(
|
||||
selector: (_, provider) => (provider.albums, provider.isLoading),
|
||||
builder: (context, data, _) {
|
||||
final (albums, isLoading) = data;
|
||||
return _buildAlbumsList(context, albums, isLoading);
|
||||
},
|
||||
),
|
||||
body: _buildAlbumsList(context, _albums, _isLoading),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,4 +127,3 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,52 @@ import '../widgets/artist_avatar.dart';
|
||||
import 'artist_details_screen.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class LibraryArtistsScreen extends StatelessWidget {
|
||||
class LibraryArtistsScreen extends StatefulWidget {
|
||||
const LibraryArtistsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LibraryArtistsScreen> createState() => _LibraryArtistsScreenState();
|
||||
}
|
||||
|
||||
class _LibraryArtistsScreenState extends State<LibraryArtistsScreen> {
|
||||
List<Artist> _artists = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
_artists = provider.artists;
|
||||
_isLoading = provider.isLoading;
|
||||
provider.addListener(_onProviderChanged);
|
||||
|
||||
// Auto-load library if empty
|
||||
if (_artists.isEmpty && !_isLoading) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
provider.loadLibrary();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
context.read<MusicAssistantProvider>().removeListener(_onProviderChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onProviderChanged() {
|
||||
if (!mounted) return;
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
setState(() {
|
||||
_artists = provider.artists;
|
||||
_isLoading = provider.isLoading;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Use Selector for targeted rebuilds - only rebuild when artists or loading state changes
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
@@ -36,13 +76,7 @@ class LibraryArtistsScreen extends StatelessWidget {
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Selector<MusicAssistantProvider, (List<Artist>, bool)>(
|
||||
selector: (_, provider) => (provider.artists, provider.isLoading),
|
||||
builder: (context, data, _) {
|
||||
final (artists, isLoading) = data;
|
||||
return _buildArtistsList(context, artists, isLoading);
|
||||
},
|
||||
),
|
||||
body: _buildArtistsList(context, _artists, _isLoading),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
@@ -16,6 +17,7 @@ import '../widgets/series_row.dart';
|
||||
import '../widgets/playlist_row.dart';
|
||||
import '../widgets/radio_station_row.dart';
|
||||
import '../widgets/podcast_row.dart';
|
||||
import '../widgets/discovery_row.dart';
|
||||
import '../widgets/common/disconnected_state.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
@@ -47,6 +49,11 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
bool _showDiscoverSeries = false;
|
||||
// Row order (loaded from settings)
|
||||
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
|
||||
// Discovery folders (dynamic rows from provider recommendations)
|
||||
List<RecommendationFolder> _discoveryFolders = [];
|
||||
bool _isLoadingDiscoveryFolders = false;
|
||||
// Discovery row preferences (itemId -> enabled)
|
||||
Map<String, bool> _discoveryRowEnabled = {};
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
@@ -56,6 +63,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
SyncStatus? _lastSyncStatus;
|
||||
int _lastArtistCount = 0;
|
||||
MusicAssistantProvider? _providerListeningTo;
|
||||
int _lastHomeRefreshCounter = 0; // Track home refresh counter to force row refreshes
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -66,12 +74,18 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
SyncService.instance.addListener(_onSyncChanged);
|
||||
_lastSyncStatus = SyncService.instance.status;
|
||||
_checkInitialFavoriteState();
|
||||
|
||||
// Always listen to provider for home refresh counter changes
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
_lastHomeRefreshCounter = provider.homeRefreshCounter;
|
||||
_providerListeningTo = provider;
|
||||
provider.addListener(_onProviderChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
SyncService.instance.removeListener(_onSyncChanged);
|
||||
// Clean up provider listener if attached
|
||||
// Clean up provider listener
|
||||
_providerListeningTo?.removeListener(_onProviderChanged);
|
||||
_providerListeningTo = null;
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
@@ -96,21 +110,35 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
});
|
||||
}
|
||||
|
||||
/// Called when MusicAssistantProvider changes (for cache load detection)
|
||||
/// Called when MusicAssistantProvider changes (for cache load detection and home refresh)
|
||||
void _onProviderChanged() {
|
||||
if (!mounted || !_hadEmptyFavoritesOnLoad || _providerListeningTo == null) return;
|
||||
final currentCount = _providerListeningTo!.artists.length;
|
||||
// If artists went from 0 to non-zero, refresh
|
||||
if (_lastArtistCount == 0 && currentCount > 0) {
|
||||
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
|
||||
_hadEmptyFavoritesOnLoad = false;
|
||||
_providerListeningTo!.removeListener(_onProviderChanged);
|
||||
_providerListeningTo = null;
|
||||
if (!mounted || _providerListeningTo == null) return;
|
||||
|
||||
final provider = _providerListeningTo!;
|
||||
final currentRefreshCounter = provider.homeRefreshCounter;
|
||||
|
||||
// Check if home refresh counter changed (indicates authentication completed and cache was invalidated)
|
||||
if (currentRefreshCounter != _lastHomeRefreshCounter) {
|
||||
_logger.log('🔄 Home: refresh counter changed ($_lastHomeRefreshCounter -> $currentRefreshCounter), refreshing all rows');
|
||||
_lastHomeRefreshCounter = currentRefreshCounter;
|
||||
setState(() {
|
||||
_refreshKey = UniqueKey();
|
||||
_refreshKey = UniqueKey(); // Forces all rows to rebuild and re-fetch data
|
||||
});
|
||||
return; // Don't process other refresh conditions in this cycle
|
||||
}
|
||||
|
||||
// Check for artists cache load (when favorites were empty on load)
|
||||
if (_hadEmptyFavoritesOnLoad) {
|
||||
final currentCount = provider.artists.length;
|
||||
if (_lastArtistCount == 0 && currentCount > 0) {
|
||||
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
|
||||
_hadEmptyFavoritesOnLoad = false;
|
||||
setState(() {
|
||||
_refreshKey = UniqueKey();
|
||||
});
|
||||
}
|
||||
_lastArtistCount = currentCount;
|
||||
}
|
||||
_lastArtistCount = currentCount;
|
||||
}
|
||||
|
||||
/// Called when SyncService status changes
|
||||
@@ -156,6 +184,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
|
||||
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
|
||||
final rowOrder = await SettingsService.getHomeRowOrder();
|
||||
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showRecentAlbums = showRecent;
|
||||
@@ -171,8 +200,91 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
_homeRowOrder = rowOrder;
|
||||
_discoveryRowEnabled = discoveryRowPrefs;
|
||||
});
|
||||
}
|
||||
|
||||
// Load discovery folders dynamically (after settings load)
|
||||
// This will add enabled discovery rows to the home order
|
||||
await _loadDiscoveryFolders(forceRefresh: false);
|
||||
}
|
||||
|
||||
Future<void> _loadDiscoveryFolders({bool forceRefresh = false}) async {
|
||||
if (!mounted) return;
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// Set loading state if not already loaded
|
||||
if (_discoveryFolders.isEmpty && mounted) {
|
||||
setState(() {
|
||||
_isLoadingDiscoveryFolders = true;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
var folders = await provider.getDiscoveryFoldersWithCache();
|
||||
// If cached result is empty, force a refresh to get from API/database
|
||||
if (folders.isEmpty && !forceRefresh) {
|
||||
_logger.log('🔄 Discovery: Cached folders empty, forcing refresh...');
|
||||
folders = await provider.getDiscoveryFoldersWithCache(forceRefresh: true);
|
||||
}
|
||||
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_discoveryFolders = folders;
|
||||
_discoveryRowEnabled = discoveryRowPrefs;
|
||||
_isLoadingDiscoveryFolders = false;
|
||||
});
|
||||
}
|
||||
// Add enabled discovery rows to home row order if not already present
|
||||
if (mounted) {
|
||||
_addDiscoveryRowsToHomeOrder(folders);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to load discovery folders: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingDiscoveryFolders = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add discovery rows to the home row order (at the end, like static rows)
|
||||
void _addDiscoveryRowsToHomeOrder(List<RecommendationFolder> folders) {
|
||||
if (!mounted) return;
|
||||
|
||||
bool didModify = false;
|
||||
|
||||
_logger.log('🔍 Discovery: Processing ${folders.length} folders, prefs: $_discoveryRowEnabled');
|
||||
|
||||
for (final folder in folders) {
|
||||
final discoveryRowId = 'discovery:${folder.itemId}';
|
||||
// Check if this discovery row is enabled (default to false)
|
||||
final isEnabled = _discoveryRowEnabled[folder.itemId] ?? false;
|
||||
|
||||
_logger.log('🔍 Discovery: ${folder.name} (${folder.itemId}) enabled=$isEnabled, inOrder=${_homeRowOrder.contains(discoveryRowId)}');
|
||||
|
||||
if (isEnabled && !_homeRowOrder.contains(discoveryRowId)) {
|
||||
// Add at the end (consistent with static rows behavior)
|
||||
_homeRowOrder.add(discoveryRowId);
|
||||
_logger.log('✅ Discovery: Adding $discoveryRowId to home order at end');
|
||||
didModify = true;
|
||||
} else if (!isEnabled && _homeRowOrder.contains(discoveryRowId)) {
|
||||
// Remove disabled discovery row from order
|
||||
_homeRowOrder.remove(discoveryRowId);
|
||||
_logger.log('❌ Discovery: Removing $discoveryRowId from home order');
|
||||
didModify = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Save the updated row order to settings and trigger rebuild
|
||||
if (didModify) {
|
||||
_logger.log('💾 Discovery: Saving updated home row order: $_homeRowOrder');
|
||||
SettingsService.setHomeRowOrder(_homeRowOrder);
|
||||
if (mounted) {
|
||||
setState(() {}); // Trigger rebuild to show new row order
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRefresh() async {
|
||||
@@ -326,11 +438,34 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
for (final rowId in _homeRowOrder) {
|
||||
if (_isRowEnabled(rowId)) count++;
|
||||
}
|
||||
// Count discovery folders that are NOT in the main row order yet (legacy support during load)
|
||||
// This handles the brief moment between when discovery folders load and when they're added to the order
|
||||
if (_discoveryFolders.isNotEmpty) {
|
||||
for (final folder in _discoveryFolders) {
|
||||
final discoveryRowId = 'discovery:${folder.itemId}';
|
||||
if (!_homeRowOrder.contains(discoveryRowId) && (_discoveryRowEnabled[folder.itemId] ?? false)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
} else if (_isLoadingDiscoveryFolders && _discoveryFolders.isEmpty) {
|
||||
// Count as 1 row when loading so RefreshIndicator works
|
||||
// Only if no discovery rows are in the main order
|
||||
final hasDiscoveryRowsInOrder = _homeRowOrder.any((id) => id.startsWith('discovery:'));
|
||||
if (!hasDiscoveryRowsInOrder) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Check if a specific row is enabled
|
||||
bool _isRowEnabled(String rowId) {
|
||||
// Discovery folders use the pattern 'discovery:{itemId}'
|
||||
if (rowId.startsWith('discovery:')) {
|
||||
final itemId = rowId.substring('discovery:'.length);
|
||||
// Default to true if not explicitly set
|
||||
return _discoveryRowEnabled[itemId] ?? false;
|
||||
}
|
||||
switch (rowId) {
|
||||
case 'recent-albums': return _showRecentAlbums;
|
||||
case 'discover-artists': return _showDiscoverArtists;
|
||||
@@ -348,6 +483,57 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a loading placeholder for discovery rows
|
||||
Widget _buildDiscoveryLoadingPlaceholder(double rowHeight) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return SizedBox(
|
||||
height: rowHeight,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||
child: Text(
|
||||
S.of(context)!.discoveryMixes,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colorScheme.primary.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
S.of(context)!.loading,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConnectedView(
|
||||
BuildContext context, MusicAssistantProvider provider) {
|
||||
// Use LayoutBuilder to get available screen height
|
||||
@@ -386,10 +572,14 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.only(bottom: BottomSpacing.withMiniPlayer),
|
||||
child: Column(
|
||||
key: _refreshKey,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _buildOrderedRows(provider, rowHeight),
|
||||
child: SizedBox(
|
||||
// Ensure minimum height for pull-to-refresh when empty
|
||||
height: enabledRows == 0 ? availableHeight : null,
|
||||
child: Column(
|
||||
key: _refreshKey,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _buildOrderedRows(provider, rowHeight, enabledRows == 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -399,9 +589,54 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
}
|
||||
|
||||
/// Build rows in the user's configured order
|
||||
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight) {
|
||||
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight, bool isEmpty) {
|
||||
final rows = <Widget>[];
|
||||
|
||||
// Show empty state with refresh hint when no rows are enabled
|
||||
if (isEmpty) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
rows.add(
|
||||
SizedBox.expand(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.home_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurface.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
S.of(context)!.noRowsEnabled,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.pullToRefreshHint,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.4),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Track which discovery folders have been rendered (from main row order)
|
||||
final renderedDiscoveryFolders = <String>{};
|
||||
|
||||
for (final rowId in _homeRowOrder) {
|
||||
final widget = _buildRowWidget(rowId, provider, rowHeight);
|
||||
if (widget != null) {
|
||||
@@ -410,6 +645,42 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
rows.add(const SizedBox(height: 2.0));
|
||||
}
|
||||
rows.add(widget);
|
||||
// Track discovery folders that were rendered from main row order
|
||||
if (rowId.startsWith('discovery:')) {
|
||||
renderedDiscoveryFolders.add(rowId.substring('discovery:'.length));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add legacy discovery folders that aren't in the main row order yet
|
||||
// This ensures existing discovery folders still show even if not yet in _homeRowOrder
|
||||
if (_discoveryFolders.isNotEmpty) {
|
||||
for (final folder in _discoveryFolders) {
|
||||
if (!renderedDiscoveryFolders.contains(folder.itemId)) {
|
||||
final discoveryRowId = 'discovery:${folder.itemId}';
|
||||
// Check if this specific discovery row is enabled
|
||||
final isEnabled = _discoveryRowEnabled[folder.itemId] ?? false;
|
||||
if (isEnabled) {
|
||||
final widget = _buildRowWidget(discoveryRowId, provider, rowHeight);
|
||||
if (widget != null) {
|
||||
// Add spacing between rows
|
||||
if (rows.isNotEmpty) {
|
||||
rows.add(const SizedBox(height: 2.0));
|
||||
}
|
||||
rows.add(widget);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (_isLoadingDiscoveryFolders && _discoveryFolders.isEmpty) {
|
||||
// Show loading placeholder when discovery folders are loading
|
||||
// Only if no discovery rows are in the main order
|
||||
final hasDiscoveryRowsInOrder = _homeRowOrder.any((id) => id.startsWith('discovery:'));
|
||||
if (!hasDiscoveryRowsInOrder) {
|
||||
if (rows.isNotEmpty) {
|
||||
rows.add(const SizedBox(height: 2.0));
|
||||
}
|
||||
rows.add(_buildDiscoveryLoadingPlaceholder(rowHeight));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,6 +796,29 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
default:
|
||||
// Handle dynamic discovery folders
|
||||
if (rowId.startsWith('discovery:')) {
|
||||
final folderId = rowId.substring('discovery:'.length);
|
||||
// Check per-row preference
|
||||
final isEnabled = _discoveryRowEnabled[folderId] ?? false;
|
||||
if (!isEnabled) return null;
|
||||
|
||||
try {
|
||||
final folder = _discoveryFolders.firstWhere(
|
||||
(f) => f.itemId == folderId,
|
||||
);
|
||||
return DiscoveryRow(
|
||||
key: ValueKey(rowId),
|
||||
title: folder.name,
|
||||
loadItems: () => provider.getDiscoveryFolderItems(folderId),
|
||||
getCachedItems: () => provider.getCachedDiscoveryFolderItems(folderId),
|
||||
heroTagSuffix: 'home',
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
} catch (e) {
|
||||
// Folder not found, skip this row
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,12 +590,13 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
ListTile(
|
||||
leading: Icon(Icons.playlist_add, color: colorScheme.onSurface),
|
||||
title: Text(S.of(context)!.addToQueue, style: TextStyle(color: colorScheme.onSurface)),
|
||||
onTap: () {
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
// Add to queue on selected player
|
||||
final player = maProvider.selectedPlayer;
|
||||
if (player != null) {
|
||||
maProvider.addTracksToQueue(player.playerId, _tracks).then((_) {
|
||||
try {
|
||||
await maProvider.addTracksToQueue(player.playerId, _tracks);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -604,7 +605,11 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showError(S.of(context)!.failedToAddToQueue(e.toString()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_showError(S.of(context)!.noPlayerSelected);
|
||||
}
|
||||
@@ -882,7 +887,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
@@ -736,7 +736,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
|
||||
@@ -330,7 +330,7 @@ class _PodcastEpisodeDetailScreenState extends State<PodcastEpisodeDetailScreen>
|
||||
// Header content with title, podcast name, metadata, and actions
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
@@ -108,6 +108,9 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
};
|
||||
bool _isSearching = false;
|
||||
bool _hasSearched = false;
|
||||
// Race condition fix: Track the active search request ID
|
||||
int _currentSearchId = 0;
|
||||
bool _hasPendingSearch = false;
|
||||
// PERF: Use ValueNotifier to avoid full screen rebuild on filter change
|
||||
final ValueNotifier<String> _activeFilterNotifier = ValueNotifier('all');
|
||||
String? _searchError;
|
||||
@@ -359,6 +362,8 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
void _onSearchChanged(String query) {
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = Timer(Timings.searchDebounce, () {
|
||||
// Race condition fix: Check mounted before proceeding
|
||||
if (!mounted) return;
|
||||
_performSearch(query, keepFocus: true);
|
||||
});
|
||||
}
|
||||
@@ -375,6 +380,10 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Race condition fix: Generate unique ID for this search
|
||||
final searchId = ++_currentSearchId;
|
||||
_hasPendingSearch = true;
|
||||
|
||||
setState(() {
|
||||
_isSearching = true;
|
||||
_searchError = null;
|
||||
@@ -400,6 +409,11 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
} catch (e) {
|
||||
_logger.log('Global radio search failed: $e');
|
||||
}
|
||||
// Race condition fix: Abort if a newer search has started
|
||||
if (!mounted || searchId != _currentSearchId) {
|
||||
_logger.log('⏭️ Ignoring stale radio search results for "$query"');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Combine and deduplicate by name
|
||||
@@ -428,6 +442,11 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
} catch (e) {
|
||||
_logger.log('Global podcast search failed: $e');
|
||||
}
|
||||
// Race condition fix: Abort if a newer search has started
|
||||
if (!mounted || searchId != _currentSearchId) {
|
||||
_logger.log('⏭️ Ignoring stale podcast search results for "$query"');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Combine and deduplicate by name
|
||||
@@ -442,46 +461,55 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
// Combine all results
|
||||
var combinedResults = {
|
||||
...results,
|
||||
'radios': allRadios.values.toList(),
|
||||
'podcasts': allPodcasts.values.toList(),
|
||||
};
|
||||
// Race condition fix: Only update results if this is still the latest search
|
||||
if (!mounted) return;
|
||||
if (searchId != _currentSearchId) {
|
||||
_logger.log('⏭️ Ignoring stale search results for "$query"');
|
||||
return;
|
||||
}
|
||||
|
||||
// When "in library" mode is on, also filter by enabled providers
|
||||
if (_libraryOnly) {
|
||||
final enabledProviders = provider.enabledProviderIds.toSet();
|
||||
combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
|
||||
}
|
||||
// Combine all results
|
||||
var combinedResults = {
|
||||
...results,
|
||||
'radios': allRadios.values.toList(),
|
||||
'podcasts': allPodcasts.values.toList(),
|
||||
};
|
||||
|
||||
setState(() {
|
||||
_searchResults = combinedResults;
|
||||
_isSearching = false;
|
||||
_hasSearched = true;
|
||||
_cachedListItems.clear(); // PERF: Clear cache on new results
|
||||
_cachedAvailableFilters = null;
|
||||
});
|
||||
// When "in library" mode is on, also filter by enabled providers
|
||||
if (_libraryOnly) {
|
||||
final enabledProviders = provider.enabledProviderIds.toSet();
|
||||
combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
|
||||
}
|
||||
|
||||
// Save to search history if we got results
|
||||
final hasResults = results.values.any((list) => list.isNotEmpty);
|
||||
if (hasResults && DatabaseService.instance.isInitialized) {
|
||||
DatabaseService.instance.saveSearchQuery(query);
|
||||
_loadRecentSearches(); // Refresh the list
|
||||
}
|
||||
setState(() {
|
||||
_searchResults = combinedResults;
|
||||
_isSearching = false;
|
||||
_hasSearched = true;
|
||||
_hasPendingSearch = false;
|
||||
_cachedListItems.clear(); // PERF: Clear cache on new results
|
||||
_cachedAvailableFilters = null;
|
||||
});
|
||||
|
||||
// Keep keyboard open if user is still typing
|
||||
if (keepFocus && _focusNode.hasFocus) {
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
// Save to search history if we got results
|
||||
final hasResults = results.values.any((list) => list.isNotEmpty);
|
||||
if (hasResults && DatabaseService.instance.isInitialized) {
|
||||
DatabaseService.instance.saveSearchQuery(query);
|
||||
_loadRecentSearches(); // Refresh the list
|
||||
}
|
||||
|
||||
// Keep keyboard open if user is still typing
|
||||
if (keepFocus && _focusNode.hasFocus) {
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Search error: $e');
|
||||
if (mounted) {
|
||||
if (!mounted) return;
|
||||
// Race condition fix: Only update error state if this is still the latest search
|
||||
if (searchId == _currentSearchId) {
|
||||
setState(() {
|
||||
_isSearching = false;
|
||||
_hasSearched = true;
|
||||
_hasPendingSearch = false;
|
||||
_searchError = S.of(context)!.searchFailed;
|
||||
});
|
||||
if (keepFocus && _focusNode.hasFocus) {
|
||||
@@ -570,35 +598,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,
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Only use Tooltip if an Overlay ancestor exists
|
||||
final hasOverlay = Overlay.maybeOf(context) != null;
|
||||
if (!hasOverlay) {
|
||||
return control;
|
||||
}
|
||||
|
||||
return Tooltip(
|
||||
message: S.of(context)!.libraryOnly,
|
||||
child: control,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import '../providers/locale_provider.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
@@ -22,6 +26,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final _lastFmApiKeyController = TextEditingController();
|
||||
final _audioDbApiKeyController = TextEditingController();
|
||||
String _appVersion = '';
|
||||
// Main rows (default on)
|
||||
bool _showRecentAlbums = true;
|
||||
bool _showDiscoverArtists = true;
|
||||
@@ -40,19 +45,77 @@ 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;
|
||||
// Display settings
|
||||
bool _showProviderIcons = true;
|
||||
// Discovery folders state
|
||||
List<RecommendationFolder> _discoveryFolders = [];
|
||||
Map<String, bool> _discoveryRowEnabled = {};
|
||||
bool _isLoadingDiscoveryFolders = false;
|
||||
|
||||
/// All available static home screen rows (that can be toggled on/off)
|
||||
static const List<String> _allAvailableRows = [
|
||||
'recent-albums',
|
||||
'discover-artists',
|
||||
'discover-albums',
|
||||
'discovery-mixes',
|
||||
'continue-listening',
|
||||
'discover-audiobooks',
|
||||
'discover-series',
|
||||
'favorite-albums',
|
||||
'favorite-artists',
|
||||
'favorite-tracks',
|
||||
'favorite-playlists',
|
||||
'favorite-radio-stations',
|
||||
'favorite-podcasts',
|
||||
];
|
||||
|
||||
/// Filtered row order for the draggable list
|
||||
/// Shows all available rows (in home order + discovery rows), regardless of enabled state
|
||||
List<String> get _filteredRowOrder {
|
||||
final rows = <String>[];
|
||||
|
||||
// First, add all static rows in their current home order
|
||||
for (final rowId in _homeRowOrder) {
|
||||
if (rowId == 'discovery-mixes') continue;
|
||||
rows.add(rowId);
|
||||
}
|
||||
|
||||
// Then, add any static rows that aren't in home order yet (so users can enable them)
|
||||
for (final rowId in _allAvailableRows) {
|
||||
if (!_homeRowOrder.contains(rowId) && rowId != 'discovery-mixes') {
|
||||
rows.add(rowId);
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, add any discovery rows from provider that aren't in home order yet
|
||||
for (final folder in _discoveryFolders) {
|
||||
final discoveryRowId = 'discovery:${folder.itemId}';
|
||||
if (!_homeRowOrder.contains(discoveryRowId)) {
|
||||
rows.add(discoveryRowId);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
_loadAppVersion();
|
||||
}
|
||||
|
||||
Future<void> _loadAppVersion() async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_appVersion = '${info.version}+${info.buildNumber}';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
@@ -86,9 +149,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
|
||||
@@ -97,6 +158,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Load display settings
|
||||
final showProviderIcons = await SettingsService.getShowProviderIcons();
|
||||
|
||||
// Load discovery row preferences
|
||||
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showRecentAlbums = showRecent;
|
||||
@@ -112,14 +176,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
_homeRowOrder = rowOrder;
|
||||
_preferLocalPlayer = preferLocal;
|
||||
_smartSortPlayers = smartSort;
|
||||
_disableAutoSwitch = disableAutoSwitch;
|
||||
_volumePrecisionMode = volumePrecision;
|
||||
_showHints = showHints;
|
||||
_showProviderIcons = showProviderIcons;
|
||||
_discoveryRowEnabled = discoveryRowPrefs;
|
||||
});
|
||||
}
|
||||
|
||||
// Load discovery folders after initial state is set
|
||||
_loadDiscoveryFolders();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -129,9 +195,84 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Load discovery folders from Music Assistant
|
||||
Future<void> _loadDiscoveryFolders() async {
|
||||
if (_discoveryFolders.isEmpty && mounted) {
|
||||
setState(() {
|
||||
_isLoadingDiscoveryFolders = true;
|
||||
});
|
||||
}
|
||||
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
try {
|
||||
final folders = await provider.getDiscoveryFoldersWithCache();
|
||||
// Also reload discovery row preferences to ensure they're up to date
|
||||
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_discoveryFolders = folders;
|
||||
_discoveryRowEnabled = discoveryRowPrefs;
|
||||
_isLoadingDiscoveryFolders = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingDiscoveryFolders = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh discovery folders from Music Assistant
|
||||
Future<void> _refreshDiscoveryFolders() async {
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
try {
|
||||
setState(() {
|
||||
_isLoadingDiscoveryFolders = true;
|
||||
});
|
||||
await provider.refreshDiscoveryFolders();
|
||||
final folders = provider.getCachedDiscoveryFolders() ?? [];
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_discoveryFolders = folders;
|
||||
_isLoadingDiscoveryFolders = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingDiscoveryFolders = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle a specific discovery row on/off
|
||||
Future<void> _toggleDiscoveryRow(String itemId, bool enabled) async {
|
||||
setState(() {
|
||||
_discoveryRowEnabled[itemId] = enabled;
|
||||
});
|
||||
await SettingsService.setDiscoveryRowPreference(itemId, enabled);
|
||||
}
|
||||
|
||||
// Helper to get row display info
|
||||
Map<String, String> _getRowInfo(String rowId) {
|
||||
final s = S.of(context)!;
|
||||
// Handle dynamic discovery folders
|
||||
if (rowId.startsWith('discovery:')) {
|
||||
final itemId = rowId.substring('discovery:'.length);
|
||||
final folder = _discoveryFolders.firstWhere(
|
||||
(f) => f.itemId == itemId,
|
||||
orElse: () => RecommendationFolder(
|
||||
itemId: itemId,
|
||||
provider: 'unknown',
|
||||
name: rowId,
|
||||
items: [],
|
||||
),
|
||||
);
|
||||
return {'title': folder.name, 'subtitle': 'Discovery row from Music Assistant'};
|
||||
}
|
||||
switch (rowId) {
|
||||
case 'recent-albums':
|
||||
return {'title': s.recentlyPlayed, 'subtitle': s.showRecentlyPlayedAlbums};
|
||||
@@ -139,6 +280,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return {'title': s.discoverArtists, 'subtitle': s.showRandomArtists};
|
||||
case 'discover-albums':
|
||||
return {'title': s.discoverAlbums, 'subtitle': s.showRandomAlbums};
|
||||
case 'discovery-mixes':
|
||||
return {'title': s.discoveryMixes, 'subtitle': s.discoveryMixesDescription};
|
||||
case 'continue-listening':
|
||||
return {'title': s.continueListening, 'subtitle': s.showAudiobooksInProgress};
|
||||
case 'discover-audiobooks':
|
||||
@@ -164,6 +307,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
// Helper to get row enabled state
|
||||
bool _getRowEnabled(String rowId) {
|
||||
// Handle dynamic discovery folders
|
||||
if (rowId.startsWith('discovery:')) {
|
||||
final itemId = rowId.substring('discovery:'.length);
|
||||
return _discoveryRowEnabled[itemId] ?? false;
|
||||
}
|
||||
switch (rowId) {
|
||||
case 'recent-albums':
|
||||
return _showRecentAlbums;
|
||||
@@ -197,6 +345,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Helper to set row enabled state
|
||||
void _setRowEnabled(String rowId, bool value) {
|
||||
setState(() {
|
||||
// Handle dynamic discovery folders
|
||||
if (rowId.startsWith('discovery:')) {
|
||||
final itemId = rowId.substring('discovery:'.length);
|
||||
_discoveryRowEnabled[itemId] = value;
|
||||
SettingsService.setDiscoveryRowPreference(itemId, value);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (rowId) {
|
||||
case 'recent-albums':
|
||||
_showRecentAlbums = value;
|
||||
@@ -703,33 +859,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 +883,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(
|
||||
@@ -903,7 +1007,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Reorderable home rows list
|
||||
// Reorderable home rows list (exclude discovery-mixes from list)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
@@ -914,24 +1018,42 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: _homeRowOrder.length,
|
||||
itemCount: _filteredRowOrder.length,
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
// Create a mutable copy of the filtered list for reordering
|
||||
final displayRows = List<String>.from(_filteredRowOrder);
|
||||
if (newIndex > oldIndex) newIndex--;
|
||||
final item = displayRows.removeAt(oldIndex);
|
||||
displayRows.insert(newIndex, item);
|
||||
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) newIndex--;
|
||||
final item = _homeRowOrder.removeAt(oldIndex);
|
||||
_homeRowOrder.insert(newIndex, item);
|
||||
// Update _homeRowOrder with the new order
|
||||
_homeRowOrder.clear();
|
||||
|
||||
// Add discovery rows that are enabled
|
||||
for (final rowId in displayRows) {
|
||||
if (rowId.startsWith('discovery:')) {
|
||||
// Only add enabled discovery rows to home order
|
||||
final itemId = rowId.substring('discovery:'.length);
|
||||
if (_discoveryRowEnabled[itemId] ?? false) {
|
||||
_homeRowOrder.add(rowId);
|
||||
}
|
||||
} else {
|
||||
_homeRowOrder.add(rowId);
|
||||
}
|
||||
}
|
||||
});
|
||||
SettingsService.setHomeRowOrder(_homeRowOrder);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final rowId = _homeRowOrder[index];
|
||||
final rowId = _filteredRowOrder[index];
|
||||
final rowInfo = _getRowInfo(rowId);
|
||||
final isEnabled = _getRowEnabled(rowId);
|
||||
|
||||
return Container(
|
||||
key: ValueKey(rowId),
|
||||
decoration: BoxDecoration(
|
||||
border: index < _homeRowOrder.length - 1
|
||||
border: index < _filteredRowOrder.length - 1
|
||||
? Border(bottom: BorderSide(color: colorScheme.outline.withOpacity(0.2)))
|
||||
: null,
|
||||
),
|
||||
@@ -1062,6 +1184,149 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// About & Support section
|
||||
Text(
|
||||
S.of(context)!.aboutAndSupport,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Support Development banner
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Color(0xFFEA4AAA).withOpacity(0.15),
|
||||
Color(0xFF1F6FEB).withOpacity(0.15),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.favorite_rounded,
|
||||
color: Color(0xFFEA4AAA),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
S.of(context)!.supportDevelopment,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.ensembleIsUnofficial,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
// GitHub Sponsors button
|
||||
Expanded(
|
||||
child: _DonationButton(
|
||||
label: S.of(context)!.sponsorOnGitHub,
|
||||
color: Color(0xFF1F6FEB),
|
||||
iconWidget: Icon(MdiIcons.github, size: 20),
|
||||
onTap: () => _launchUrl('https://github.com/sponsors/CollotsSpot'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Ko-fi button
|
||||
Expanded(
|
||||
child: _DonationButton(
|
||||
icon: null,
|
||||
label: S.of(context)!.buyMeAKoFi,
|
||||
color: Color(0xFFFF5E5B),
|
||||
iconWidget: Icon(Icons.coffee_rounded, color: Colors.white),
|
||||
onTap: () => _launchUrl('https://ko-fi.com/collotsspot'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// About Ensemble card
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
S.of(context)!.aboutEnsemble,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
S.of(context)!.version,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_appVersion,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_LinkButton(
|
||||
icon: Icons.code_rounded,
|
||||
label: S.of(context)!.visitGitHubRepo,
|
||||
onTap: () => _launchUrl('https://github.com/CollotsSpot/Ensemble'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_LinkButton(
|
||||
icon: Icons.bug_report_outlined,
|
||||
label: S.of(context)!.reportABug,
|
||||
onTap: () => _launchUrl('https://github.com/CollotsSpot/Ensemble/issues'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
@@ -1139,4 +1404,227 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _launchUrl(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the discovery section with individual row toggles
|
||||
Widget _buildDiscoverySection(ColorScheme colorScheme, TextTheme textTheme) {
|
||||
final s = S.of(context)!;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header with title and refresh button
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.explore_outlined,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.discoveryRows,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isLoadingDiscoveryFolders)
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colorScheme.primary.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
onPressed: _refreshDiscoveryFolders,
|
||||
tooltip: s.refreshDiscoveryRows,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Discovery rows list
|
||||
if (_discoveryFolders.isEmpty && !_isLoadingDiscoveryFolders)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Text(
|
||||
s.noDiscoveryRows,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
..._discoveryFolders.map((folder) {
|
||||
final itemId = folder.itemId;
|
||||
final isEnabled = _discoveryRowEnabled[itemId] ?? false;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: SwitchListTile(
|
||||
title: Text(
|
||||
folder.name,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
subtitle: folder.provider.isNotEmpty
|
||||
? Text(
|
||||
folder.provider,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 11,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
value: isEnabled,
|
||||
onChanged: (value) => _toggleDiscoveryRow(itemId, value),
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
|
||||
dense: true,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom donation button widget
|
||||
class _DonationButton extends StatelessWidget {
|
||||
final String? icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
final Widget? iconWidget;
|
||||
|
||||
const _DonationButton({
|
||||
this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
this.iconWidget,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: color.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (iconWidget != null)
|
||||
iconWidget!
|
||||
else if (icon != null)
|
||||
Image.asset(
|
||||
icon!,
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom link button widget
|
||||
class _LinkButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _LinkButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.open_in_new,
|
||||
size: 14,
|
||||
color: colorScheme.primary.withOpacity(0.7),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../constants/timings.dart';
|
||||
@@ -10,6 +11,7 @@ import '../constants/timings.dart';
|
||||
class CacheService {
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
bool _homeRowsLoaded = false;
|
||||
Future<void>? _homeRowsLoadFuture;
|
||||
|
||||
// Cache size limits to prevent unbounded memory growth
|
||||
static const int _maxDetailCacheSize = 50; // Max albums/playlists/artists cached
|
||||
@@ -20,12 +22,14 @@ class CacheService {
|
||||
List<Album>? _cachedRecentAlbums;
|
||||
List<Artist>? _cachedDiscoverArtists;
|
||||
List<Album>? _cachedDiscoverAlbums;
|
||||
List<RecommendationFolder>? _cachedDiscoveryFolders;
|
||||
List<Audiobook>? _cachedInProgressAudiobooks;
|
||||
List<Audiobook>? _cachedDiscoverAudiobooks;
|
||||
List<AudiobookSeries>? _cachedDiscoverSeries;
|
||||
DateTime? _recentAlbumsLastFetched;
|
||||
DateTime? _discoverArtistsLastFetched;
|
||||
DateTime? _discoverAlbumsLastFetched;
|
||||
DateTime? _discoveryFoldersLastFetched;
|
||||
DateTime? _inProgressAudiobooksLastFetched;
|
||||
DateTime? _discoverAudiobooksLastFetched;
|
||||
DateTime? _discoverSeriesLastFetched;
|
||||
@@ -55,6 +59,14 @@ class CacheService {
|
||||
// HOME SCREEN ROW CACHING
|
||||
// ============================================================================
|
||||
|
||||
/// Ensure home rows are loaded from database before accessing cache
|
||||
/// This ensures the cache is populated on first access if not already loaded
|
||||
Future<void> ensureHomeRowsLoaded() async {
|
||||
if (!_homeRowsLoaded) {
|
||||
await loadHomeRowsFromDatabase();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if recent albums cache is valid
|
||||
bool isRecentAlbumsCacheValid({bool forceRefresh = false}) {
|
||||
if (forceRefresh) return false;
|
||||
@@ -69,6 +81,11 @@ class CacheService {
|
||||
|
||||
/// Set cached recent albums
|
||||
void setCachedRecentAlbums(List<Album> albums) {
|
||||
// Don't cache empty results (prevents persisting "not authenticated" errors)
|
||||
if (albums.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty recent albums result');
|
||||
return;
|
||||
}
|
||||
_cachedRecentAlbums = albums;
|
||||
_recentAlbumsLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${albums.length} recent albums');
|
||||
@@ -90,6 +107,11 @@ class CacheService {
|
||||
|
||||
/// Set cached discover artists
|
||||
void setCachedDiscoverArtists(List<Artist> artists) {
|
||||
// Don't cache empty results (prevents persisting "not authenticated" errors)
|
||||
if (artists.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty discover artists result');
|
||||
return;
|
||||
}
|
||||
_cachedDiscoverArtists = artists;
|
||||
_discoverArtistsLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${artists.length} discover artists');
|
||||
@@ -111,6 +133,11 @@ class CacheService {
|
||||
|
||||
/// Set cached discover albums
|
||||
void setCachedDiscoverAlbums(List<Album> albums) {
|
||||
// Don't cache empty results (prevents persisting "not authenticated" errors)
|
||||
if (albums.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty discover albums result');
|
||||
return;
|
||||
}
|
||||
_cachedDiscoverAlbums = albums;
|
||||
_discoverAlbumsLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${albums.length} discover albums');
|
||||
@@ -118,6 +145,32 @@ class CacheService {
|
||||
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Check if discovery folders cache is valid
|
||||
bool isDiscoveryFoldersCacheValid({bool forceRefresh = false}) {
|
||||
if (forceRefresh) return false;
|
||||
final now = DateTime.now();
|
||||
return _cachedDiscoveryFolders != null &&
|
||||
_discoveryFoldersLastFetched != null &&
|
||||
now.difference(_discoveryFoldersLastFetched!) < Timings.homeRowCacheDuration;
|
||||
}
|
||||
|
||||
/// Get cached discovery folders
|
||||
List<RecommendationFolder>? getCachedDiscoveryFolders() => _cachedDiscoveryFolders;
|
||||
|
||||
/// Set cached discovery folders
|
||||
void setCachedDiscoveryFolders(List<RecommendationFolder> folders) {
|
||||
// Don't cache empty results (prevents persisting "not authenticated" errors)
|
||||
if (folders.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty discovery folders result');
|
||||
return;
|
||||
}
|
||||
_cachedDiscoveryFolders = folders;
|
||||
_discoveryFoldersLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${folders.length} discovery folders');
|
||||
// Persist to database for instant load on next launch
|
||||
_persistHomeRowToDatabase('discovery_folders', folders.map((f) => f.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Check if in-progress audiobooks cache is valid
|
||||
bool isInProgressAudiobooksCacheValid({bool forceRefresh = false}) {
|
||||
if (forceRefresh) return false;
|
||||
@@ -132,6 +185,11 @@ class CacheService {
|
||||
|
||||
/// Set cached in-progress audiobooks
|
||||
void setCachedInProgressAudiobooks(List<Audiobook> audiobooks) {
|
||||
// Don't cache empty results
|
||||
if (audiobooks.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty in-progress audiobooks result');
|
||||
return;
|
||||
}
|
||||
_cachedInProgressAudiobooks = audiobooks;
|
||||
_inProgressAudiobooksLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${audiobooks.length} in-progress audiobooks');
|
||||
@@ -151,6 +209,11 @@ class CacheService {
|
||||
|
||||
/// Set cached discover audiobooks
|
||||
void setCachedDiscoverAudiobooks(List<Audiobook> audiobooks) {
|
||||
// Don't cache empty results
|
||||
if (audiobooks.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty discover audiobooks result');
|
||||
return;
|
||||
}
|
||||
_cachedDiscoverAudiobooks = audiobooks;
|
||||
_discoverAudiobooksLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${audiobooks.length} discover audiobooks');
|
||||
@@ -170,6 +233,11 @@ class CacheService {
|
||||
|
||||
/// Set cached discover series
|
||||
void setCachedDiscoverSeries(List<AudiobookSeries> series) {
|
||||
// Don't cache empty results
|
||||
if (series.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty discover series result');
|
||||
return;
|
||||
}
|
||||
_cachedDiscoverSeries = series;
|
||||
_discoverSeriesLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${series.length} discover series');
|
||||
@@ -191,6 +259,7 @@ class CacheService {
|
||||
_recentAlbumsLastFetched = null;
|
||||
_discoverArtistsLastFetched = null;
|
||||
_discoverAlbumsLastFetched = null;
|
||||
_discoveryFoldersLastFetched = null;
|
||||
_inProgressAudiobooksLastFetched = null;
|
||||
_discoverAudiobooksLastFetched = null;
|
||||
_discoverSeriesLastFetched = null;
|
||||
@@ -448,10 +517,22 @@ class CacheService {
|
||||
// ============================================================================
|
||||
|
||||
/// Load home row data from database for instant display on startup
|
||||
/// Returns the existing future if already loading, to prevent duplicate loads
|
||||
Future<void> loadHomeRowsFromDatabase() async {
|
||||
if (_homeRowsLoadFuture != null) {
|
||||
// Already loading, return the existing future
|
||||
return _homeRowsLoadFuture!;
|
||||
}
|
||||
if (_homeRowsLoaded) return;
|
||||
_homeRowsLoaded = true;
|
||||
|
||||
// Create the load future
|
||||
_homeRowsLoadFuture = _doLoadHomeRowsFromDatabase();
|
||||
await _homeRowsLoadFuture;
|
||||
_homeRowsLoadFuture = null; // Clear after completion
|
||||
}
|
||||
|
||||
/// Internal implementation of home row loading
|
||||
Future<void> _doLoadHomeRowsFromDatabase() async {
|
||||
try {
|
||||
if (!DatabaseService.instance.isInitialized) return;
|
||||
|
||||
@@ -499,8 +580,25 @@ class CacheService {
|
||||
_logger.log('⚠️ Failed to parse discover albums: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Load discovery folders
|
||||
final foldersData = await DatabaseService.instance.getHomeRowCache('discovery_folders');
|
||||
if (foldersData != null) {
|
||||
try {
|
||||
final items = (jsonDecode(foldersData.itemsJson) as List)
|
||||
.map((json) => RecommendationFolder.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
_cachedDiscoveryFolders = items;
|
||||
_discoveryFoldersLastFetched = foldersData.lastUpdated;
|
||||
_logger.log('📦 Loaded ${items.length} discovery folders from database');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse discovery folders: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error loading home rows from database: $e');
|
||||
} finally {
|
||||
_homeRowsLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -193,6 +193,33 @@ class DatabaseService {
|
||||
return db.markItemsDeleted(itemType, [itemId]);
|
||||
}
|
||||
|
||||
/// Mark a cached item as deleted by searching for library ID in the data
|
||||
/// This handles cases where itemId is different from library ID
|
||||
Future<void> markCachedItemDeletedByLibraryId(String itemType, String libraryId) async {
|
||||
// First try direct match (for provider=library items)
|
||||
await db.markItemsDeleted(itemType, [libraryId]);
|
||||
|
||||
// Also search through cached items to find any with matching library mapping
|
||||
final items = await db.getCachedItems(itemType);
|
||||
for (final item in items) {
|
||||
try {
|
||||
// Check if this item's data contains a library mapping with this ID
|
||||
if (item.data.contains('"provider_instance":"library"') &&
|
||||
item.data.contains('"item_id":"$libraryId"')) {
|
||||
// Found a match - extract the actual cache key's itemId
|
||||
final cacheKey = item.cacheKey;
|
||||
final parts = cacheKey.split('_');
|
||||
if (parts.length >= 2) {
|
||||
final actualItemId = parts.sublist(1).join('_');
|
||||
await db.markItemsDeleted(itemType, [actualItemId]);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore parse errors, continue checking other items
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch cache multiple items efficiently
|
||||
/// Uses drift's batch API for much better performance on large datasets.
|
||||
/// Items are automatically chunked into batches of 500.
|
||||
|
||||
@@ -12,7 +12,7 @@ class HardwareVolumeService {
|
||||
factory HardwareVolumeService() => _instance;
|
||||
HardwareVolumeService._internal();
|
||||
|
||||
static const _channel = MethodChannel('com.musicassistant.music_assistant/volume_buttons');
|
||||
static const _channel = MethodChannel('com.collotsspot.ensemble/volume_buttons');
|
||||
final _logger = DebugLogger();
|
||||
|
||||
final _volumeUpController = StreamController<void>.broadcast();
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import '../models/media_item.dart';
|
||||
import '../models/provider_manifest.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
|
||||
/// Service for handling image/artwork URL generation and caching.
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to reduce complexity.
|
||||
class ImageHelperService {
|
||||
final MusicAssistantAPI? _api;
|
||||
|
||||
/// Podcast cover cache: podcastId -> best available cover URL
|
||||
/// This is populated with episode covers when podcast covers are low-res
|
||||
final Map<String, String> _podcastCoverCache = {};
|
||||
|
||||
/// Callback to get cached track for a player
|
||||
final Track? Function(String playerId) getCachedTrackForPlayer;
|
||||
|
||||
ImageHelperService({
|
||||
required MusicAssistantAPI? api,
|
||||
required this.getCachedTrackForPlayer,
|
||||
}) : _api = api;
|
||||
|
||||
/// Get artwork URL for a media item from Music Assistant
|
||||
String? getImageUrl(MediaItem item, {int size = 256}) {
|
||||
return _api?.getImageUrl(item, size: size);
|
||||
}
|
||||
|
||||
/// Get best available podcast cover URL
|
||||
///
|
||||
/// Returns cached iTunes URL (800x800) if available, otherwise falls back to MA imageproxy.
|
||||
/// The cache is persisted to storage and loaded on app start for instant high-res display.
|
||||
String? getPodcastImageUrl(MediaItem podcast, {int size = 256}) {
|
||||
// Return cached iTunes URL if available (persisted across app launches)
|
||||
final cachedUrl = _podcastCoverCache[podcast.itemId];
|
||||
if (cachedUrl != null) {
|
||||
return cachedUrl;
|
||||
}
|
||||
// Fall back to MA imageproxy (will be replaced once iTunes fetch completes)
|
||||
return _api?.getImageUrl(podcast, size: size);
|
||||
}
|
||||
|
||||
/// Get artist image URL with fallback to external sources (Deezer, Fanart.tv)
|
||||
///
|
||||
/// Returns a Future since fallback requires async API calls.
|
||||
Future<String?> getArtistImageUrlWithFallback(Artist artist, {int size = 256}) async {
|
||||
// Try Music Assistant first
|
||||
final maUrl = _api?.getImageUrl(artist, size: size);
|
||||
if (maUrl != null) {
|
||||
return maUrl;
|
||||
}
|
||||
|
||||
// Fall back to external sources (Deezer, Fanart.tv, etc.)
|
||||
return MetadataService.getArtistImageUrl(artist.name);
|
||||
}
|
||||
|
||||
/// Get SVG icon for a provider domain
|
||||
///
|
||||
/// Returns null if no icon is available.
|
||||
String? getProviderIconSvg(String domain, {bool isDark = false}) {
|
||||
return _api?.getProviderIconSvg(domain, isDark: isDark);
|
||||
}
|
||||
|
||||
/// Get provider manifest by domain
|
||||
ProviderManifest? getProviderManifest(String domain) {
|
||||
return _api?.getProviderManifest(domain);
|
||||
}
|
||||
|
||||
/// Get artwork URL for a player from cache
|
||||
String? getCachedArtworkUrl(String playerId, {int size = 512}) {
|
||||
final track = getCachedTrackForPlayer(playerId);
|
||||
if (track == null) return null;
|
||||
return getImageUrl(track, size: size);
|
||||
}
|
||||
|
||||
/// Load podcast cover cache from storage for instant high-res display
|
||||
///
|
||||
/// Should be called during initialization.
|
||||
Future<void> loadPodcastCoverCache(Map<String, String> cache) async {
|
||||
if (cache.isNotEmpty) {
|
||||
_podcastCoverCache.addAll(cache);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current podcast cover cache (for persistence)
|
||||
Map<String, String> get podcastCoverCache => Map.unmodifiable(_podcastCoverCache);
|
||||
|
||||
/// Update podcast cover cache entry
|
||||
void updatePodcastCoverCache(String podcastId, String coverUrl) {
|
||||
_podcastCoverCache[podcastId] = coverUrl;
|
||||
}
|
||||
|
||||
/// Clear all podcast cover cache entries
|
||||
void clearPodcastCoverCache() {
|
||||
_podcastCoverCache.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
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();
|
||||
}
|
||||
|
||||
/// Initialize library status without notifying listeners.
|
||||
/// Use during initState to sync service from widget data without triggering rebuilds.
|
||||
void initLibraryStatus(String key, bool inLibrary) {
|
||||
_libraryStatus[key] ??= inLibrary;
|
||||
}
|
||||
|
||||
/// Initialize favorite status without notifying listeners.
|
||||
/// Use during initState to sync service from widget data without triggering rebuilds.
|
||||
void initFavoriteStatus(String key, bool favorite) {
|
||||
_favoriteStatus[key] ??= favorite;
|
||||
}
|
||||
|
||||
/// Mark operation as pending (for rollback tracking)
|
||||
void markLibraryPending(String key, bool previousValue) {
|
||||
_pendingLibraryOps[key] = previousValue;
|
||||
}
|
||||
|
||||
void markFavoritePending(String key, bool previousValue) {
|
||||
_pendingFavoriteOps[key] = previousValue;
|
||||
}
|
||||
|
||||
/// Complete operation successfully (clear pending)
|
||||
void completeLibraryOperation(String key) {
|
||||
_pendingLibraryOps.remove(key);
|
||||
}
|
||||
|
||||
void completeFavoriteOperation(String key) {
|
||||
_pendingFavoriteOps.remove(key);
|
||||
}
|
||||
|
||||
/// Rollback operation on failure
|
||||
void rollbackLibraryOperation(String key) {
|
||||
final previousValue = _pendingLibraryOps.remove(key);
|
||||
if (previousValue != null) {
|
||||
_libraryStatus[key] = previousValue;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void rollbackFavoriteOperation(String key) {
|
||||
final previousValue = _pendingFavoriteOps.remove(key);
|
||||
if (previousValue != null) {
|
||||
_favoriteStatus[key] = previousValue;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Bulk update from provider's library data
|
||||
/// Called when library is loaded/refreshed
|
||||
void syncFromLibrary({
|
||||
required String mediaType,
|
||||
required List<dynamic> items,
|
||||
required bool Function(dynamic item) getInLibrary,
|
||||
required bool Function(dynamic item) getFavorite,
|
||||
required String Function(dynamic item) getProvider,
|
||||
required String Function(dynamic item) getItemId,
|
||||
}) {
|
||||
bool changed = false;
|
||||
|
||||
for (final item in items) {
|
||||
final key = makeKey(mediaType, getProvider(item), getItemId(item));
|
||||
final inLibrary = getInLibrary(item);
|
||||
final favorite = getFavorite(item);
|
||||
|
||||
// Only update if not pending (don't overwrite optimistic updates)
|
||||
if (!_pendingLibraryOps.containsKey(key)) {
|
||||
if (_libraryStatus[key] != inLibrary) {
|
||||
_libraryStatus[key] = inLibrary;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!_pendingFavoriteOps.containsKey(key)) {
|
||||
if (_favoriteStatus[key] != favorite) {
|
||||
_favoriteStatus[key] = favorite;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a single item's status from fresh data
|
||||
void syncSingleItem({
|
||||
required String key,
|
||||
required bool inLibrary,
|
||||
required bool favorite,
|
||||
}) {
|
||||
bool changed = false;
|
||||
|
||||
if (!_pendingLibraryOps.containsKey(key) && _libraryStatus[key] != inLibrary) {
|
||||
_libraryStatus[key] = inLibrary;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!_pendingFavoriteOps.containsKey(key) && _favoriteStatus[key] != favorite) {
|
||||
_favoriteStatus[key] = favorite;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all status (on logout/disconnect)
|
||||
void clear() {
|
||||
_libraryStatus.clear();
|
||||
_favoriteStatus.clear();
|
||||
_pendingLibraryOps.clear();
|
||||
_pendingFavoriteOps.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Get debug info
|
||||
Map<String, dynamic> get debugInfo => {
|
||||
'libraryCount': _libraryStatus.length,
|
||||
'favoriteCount': _favoriteStatus.length,
|
||||
'pendingLibrary': _pendingLibraryOps.length,
|
||||
'pendingFavorite': _pendingFavoriteOps.length,
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../models/provider_instance.dart';
|
||||
import '../models/provider_manifest.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import 'debug_logger.dart';
|
||||
import 'settings_service.dart';
|
||||
import 'device_id_service.dart';
|
||||
@@ -214,6 +215,7 @@ class MusicAssistantAPI {
|
||||
},
|
||||
onDone: () {
|
||||
_logger.log('WebSocket connection closed');
|
||||
_isAuthenticated = false; // Reset auth state on disconnect
|
||||
_updateConnectionState(MAConnectionState.disconnected);
|
||||
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
|
||||
_connectionCompleter!.completeError(Exception('Connection closed'));
|
||||
@@ -1194,6 +1196,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 [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the audiobookshelf provider instance ID
|
||||
Future<String?> getAudiobookshelfProviderId() async {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../models/provider_instance.dart';
|
||||
import '../services/sync_service.dart';
|
||||
|
||||
/// Service for filtering media items and players based on user settings.
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to reduce complexity.
|
||||
class ProviderFilterService {
|
||||
/// Provider filter from MA user settings (empty = all providers allowed)
|
||||
final List<String> providerFilter;
|
||||
|
||||
/// Player filter from MA user settings (empty = all players allowed)
|
||||
final List<String> playerFilter;
|
||||
|
||||
/// Available music providers discovered from MA
|
||||
final List<ProviderInstance> availableMusicProviders;
|
||||
|
||||
/// Library data for provider counting
|
||||
final List<dynamic> artists;
|
||||
final List<dynamic> albums;
|
||||
final List<dynamic> tracks;
|
||||
final List<dynamic> podcasts;
|
||||
final List<dynamic> radioStations;
|
||||
|
||||
ProviderFilterService({
|
||||
required this.providerFilter,
|
||||
required this.playerFilter,
|
||||
required this.availableMusicProviders,
|
||||
required this.artists,
|
||||
required this.albums,
|
||||
required this.tracks,
|
||||
required this.podcasts,
|
||||
required this.radioStations,
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// PROVIDER FILTER (from MA user settings)
|
||||
// ===========================================================================
|
||||
|
||||
/// Whether provider filtering is active
|
||||
bool get hasProviderFilter => providerFilter.isNotEmpty;
|
||||
|
||||
/// Check if a media item should be visible based on provider filter.
|
||||
///
|
||||
/// Returns true if:
|
||||
/// - No filter is active (empty list = all providers allowed)
|
||||
/// - The item has at least one provider mapping in the allowed list
|
||||
/// - The item's primary provider is in the allowed list
|
||||
bool isItemAllowedByProviderFilter(MediaItem item) {
|
||||
// No filter = show everything
|
||||
if (providerFilter.isEmpty) return true;
|
||||
|
||||
// Check if item's provider mappings include any allowed provider
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings != null && mappings.isNotEmpty) {
|
||||
for (final mapping in mappings) {
|
||||
if (providerFilter.contains(mapping.providerInstance)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check primary provider field (for items without full mappings)
|
||||
if (providerFilter.contains(item.provider)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Filter a list of media items based on provider filter.
|
||||
List<T> filterByProvider<T extends MediaItem>(List<T> items) {
|
||||
if (providerFilter.isEmpty) return items;
|
||||
return items.where(isItemAllowedByProviderFilter).toList();
|
||||
}
|
||||
|
||||
/// Filter search results map based on provider filter.
|
||||
Map<String, List<MediaItem>> filterSearchResults(Map<String, List<MediaItem>> results) {
|
||||
if (providerFilter.isEmpty) return results;
|
||||
return {
|
||||
for (final entry in results.entries)
|
||||
entry.key: entry.value.where(isItemAllowedByProviderFilter).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// PLAYER FILTER (from MA user settings)
|
||||
// ===========================================================================
|
||||
|
||||
/// Whether player filtering is active
|
||||
bool get hasPlayerFilter => playerFilter.isNotEmpty;
|
||||
|
||||
/// Check if a player should be visible based on player filter.
|
||||
bool isPlayerAllowedByFilter(Player player) {
|
||||
if (playerFilter.isEmpty) return true;
|
||||
return playerFilter.contains(player.playerId);
|
||||
}
|
||||
|
||||
/// Filter a list of players based on player filter.
|
||||
List<Player> filterPlayers(List<Player> players) {
|
||||
if (playerFilter.isEmpty) return players;
|
||||
return players.where(isPlayerAllowedByFilter).toList();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// PROVIDER COUNTING
|
||||
// ===========================================================================
|
||||
|
||||
/// Get providers that have content in artists library (with item counts).
|
||||
Map<String, int> getProvidersWithArtists() {
|
||||
return _getProvidersWithCounts(artists);
|
||||
}
|
||||
|
||||
/// Get providers that have content in albums library (with item counts).
|
||||
Map<String, int> getProvidersWithAlbums() {
|
||||
return _getProvidersWithCounts(albums);
|
||||
}
|
||||
|
||||
/// Get providers that have content in tracks library (with item counts).
|
||||
Map<String, int> getProvidersWithTracks() {
|
||||
return _getProvidersWithCounts(tracks);
|
||||
}
|
||||
|
||||
/// Get providers that have content in playlists (with item counts).
|
||||
Map<String, int> getProvidersWithPlaylists() {
|
||||
return _getProvidersWithCounts(SyncService.instance.cachedPlaylists);
|
||||
}
|
||||
|
||||
/// Get providers that have audiobooks (with item counts).
|
||||
Map<String, int> getProvidersWithAudiobooks() {
|
||||
return _getProvidersWithCounts(SyncService.instance.cachedAudiobooks);
|
||||
}
|
||||
|
||||
/// Get providers that have radio stations (with item counts).
|
||||
Map<String, int> getProvidersWithRadio() {
|
||||
return _getProvidersWithCounts(radioStations);
|
||||
}
|
||||
|
||||
/// Get providers that have podcasts (with item counts).
|
||||
///
|
||||
/// Only counts mappings where inLibrary is true - this indicates which provider
|
||||
/// the user actually added the podcast from (vs providers that can play it).
|
||||
Map<String, int> getProvidersWithPodcasts() {
|
||||
final counts = <String, int>{};
|
||||
for (final item in podcasts) {
|
||||
final mappings = (item as MediaItem).providerMappings;
|
||||
if (mappings != null) {
|
||||
for (final mapping in mappings) {
|
||||
// Only count if this provider "owns" the item (user added it from this account)
|
||||
if (mapping.inLibrary) {
|
||||
final instanceId = mapping.providerInstance;
|
||||
if (instanceId.isNotEmpty) {
|
||||
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/// Internal helper to count items per provider from a list of media items.
|
||||
///
|
||||
/// Only counts mappings where inLibrary is true - this indicates which provider
|
||||
/// the user actually added the item from (vs providers that can play it).
|
||||
Map<String, int> _getProvidersWithCounts(List<dynamic> items) {
|
||||
final counts = <String, int>{};
|
||||
for (final item in items) {
|
||||
if (item is! MediaItem) continue;
|
||||
final mediaItem = item as MediaItem;
|
||||
final mappings = mediaItem.providerMappings;
|
||||
if (mappings != null) {
|
||||
for (final mapping in mappings) {
|
||||
// Only count if this provider "owns" the item (user added it from this account)
|
||||
if (mapping.inLibrary) {
|
||||
final instanceId = mapping.providerInstance;
|
||||
if (instanceId.isNotEmpty) {
|
||||
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/// Get ProviderInstance objects for providers that have content in a category.
|
||||
///
|
||||
/// Returns list of (ProviderInstance, itemCount) tuples, sorted by name.
|
||||
List<(ProviderInstance, int)> getRelevantProvidersForCategory(String category) {
|
||||
final Map<String, int> counts;
|
||||
switch (category) {
|
||||
case 'artists':
|
||||
counts = getProvidersWithArtists();
|
||||
break;
|
||||
case 'albums':
|
||||
counts = getProvidersWithAlbums();
|
||||
break;
|
||||
case 'tracks':
|
||||
counts = getProvidersWithTracks();
|
||||
break;
|
||||
case 'playlists':
|
||||
counts = getProvidersWithPlaylists();
|
||||
break;
|
||||
case 'audiobooks':
|
||||
counts = getProvidersWithAudiobooks();
|
||||
break;
|
||||
case 'radio':
|
||||
counts = getProvidersWithRadio();
|
||||
break;
|
||||
case 'podcasts':
|
||||
counts = getProvidersWithPodcasts();
|
||||
break;
|
||||
default:
|
||||
counts = {};
|
||||
}
|
||||
|
||||
// Build list of providers that support this category, with their item counts
|
||||
// Only include providers that have at least 1 item (hides providers not synced to library)
|
||||
// Multiple instances of the same domain are allowed (e.g., two Spotify accounts)
|
||||
// but we dedupe if names match exactly (same account re-added with new instance ID)
|
||||
final result = <(ProviderInstance, int)>[];
|
||||
final addedInstanceIds = <String>{};
|
||||
final addedNames = <String, int>{}; // Track name -> count to dedupe same-name providers
|
||||
|
||||
for (final provider in availableMusicProviders) {
|
||||
// Only include providers that support this content type AND have items
|
||||
if (provider.supportsContentType(category)) {
|
||||
final count = counts[provider.instanceId] ?? 0;
|
||||
if (count > 0) {
|
||||
final existingCount = addedNames[provider.name];
|
||||
if (existingCount == null) {
|
||||
// First provider with this name
|
||||
result.add((provider, count));
|
||||
addedInstanceIds.add(provider.instanceId);
|
||||
addedNames[provider.name] = count;
|
||||
} else if (count > existingCount) {
|
||||
// Same name but more items - likely same account re-added, keep better one
|
||||
result.removeWhere((r) => r.$1.name == provider.name);
|
||||
result.add((provider, count));
|
||||
addedInstanceIds.add(provider.instanceId);
|
||||
addedNames[provider.name] = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also include providers that have items but aren't in availableMusicProviders
|
||||
// (e.g., search-only providers like iTunes, or orphaned items from removed providers)
|
||||
// For these synthetic providers, dedupe by domain since we don't have account-specific names
|
||||
final addedRealDomains = result.map((r) => r.$1.domain).toSet();
|
||||
final addedSyntheticDomains = <String>{};
|
||||
|
||||
for (final entry in counts.entries) {
|
||||
if (!addedInstanceIds.contains(entry.key) && entry.value > 0) {
|
||||
// Create a synthetic ProviderInstance from the instance ID
|
||||
// Format is typically "domain--uniqueId" (e.g., "itunes--abc123")
|
||||
final instanceId = entry.key;
|
||||
final domain = instanceId.contains('--')
|
||||
? instanceId.split('--').first
|
||||
: instanceId;
|
||||
|
||||
// Skip if this domain is already represented by a real provider or synthetic
|
||||
if (addedRealDomains.contains(domain)) continue;
|
||||
if (addedSyntheticDomains.contains(domain)) continue;
|
||||
|
||||
// Only add if this domain supports the category
|
||||
final capabilities = ProviderInstance.providerCapabilities[domain];
|
||||
if (capabilities != null && capabilities.contains(category)) {
|
||||
result.add((
|
||||
ProviderInstance(
|
||||
instanceId: instanceId,
|
||||
domain: domain,
|
||||
name: _formatProviderName(domain),
|
||||
available: true,
|
||||
),
|
||||
entry.value,
|
||||
));
|
||||
addedSyntheticDomains.add(domain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by name
|
||||
result.sort((a, b) => a.$1.name.compareTo(b.$1.name));
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Format a provider domain into a readable display name.
|
||||
String _formatProviderName(String domain) {
|
||||
const names = {
|
||||
'spotify': 'Spotify',
|
||||
'tidal': 'Tidal',
|
||||
'qobuz': 'Qobuz',
|
||||
'deezer': 'Deezer',
|
||||
'ytmusic': 'YouTube Music',
|
||||
'soundcloud': 'SoundCloud',
|
||||
'apple_music': 'Apple Music',
|
||||
'amazon_music': 'Amazon Music',
|
||||
'plex': 'Plex',
|
||||
'jellyfin': 'Jellyfin',
|
||||
'emby': 'Emby',
|
||||
'subsonic': 'Subsonic',
|
||||
'opensubsonic': 'OpenSubsonic',
|
||||
'gmusic': 'Google Music',
|
||||
'bandcamp': 'Bandcamp',
|
||||
'youtube': 'YouTube',
|
||||
'itunes': 'iTunes/Apple',
|
||||
'spotifyfree': 'Spotify Free',
|
||||
'tunein': 'TuneIn',
|
||||
'radio_net': 'Radio.net',
|
||||
'dirble': 'Dirble',
|
||||
'freq': 'FREQ',
|
||||
'radionomy': 'Radionomy',
|
||||
};
|
||||
return names[domain] ?? domain.split('_').map((s) => s[0].toUpperCase() + s.substring(1)).join(' ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/error_handler.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
|
||||
/// Service for managing player queue operations.
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to isolate queue logic including:
|
||||
/// - Queue fetching from API with player ID translation
|
||||
/// - Queue caching to database for instant display
|
||||
/// - Queue restoration from cache on app resume
|
||||
/// - Track validation for queue items
|
||||
class QueueManagerService {
|
||||
final MusicAssistantAPI? _api;
|
||||
final DatabaseService _database;
|
||||
final DebugLogger _logger;
|
||||
final List<Player> Function() getAvailablePlayers;
|
||||
final Map<String, String> Function() getCastToSendspinMap;
|
||||
final Future<void> Function(String playerId, List<Track> tracks, {int startIndex}) playTracks;
|
||||
|
||||
QueueManagerService({
|
||||
required MusicAssistantAPI? api,
|
||||
required DatabaseService database,
|
||||
required DebugLogger logger,
|
||||
required this.getAvailablePlayers,
|
||||
required this.getCastToSendspinMap,
|
||||
required this.playTracks,
|
||||
}) : _api = api,
|
||||
_database = database,
|
||||
_logger = logger;
|
||||
|
||||
/// Fetch queue for a player from the API.
|
||||
///
|
||||
/// Handles:
|
||||
/// - Group child players (fetches leader's queue)
|
||||
/// - Cast-to-Sendspin ID translation
|
||||
/// - Automatic caching to database
|
||||
Future<PlayerQueue?> getQueue(String playerId) async {
|
||||
final effectivePlayerId = _getEffectivePlayerId(playerId);
|
||||
|
||||
final queue = await _api?.getQueue(effectivePlayerId);
|
||||
|
||||
// Persist queue to database for instant display on app resume
|
||||
if (queue != null) {
|
||||
persistQueueToDatabase(playerId, queue);
|
||||
}
|
||||
|
||||
return queue;
|
||||
}
|
||||
|
||||
/// Get cached queue for instant display (before API refresh).
|
||||
Future<PlayerQueue?> getCachedQueue(String playerId) async {
|
||||
try {
|
||||
if (!_database.isInitialized) return null;
|
||||
|
||||
final cachedItems = await _database.getCachedQueue(playerId);
|
||||
if (cachedItems.isEmpty) return null;
|
||||
|
||||
final items = <QueueItem>[];
|
||||
for (final cached in cachedItems) {
|
||||
try {
|
||||
final itemData = jsonDecode(cached.itemJson) as Map<String, dynamic>;
|
||||
items.add(QueueItem.fromJson(itemData));
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error parsing cached queue item: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (items.isEmpty) return null;
|
||||
|
||||
return PlayerQueue(
|
||||
playerId: playerId,
|
||||
items: items,
|
||||
currentIndex: 0, // Will be updated from fresh data
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error loading cached queue: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist queue to database for app restart persistence.
|
||||
void persistQueueToDatabase(String playerId, PlayerQueue queue) {
|
||||
() async {
|
||||
try {
|
||||
if (!_database.isInitialized) return;
|
||||
|
||||
final itemJsonList = queue.items.map((item) => jsonEncode(item.toJson())).toList();
|
||||
await _database.saveQueue(playerId, itemJsonList);
|
||||
_logger.log('💾 Persisted ${queue.items.length} queue items to database');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error persisting queue to database: $e');
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
/// Attempt to restore the queue from cached data and start playback.
|
||||
Future<bool> restoreQueueFromCache(String playerId) async {
|
||||
try {
|
||||
final cachedQueue = await getCachedQueue(playerId);
|
||||
if (cachedQueue == null || cachedQueue.items.isEmpty) {
|
||||
_logger.log('⚠️ No cached queue to restore');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract and validate tracks from queue items
|
||||
final tracks = _validateQueueTracks(cachedQueue.items);
|
||||
|
||||
if (tracks.isEmpty) {
|
||||
_logger.log('⚠️ Cached queue has no valid tracks');
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.log('🔄 Restoring ${tracks.length} tracks from cached queue');
|
||||
|
||||
// Re-queue all tracks and start playback
|
||||
await playTracks(playerId, tracks, startIndex: 0);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error restoring queue from cache: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate and filter tracks from queue items.
|
||||
///
|
||||
/// Returns only tracks that can be played (have available provider mappings
|
||||
/// or valid provider/itemId combination).
|
||||
List<Track> _validateQueueTracks(List<QueueItem> queueItems) {
|
||||
return queueItems
|
||||
.map((item) => item.track)
|
||||
.where((track) {
|
||||
// Check for providerMappings with at least one available entry
|
||||
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
|
||||
return track.providerMappings!.any((m) => m.available);
|
||||
}
|
||||
// Fallback: provider + itemId can be used to construct URI
|
||||
return track.provider.isNotEmpty && track.itemId.isNotEmpty;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Get the effective player ID for queue operations.
|
||||
///
|
||||
/// Handles:
|
||||
/// - Group child players (returns leader's ID)
|
||||
/// - Cast-to-Sendspin ID translation
|
||||
String _getEffectivePlayerId(String playerId) {
|
||||
String effectivePlayerId = playerId;
|
||||
final availablePlayers = getAvailablePlayers();
|
||||
final castToSendspinMap = getCastToSendspinMap();
|
||||
|
||||
final player = availablePlayers.firstWhere(
|
||||
(p) => p.playerId == playerId,
|
||||
orElse: () => Player(
|
||||
playerId: playerId,
|
||||
name: '',
|
||||
available: false,
|
||||
powered: false,
|
||||
state: 'idle',
|
||||
),
|
||||
);
|
||||
|
||||
// If this player is a group child, fetch the leader's queue instead
|
||||
// This ensures grouped players show the same queue as their leader
|
||||
if (player.isGroupChild && player.syncedTo != null) {
|
||||
_logger.log('🔗 Player $playerId is grouped, fetching leader queue: ${player.syncedTo}');
|
||||
effectivePlayerId = player.syncedTo!;
|
||||
}
|
||||
|
||||
// Translate Sendspin ID to Cast UUID for queue fetch
|
||||
// MA stores queues under the Cast UUID, not the Sendspin ID
|
||||
effectivePlayerId = _translateSendspinToCastId(effectivePlayerId, availablePlayers, castToSendspinMap);
|
||||
|
||||
return effectivePlayerId;
|
||||
}
|
||||
|
||||
/// Translate Sendspin ID to Cast UUID for queue operations.
|
||||
///
|
||||
/// MA stores queues under the Cast UUID, not the Sendspin ID.
|
||||
/// Format: cast-7df484e3 -> need Cast UUID starting with 7df484e3
|
||||
String _translateSendspinToCastId(
|
||||
String playerId,
|
||||
List<Player> availablePlayers,
|
||||
Map<String, String> castToSendspinMap,
|
||||
) {
|
||||
if (!playerId.startsWith('cast-') || playerId.length < 13) {
|
||||
return playerId;
|
||||
}
|
||||
|
||||
final prefix = playerId.substring(5); // Remove "cast-"
|
||||
|
||||
// Reverse lookup in the map
|
||||
for (final entry in castToSendspinMap.entries) {
|
||||
if (entry.value == playerId) {
|
||||
_logger.log('🔗 Translated Sendspin ID $playerId to Cast UUID ${entry.key} for queue fetch');
|
||||
return entry.key;
|
||||
}
|
||||
}
|
||||
|
||||
// If not in map, try to find in available players
|
||||
for (final p in availablePlayers) {
|
||||
if (p.provider == 'chromecast' && p.playerId.startsWith(prefix)) {
|
||||
_logger.log('🔗 Found Cast UUID ${p.playerId} for Sendspin ID $playerId via player lookup');
|
||||
return p.playerId;
|
||||
}
|
||||
}
|
||||
|
||||
return playerId;
|
||||
}
|
||||
|
||||
/// Clear cached queue for a player.
|
||||
Future<void> clearCachedQueue(String playerId) async {
|
||||
await _database.clearCachedQueue(playerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import '../models/media_item.dart';
|
||||
import '../services/cache_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
|
||||
/// Service for handling search operations with caching.
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to isolate search logic.
|
||||
class SearchService {
|
||||
final MusicAssistantAPI? _api;
|
||||
final CacheService _cacheService;
|
||||
final DebugLogger _logger;
|
||||
|
||||
SearchService({
|
||||
required MusicAssistantAPI? api,
|
||||
required CacheService cacheService,
|
||||
required DebugLogger logger,
|
||||
}) : _api = api,
|
||||
_cacheService = cacheService,
|
||||
_logger = logger;
|
||||
|
||||
/// Search with caching support.
|
||||
///
|
||||
/// Returns cached results if available and not expired.
|
||||
/// Otherwise fetches from API and caches the results.
|
||||
Future<Map<String, List<MediaItem>>> searchWithCache(
|
||||
String query, {
|
||||
bool forceRefresh = false,
|
||||
bool libraryOnly = false,
|
||||
}) async {
|
||||
final baseKey = query.toLowerCase().trim();
|
||||
if (baseKey.isEmpty) {
|
||||
return _emptySearchResults();
|
||||
}
|
||||
|
||||
// Include libraryOnly in cache key to separate results
|
||||
final cacheKey = libraryOnly ? '$baseKey:library' : baseKey;
|
||||
|
||||
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached search results for "$query" (libraryOnly: $libraryOnly)');
|
||||
return _cacheService.getCachedSearchResults(cacheKey)!;
|
||||
}
|
||||
|
||||
if (_api == null) {
|
||||
return _cacheService.getCachedSearchResults(cacheKey) ?? _emptySearchResults();
|
||||
}
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Searching for "$query" (libraryOnly: $libraryOnly)...');
|
||||
final results = await _api!.search(query, libraryOnly: libraryOnly);
|
||||
|
||||
final cachedResults = <String, List<MediaItem>>{
|
||||
'artists': results['artists'] ?? [],
|
||||
'albums': results['albums'] ?? [],
|
||||
'tracks': results['tracks'] ?? [],
|
||||
'playlists': results['playlists'] ?? [],
|
||||
'audiobooks': results['audiobooks'] ?? [],
|
||||
};
|
||||
|
||||
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
|
||||
_logger.log('✅ Cached search results for "$query"');
|
||||
return cachedResults;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Search failed: $e');
|
||||
return _cacheService.getCachedSearchResults(cacheKey) ?? _emptySearchResults();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all search and detail caches.
|
||||
void clearAllDetailCaches() {
|
||||
_cacheService.clearAllDetailCaches();
|
||||
}
|
||||
|
||||
/// Check if search cache is valid for a query.
|
||||
bool isSearchCacheValid(String query, {bool forceRefresh = false}) {
|
||||
final baseKey = query.toLowerCase().trim();
|
||||
return _cacheService.isSearchCacheValid(baseKey, forceRefresh: forceRefresh);
|
||||
}
|
||||
|
||||
/// Get cached search results for a query.
|
||||
Map<String, List<MediaItem>>? getCachedSearchResults(String query, {bool libraryOnly = false}) {
|
||||
final baseKey = query.toLowerCase().trim();
|
||||
final cacheKey = libraryOnly ? '$baseKey:library' : baseKey;
|
||||
return _cacheService.getCachedSearchResults(cacheKey);
|
||||
}
|
||||
|
||||
Map<String, List<MediaItem>> _emptySearchResults() {
|
||||
return {
|
||||
'artists': [],
|
||||
'albums': [],
|
||||
'tracks': [],
|
||||
'playlists': [],
|
||||
'audiobooks': [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -657,7 +657,10 @@ class SendspinService {
|
||||
void _updateState(SendspinConnectionState newState) {
|
||||
if (_state != newState) {
|
||||
_state = newState;
|
||||
_stateController.add(newState);
|
||||
// Don't try to emit if disposed or controller is closed
|
||||
if (!_isDisposed && !_stateController.isClosed) {
|
||||
_stateController.add(newState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 _keyShowDiscoveryFolders = 'show_discovery_folders';
|
||||
static const String _keyShowContinueListeningAudiobooks = 'show_continue_listening_audiobooks';
|
||||
static const String _keyShowDiscoverAudiobooks = 'show_discover_audiobooks';
|
||||
static const String _keyShowDiscoverSeries = 'show_discover_series';
|
||||
@@ -108,20 +107,11 @@ class SettingsService {
|
||||
static const String _keyShowOnlyArtistsWithAlbums = 'show_only_artists_with_albums'; // Library artists filter
|
||||
static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs
|
||||
|
||||
// Default row order
|
||||
// Default row order (only these 3 rows enabled by default on first launch)
|
||||
static const List<String> defaultHomeRowOrder = [
|
||||
'recent-albums',
|
||||
'discover-artists',
|
||||
'discover-albums',
|
||||
'continue-listening',
|
||||
'discover-audiobooks',
|
||||
'discover-series',
|
||||
'favorite-albums',
|
||||
'favorite-artists',
|
||||
'favorite-tracks',
|
||||
'favorite-playlists',
|
||||
'favorite-radio-stations',
|
||||
'favorite-podcasts',
|
||||
];
|
||||
|
||||
// View Mode Settings
|
||||
@@ -179,6 +169,9 @@ class SettingsService {
|
||||
// Podcast Cover Cache (iTunes URLs for high-res artwork)
|
||||
static const String _keyPodcastCoverCache = 'podcast_cover_cache';
|
||||
|
||||
// Discovery Row Preferences (per-row settings for Music Assistant recommendations)
|
||||
static const String _keyDiscoveryRowPreferences = 'discovery_row_preferences';
|
||||
|
||||
static Future<String?> getServerUrl() async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyServerUrl);
|
||||
@@ -355,18 +348,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 +386,10 @@ class SettingsService {
|
||||
static Future<bool> getShowDiscoverAlbums() => _getBool(_keyShowDiscoverAlbums, defaultValue: true);
|
||||
static Future<void> setShowDiscoverAlbums(bool show) => _setBool(_keyShowDiscoverAlbums, show);
|
||||
|
||||
// Home Screen Discovery Folders (default off - provider mixes like Tidal)
|
||||
static Future<bool> getShowDiscoveryFolders() => _getBool(_keyShowDiscoveryFolders, defaultValue: false);
|
||||
static Future<void> setShowDiscoveryFolders(bool show) => _setBool(_keyShowDiscoveryFolders, 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);
|
||||
@@ -949,7 +938,7 @@ class SettingsService {
|
||||
/// for fine-grained control (10x more precise)
|
||||
static Future<bool> getVolumePrecisionMode() async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getBool(_keyVolumePrecisionMode) ?? true;
|
||||
return prefs.getBool(_keyVolumePrecisionMode) ?? false;
|
||||
}
|
||||
|
||||
/// Set whether volume precision mode is enabled
|
||||
@@ -986,4 +975,46 @@ class SettingsService {
|
||||
cache[podcastId] = url;
|
||||
await setPodcastCoverCache(cache);
|
||||
}
|
||||
|
||||
// Discovery Row Preferences (per-row settings for Music Assistant recommendations)
|
||||
|
||||
/// Get all discovery row preferences as a map (itemId -> enabled)
|
||||
/// Defaults to all enabled (true) if not set
|
||||
static Future<Map<String, bool>> getDiscoveryRowPreferences() async {
|
||||
final prefs = await _getPrefs();
|
||||
final json = prefs.getString(_keyDiscoveryRowPreferences);
|
||||
if (json == null) return {}; // Empty map = all enabled by default
|
||||
try {
|
||||
final decoded = jsonDecode(json) as Map<String, dynamic>;
|
||||
return decoded.map((key, value) => MapEntry(key, value as bool));
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Set all discovery row preferences at once
|
||||
static Future<void> setDiscoveryRowPreferences(Map<String, bool> prefs) async {
|
||||
final sharedPrefs = await _getPrefs();
|
||||
await sharedPrefs.setString(_keyDiscoveryRowPreferences, jsonEncode(prefs));
|
||||
}
|
||||
|
||||
/// Get whether a specific discovery row is enabled
|
||||
/// Returns false if not explicitly set (default off)
|
||||
static Future<bool> getDiscoveryRowPreference(String itemId) async {
|
||||
final allPrefs = await getDiscoveryRowPreferences();
|
||||
return allPrefs[itemId] ?? false; // Default to disabled
|
||||
}
|
||||
|
||||
/// Set whether a specific discovery row is enabled
|
||||
static Future<void> setDiscoveryRowPreference(String itemId, bool enabled) async {
|
||||
final allPrefs = await getDiscoveryRowPreferences();
|
||||
allPrefs[itemId] = enabled;
|
||||
await setDiscoveryRowPreferences(allPrefs);
|
||||
}
|
||||
|
||||
/// Clear all discovery row preferences (reverts to all enabled)
|
||||
static Future<void> clearDiscoveryRowPreferences() async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.remove(_keyDiscoveryRowPreferences);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,4 +596,78 @@ class SyncService with ChangeNotifier {
|
||||
_cachedPlaylists = playlists;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Remove an item from in-memory cache by library ID
|
||||
/// Called when an item is removed from library to keep SyncService in sync
|
||||
void removeFromCacheByLibraryId(String mediaType, int libraryItemId) {
|
||||
final libraryIdStr = libraryItemId.toString();
|
||||
bool updated = false;
|
||||
|
||||
// Helper to check if item matches the library ID being removed
|
||||
bool matchesLibraryId(String? provider, String? itemId, List<ProviderMapping>? mappings) {
|
||||
if (provider == 'library' && itemId == libraryIdStr) return true;
|
||||
if (mappings != null) {
|
||||
for (final m in mappings) {
|
||||
if ((m.providerInstance == 'library' || m.providerDomain == 'library') &&
|
||||
m.itemId == libraryIdStr) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (mediaType) {
|
||||
case 'album':
|
||||
final before = _cachedAlbums.length;
|
||||
_cachedAlbums = _cachedAlbums.where((a) =>
|
||||
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
|
||||
).toList();
|
||||
updated = _cachedAlbums.length != before;
|
||||
// Also remove from source provider tracking
|
||||
if (updated) {
|
||||
_albumSourceProviders.removeWhere((key, _) =>
|
||||
_cachedAlbums.every((a) => a.itemId != key));
|
||||
}
|
||||
break;
|
||||
case 'artist':
|
||||
final before = _cachedArtists.length;
|
||||
_cachedArtists = _cachedArtists.where((a) =>
|
||||
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
|
||||
).toList();
|
||||
updated = _cachedArtists.length != before;
|
||||
if (updated) {
|
||||
_artistSourceProviders.removeWhere((key, _) =>
|
||||
_cachedArtists.every((a) => a.itemId != key));
|
||||
}
|
||||
break;
|
||||
case 'audiobook':
|
||||
final before = _cachedAudiobooks.length;
|
||||
_cachedAudiobooks = _cachedAudiobooks.where((a) =>
|
||||
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
|
||||
).toList();
|
||||
updated = _cachedAudiobooks.length != before;
|
||||
if (updated) {
|
||||
_audiobookSourceProviders.removeWhere((key, _) =>
|
||||
_cachedAudiobooks.every((a) => a.itemId != key));
|
||||
}
|
||||
break;
|
||||
case 'playlist':
|
||||
final before = _cachedPlaylists.length;
|
||||
_cachedPlaylists = _cachedPlaylists.where((p) =>
|
||||
!matchesLibraryId(p.provider, p.itemId, p.providerMappings)
|
||||
).toList();
|
||||
updated = _cachedPlaylists.length != before;
|
||||
if (updated) {
|
||||
_playlistSourceProviders.removeWhere((key, _) =>
|
||||
_cachedPlaylists.every((p) => p.itemId != key));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
_logger.log('🗑️ Removed $mediaType with libraryId=$libraryItemId from SyncService cache');
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_color_utilities/material_color_utilities.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -8,6 +10,16 @@ import '../services/debug_logger.dart';
|
||||
|
||||
final _logger = DebugLogger();
|
||||
|
||||
/// Minimum contrast ratio for UI components (WCAG 2.1 Level AA)
|
||||
const double _minContrastRatio = 3.0;
|
||||
|
||||
/// Minimum chroma for primary colors to ensure they're visually distinct
|
||||
/// Material Design 3 uses 48 for vibrant, but we use 36 as a balance
|
||||
const double _minPrimaryChroma = 36.0;
|
||||
|
||||
/// Minimum chroma for surface/container colors (lower is fine for backgrounds)
|
||||
const double _minSurfaceChroma = 6.0;
|
||||
|
||||
/// Extracted adaptive colors from album art
|
||||
class AdaptiveColors {
|
||||
final Color primary; // Main accent color (vibrant)
|
||||
@@ -50,11 +62,96 @@ class _ExtractedColors {
|
||||
});
|
||||
}
|
||||
|
||||
/// Helper to get contrasting text color based on background luminance
|
||||
/// Calculate WCAG 2.1 contrast ratio between two colors
|
||||
double _contrastRatio(Color c1, Color c2) {
|
||||
final l1 = c1.computeLuminance();
|
||||
final l2 = c2.computeLuminance();
|
||||
final lighter = math.max(l1, l2);
|
||||
final darker = math.min(l1, l2);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/// Helper to get contrasting text color based on WCAG contrast ratio
|
||||
Color getContrastingTextColor(Color backgroundColor) {
|
||||
// Use relative luminance to determine if we need light or dark text
|
||||
// Standard threshold is 0.179 based on WCAG guidelines
|
||||
return backgroundColor.computeLuminance() > 0.4 ? Colors.black : Colors.white;
|
||||
// Calculate contrast with both black and white, pick the one with better contrast
|
||||
final contrastWithWhite = _contrastRatio(backgroundColor, Colors.white);
|
||||
final contrastWithBlack = _contrastRatio(backgroundColor, Colors.black);
|
||||
return contrastWithWhite >= contrastWithBlack ? Colors.white : Colors.black;
|
||||
}
|
||||
|
||||
/// Adjust a color's tone using HCT to ensure minimum contrast against a background
|
||||
/// Uses binary search to find the MINIMUM tone adjustment needed (preserves color identity)
|
||||
Color _ensureContrastHct(Color foreground, Color background, {double minRatio = _minContrastRatio}) {
|
||||
final fgHct = Hct.fromInt(foreground.value);
|
||||
final bgHct = Hct.fromInt(background.value);
|
||||
|
||||
// Ensure foreground has enough chroma to be visually distinct
|
||||
final chroma = math.max(fgHct.chroma, _minPrimaryChroma);
|
||||
|
||||
final currentRatio = _contrastRatio(foreground, background);
|
||||
if (currentRatio >= minRatio) {
|
||||
// Already has sufficient contrast, just ensure chroma
|
||||
if (fgHct.chroma < _minPrimaryChroma) {
|
||||
return Color(Hct.from(fgHct.hue, chroma, fgHct.tone).toInt());
|
||||
}
|
||||
return foreground;
|
||||
}
|
||||
|
||||
// For dark backgrounds (tone < 50), we need lighter foreground
|
||||
// For light backgrounds (tone >= 50), we need darker foreground
|
||||
final bgIsDark = bgHct.tone < 50;
|
||||
|
||||
// Binary search for the minimum tone that achieves target contrast
|
||||
double lo, hi;
|
||||
if (bgIsDark) {
|
||||
// Search upward from current tone toward white
|
||||
lo = fgHct.tone;
|
||||
hi = 100.0;
|
||||
} else {
|
||||
// Search downward from current tone toward black
|
||||
lo = 0.0;
|
||||
hi = fgHct.tone;
|
||||
}
|
||||
|
||||
double bestTone = bgIsDark ? hi : lo; // Fallback to extreme if needed
|
||||
|
||||
for (int i = 0; i < 20; i++) {
|
||||
final mid = (lo + hi) / 2;
|
||||
final testColor = Hct.from(fgHct.hue, chroma, mid);
|
||||
final testRatio = _contrastRatio(Color(testColor.toInt()), background);
|
||||
|
||||
if (bgIsDark) {
|
||||
// We want lighter - if mid works, try lower (closer to original)
|
||||
if (testRatio >= minRatio) {
|
||||
bestTone = mid;
|
||||
hi = mid; // Try to find a lower tone that still works
|
||||
} else {
|
||||
lo = mid; // Need to go lighter
|
||||
}
|
||||
} else {
|
||||
// We want darker - if mid works, try higher (closer to original)
|
||||
if (testRatio >= minRatio) {
|
||||
bestTone = mid;
|
||||
lo = mid; // Try to find a higher tone that still works
|
||||
} else {
|
||||
hi = mid; // Need to go darker
|
||||
}
|
||||
}
|
||||
|
||||
if (hi - lo < 0.5) break; // Precision reached
|
||||
}
|
||||
|
||||
return Color(Hct.from(fgHct.hue, chroma, bestTone).toInt());
|
||||
}
|
||||
|
||||
/// Ensure a color has minimum chroma (colorfulness) for primary use
|
||||
Color _ensureMinChroma(Color color) {
|
||||
final hct = Hct.fromInt(color.value);
|
||||
if (hct.chroma >= _minPrimaryChroma) {
|
||||
return color;
|
||||
}
|
||||
final adjusted = Hct.from(hct.hue, _minPrimaryChroma, hct.tone);
|
||||
return Color(adjusted.toInt());
|
||||
}
|
||||
|
||||
/// Top-level function for isolate-based color extraction
|
||||
@@ -156,11 +253,22 @@ class PaletteHelper {
|
||||
static final Map<String, Uint8List> _imageCache = {};
|
||||
static const int _maxCacheSize = 20;
|
||||
|
||||
/// Cache for extracted color schemes to avoid re-processing
|
||||
static final Map<String, (ColorScheme, ColorScheme)> _colorSchemeCache = {};
|
||||
static const int _maxColorSchemeCacheSize = 30;
|
||||
|
||||
/// Extract color schemes from a URL using isolate-based processing
|
||||
/// This is the preferred method as it doesn't block the main thread
|
||||
/// Results are cached for instant retrieval on subsequent calls
|
||||
static Future<(ColorScheme, ColorScheme)?> extractColorSchemesFromUrl(String imageUrl) async {
|
||||
try {
|
||||
// Check cache first
|
||||
// Check color scheme cache first for instant return
|
||||
final cachedSchemes = _colorSchemeCache[imageUrl];
|
||||
if (cachedSchemes != null) {
|
||||
return cachedSchemes;
|
||||
}
|
||||
|
||||
// Check image bytes cache
|
||||
Uint8List? imageBytes = _imageCache[imageUrl];
|
||||
|
||||
if (imageBytes == null) {
|
||||
@@ -184,14 +292,22 @@ class PaletteHelper {
|
||||
if (extractedColors == null) return null;
|
||||
|
||||
// Convert to ColorSchemes on main thread
|
||||
return _buildColorSchemes(extractedColors);
|
||||
final colorSchemes = _buildColorSchemes(extractedColors);
|
||||
|
||||
// Cache the color schemes for instant access next time
|
||||
if (_colorSchemeCache.length >= _maxColorSchemeCacheSize) {
|
||||
_colorSchemeCache.remove(_colorSchemeCache.keys.first);
|
||||
}
|
||||
_colorSchemeCache[imageUrl] = colorSchemes;
|
||||
|
||||
return colorSchemes;
|
||||
} catch (e) {
|
||||
_logger.warning('Failed to extract colors from URL: $e', context: 'Palette');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Build ColorSchemes from extracted color data
|
||||
/// Build ColorSchemes from extracted color data using HCT color space
|
||||
static (ColorScheme, ColorScheme) _buildColorSchemes(_ExtractedColors colors) {
|
||||
// Convert int colors to Color objects
|
||||
final vibrant = Color(colors.vibrantColor);
|
||||
@@ -201,69 +317,85 @@ class PaletteHelper {
|
||||
final muted = Color(colors.mutedColor);
|
||||
final lightMuted = Color(colors.lightMutedColor);
|
||||
|
||||
// Get primary color - prefer vibrant, then dominant
|
||||
// Get primary color - prefer vibrant, then lightVibrant, then dominant
|
||||
Color primary = vibrant;
|
||||
if (vibrant.computeLuminance() < 0.1) {
|
||||
final vibrantHct = Hct.fromInt(vibrant.value);
|
||||
if (vibrantHct.tone < 20 || vibrantHct.chroma < 8) {
|
||||
primary = lightVibrant;
|
||||
}
|
||||
if (primary.computeLuminance() < 0.1) {
|
||||
final primaryHct = Hct.fromInt(primary.value);
|
||||
if (primaryHct.tone < 20 || primaryHct.chroma < 8) {
|
||||
primary = dominant;
|
||||
}
|
||||
|
||||
// Ensure primary has enough saturation
|
||||
final hslPrimary = HSLColor.fromColor(primary);
|
||||
Color adjustedPrimary = primary;
|
||||
if (hslPrimary.saturation < 0.3) {
|
||||
adjustedPrimary = hslPrimary.withSaturation(0.5).toColor();
|
||||
}
|
||||
if (adjustedPrimary.computeLuminance() < 0.15) {
|
||||
final hsl = HSLColor.fromColor(adjustedPrimary);
|
||||
adjustedPrimary = hsl.withLightness((hsl.lightness + 0.25).clamp(0.3, 0.6)).toColor();
|
||||
}
|
||||
// Use HCT for perceptually accurate color adjustment
|
||||
// Ensure minimum chroma (colorfulness) so colors aren't muddy
|
||||
Color adjustedPrimary = _ensureMinChroma(primary);
|
||||
|
||||
// Build dark mode colors
|
||||
final hslDarkSurface = HSLColor.fromColor(darkMuted);
|
||||
final darkSurface = hslDarkSurface
|
||||
.withLightness((hslDarkSurface.lightness * 0.3).clamp(0.05, 0.15))
|
||||
.toColor();
|
||||
final darkMiniPlayer = hslDarkSurface
|
||||
.withLightness(0.3.clamp(0.25, 0.38))
|
||||
.withSaturation((hslDarkSurface.saturation * 1.2).clamp(0.15, 0.5))
|
||||
.toColor();
|
||||
// Build dark mode colors using HCT
|
||||
final darkMutedHct = Hct.fromInt(darkMuted.value);
|
||||
final darkSurface = Color(Hct.from(
|
||||
darkMutedHct.hue,
|
||||
math.min(darkMutedHct.chroma, _minSurfaceChroma), // Low chroma for surface
|
||||
math.max(darkMutedHct.tone * 0.3, 5.0).clamp(5.0, 15.0), // Very dark
|
||||
).toInt());
|
||||
final darkMiniPlayer = Color(Hct.from(
|
||||
darkMutedHct.hue,
|
||||
(darkMutedHct.chroma * 1.2).clamp(4, 16),
|
||||
30, // Fixed tone for consistency
|
||||
).toInt());
|
||||
|
||||
// Build light mode colors
|
||||
final hslLightSurface = HSLColor.fromColor(lightMuted);
|
||||
final lightSurface = hslLightSurface
|
||||
.withLightness(hslLightSurface.lightness.clamp(0.92, 0.98))
|
||||
.toColor();
|
||||
final lightMiniPlayer = hslLightSurface
|
||||
.withLightness(0.75.clamp(0.65, 0.8))
|
||||
.withSaturation((hslLightSurface.saturation * 1.2).clamp(0.15, 0.5))
|
||||
.toColor();
|
||||
// Build light mode colors using HCT
|
||||
final lightMutedHct = Hct.fromInt(lightMuted.value);
|
||||
final lightSurface = Color(Hct.from(
|
||||
lightMutedHct.hue,
|
||||
math.min(lightMutedHct.chroma, _minSurfaceChroma),
|
||||
95, // Very light
|
||||
).toInt());
|
||||
final lightMiniPlayer = Color(Hct.from(
|
||||
lightMutedHct.hue,
|
||||
(lightMutedHct.chroma * 1.2).clamp(4, 16),
|
||||
75,
|
||||
).toInt());
|
||||
|
||||
// Generate secondary container colors using HCT
|
||||
// Dark secondary container at tone 25 for tonal buttons
|
||||
final primaryHctFinal = Hct.fromInt(adjustedPrimary.value);
|
||||
final darkSecondaryContainer = Color(Hct.from(
|
||||
primaryHctFinal.hue,
|
||||
(primaryHctFinal.chroma * 0.4).clamp(4, 16),
|
||||
25, // Fixed tone for dark mode tonal buttons
|
||||
).toInt());
|
||||
final lightSecondaryContainer = Color(Hct.from(
|
||||
primaryHctFinal.hue,
|
||||
(primaryHctFinal.chroma * 0.3).clamp(4, 12),
|
||||
90, // Fixed tone for light mode tonal buttons
|
||||
).toInt());
|
||||
|
||||
// CRITICAL: Ensure primary has sufficient contrast against secondaryContainer
|
||||
// This guarantees highlighted icons are visible on tonal buttons
|
||||
final darkAdjustedPrimary = _ensureContrastHct(
|
||||
adjustedPrimary,
|
||||
darkSecondaryContainer,
|
||||
minRatio: _minContrastRatio,
|
||||
);
|
||||
final lightAdjustedPrimary = _ensureContrastHct(
|
||||
adjustedPrimary,
|
||||
lightSecondaryContainer,
|
||||
minRatio: _minContrastRatio,
|
||||
);
|
||||
|
||||
// Determine contrasting text colors
|
||||
final lightOnPrimary = getContrastingTextColor(adjustedPrimary);
|
||||
final darkOnPrimary = getContrastingTextColor(adjustedPrimary);
|
||||
final lightOnPrimary = getContrastingTextColor(lightAdjustedPrimary);
|
||||
final darkOnPrimary = getContrastingTextColor(darkAdjustedPrimary);
|
||||
final lightOnMiniPlayer = getContrastingTextColor(lightMiniPlayer);
|
||||
final darkOnMiniPlayer = getContrastingTextColor(darkMiniPlayer);
|
||||
|
||||
// Generate secondary container colors for FilledButton.tonal
|
||||
// These must be explicitly set or Flutter auto-generates them unpredictably
|
||||
final hslForSecondary = HSLColor.fromColor(adjustedPrimary);
|
||||
final lightSecondaryContainer = hslForSecondary
|
||||
.withLightness(0.9)
|
||||
.withSaturation((hslForSecondary.saturation * 0.5).clamp(0.1, 0.3))
|
||||
.toColor();
|
||||
final darkSecondaryContainer = hslForSecondary
|
||||
.withLightness(0.25)
|
||||
.withSaturation((hslForSecondary.saturation * 0.6).clamp(0.15, 0.4))
|
||||
.toColor();
|
||||
|
||||
final lightScheme = ColorScheme(
|
||||
brightness: Brightness.light,
|
||||
primary: adjustedPrimary,
|
||||
primary: lightAdjustedPrimary,
|
||||
onPrimary: lightOnPrimary,
|
||||
secondary: adjustedPrimary.withOpacity(0.8),
|
||||
secondary: lightAdjustedPrimary.withOpacity(0.8),
|
||||
onSecondary: lightOnPrimary,
|
||||
secondaryContainer: lightSecondaryContainer,
|
||||
onSecondaryContainer: Colors.black87,
|
||||
@@ -277,9 +409,9 @@ class PaletteHelper {
|
||||
|
||||
final darkScheme = ColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
primary: adjustedPrimary,
|
||||
primary: darkAdjustedPrimary,
|
||||
onPrimary: darkOnPrimary,
|
||||
secondary: adjustedPrimary.withOpacity(0.8),
|
||||
secondary: darkAdjustedPrimary.withOpacity(0.8),
|
||||
onSecondary: darkOnPrimary,
|
||||
secondaryContainer: darkSecondaryContainer,
|
||||
onSecondaryContainer: Colors.white,
|
||||
@@ -294,9 +426,10 @@ class PaletteHelper {
|
||||
return (lightScheme, darkScheme);
|
||||
}
|
||||
|
||||
/// Clear the image cache
|
||||
/// Clear all caches (image bytes and color schemes)
|
||||
static void clearCache() {
|
||||
_imageCache.clear();
|
||||
_colorSchemeCache.clear();
|
||||
}
|
||||
|
||||
/// Extract a color palette from an image with higher color count for better variety
|
||||
@@ -314,7 +447,7 @@ class PaletteHelper {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract actual colors from the palette (not seed-based)
|
||||
/// Extract actual colors from the palette using HCT color space
|
||||
static AdaptiveColors? extractAdaptiveColors(PaletteGenerator? palette, {required bool isDark}) {
|
||||
if (palette == null) return null;
|
||||
|
||||
@@ -324,22 +457,9 @@ class PaletteHelper {
|
||||
palette.dominantColor?.color ??
|
||||
const Color(0xFF604CEC);
|
||||
|
||||
// Ensure primary has enough saturation to be visually distinct
|
||||
// AND enough lightness for white text to be readable on it
|
||||
final HSLColor hslPrimary = HSLColor.fromColor(primary);
|
||||
Color adjustedPrimary = primary;
|
||||
|
||||
// Adjust saturation if too low
|
||||
if (hslPrimary.saturation < 0.3) {
|
||||
adjustedPrimary = hslPrimary.withSaturation(0.5).toColor();
|
||||
}
|
||||
|
||||
// Ensure minimum lightness for contrast with white text
|
||||
// If primary is too dark (luminance < 0.15), lighten it
|
||||
if (adjustedPrimary.computeLuminance() < 0.15) {
|
||||
final hsl = HSLColor.fromColor(adjustedPrimary);
|
||||
adjustedPrimary = hsl.withLightness((hsl.lightness + 0.25).clamp(0.3, 0.6)).toColor();
|
||||
}
|
||||
// Use HCT for perceptually accurate color adjustment
|
||||
// Ensure minimum chroma (colorfulness) so colors aren't muddy
|
||||
Color adjustedPrimary = _ensureMinChroma(primary);
|
||||
|
||||
if (isDark) {
|
||||
// Dark mode: use dark muted colors for background
|
||||
@@ -347,18 +467,19 @@ class PaletteHelper {
|
||||
palette.mutedColor?.color ??
|
||||
const Color(0xFF121212);
|
||||
|
||||
// Darken the surface color significantly for the expanded background
|
||||
final HSLColor hslSurface = HSLColor.fromColor(surfaceBase);
|
||||
final Color surface = hslSurface
|
||||
.withLightness((hslSurface.lightness * 0.3).clamp(0.05, 0.15))
|
||||
.toColor();
|
||||
// Build surface colors using HCT
|
||||
final surfaceHct = Hct.fromInt(surfaceBase.value);
|
||||
final Color surface = Color(Hct.from(
|
||||
surfaceHct.hue,
|
||||
math.min(surfaceHct.chroma, _minSurfaceChroma),
|
||||
math.max(surfaceHct.tone * 0.3, 5.0).clamp(5.0, 15.0),
|
||||
).toInt());
|
||||
|
||||
// Mini player should be medium brightness - noticeably tinted but readable
|
||||
// Use the muted color but at medium lightness (0.25-0.35 range)
|
||||
final Color miniPlayer = hslSurface
|
||||
.withLightness(0.3.clamp(0.25, 0.38))
|
||||
.withSaturation((hslSurface.saturation * 1.2).clamp(0.15, 0.5))
|
||||
.toColor();
|
||||
final Color miniPlayer = Color(Hct.from(
|
||||
surfaceHct.hue,
|
||||
(surfaceHct.chroma * 1.2).clamp(4, 16),
|
||||
30,
|
||||
).toInt());
|
||||
|
||||
return AdaptiveColors(
|
||||
primary: adjustedPrimary,
|
||||
@@ -372,16 +493,18 @@ class PaletteHelper {
|
||||
palette.mutedColor?.color ??
|
||||
Colors.white;
|
||||
|
||||
final HSLColor hslSurface = HSLColor.fromColor(surfaceBase);
|
||||
final Color surface = hslSurface
|
||||
.withLightness((hslSurface.lightness).clamp(0.92, 0.98))
|
||||
.toColor();
|
||||
final surfaceHct = Hct.fromInt(surfaceBase.value);
|
||||
final Color surface = Color(Hct.from(
|
||||
surfaceHct.hue,
|
||||
math.min(surfaceHct.chroma, _minSurfaceChroma),
|
||||
95,
|
||||
).toInt());
|
||||
|
||||
// Mini player in light mode - medium tinted
|
||||
final Color miniPlayer = hslSurface
|
||||
.withLightness(0.75.clamp(0.65, 0.8))
|
||||
.withSaturation((hslSurface.saturation * 1.2).clamp(0.15, 0.5))
|
||||
.toColor();
|
||||
final Color miniPlayer = Color(Hct.from(
|
||||
surfaceHct.hue,
|
||||
(surfaceHct.chroma * 1.2).clamp(4, 16),
|
||||
75,
|
||||
).toInt());
|
||||
|
||||
return AdaptiveColors(
|
||||
primary: adjustedPrimary,
|
||||
@@ -392,7 +515,7 @@ class PaletteHelper {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate color schemes from a palette (kept for backward compatibility)
|
||||
/// Generate color schemes from a palette using HCT color space
|
||||
static (ColorScheme, ColorScheme)? generateColorSchemes(PaletteGenerator? palette) {
|
||||
if (palette == null) return null;
|
||||
|
||||
@@ -401,29 +524,44 @@ class PaletteHelper {
|
||||
|
||||
if (lightColors == null || darkColors == null) return null;
|
||||
|
||||
// Determine contrasting text colors based on primary color luminance
|
||||
final lightOnPrimary = getContrastingTextColor(lightColors.primary);
|
||||
final darkOnPrimary = getContrastingTextColor(darkColors.primary);
|
||||
// Generate secondary container colors using HCT
|
||||
final lightPrimaryHct = Hct.fromInt(lightColors.primary.value);
|
||||
final lightSecondaryContainer = Color(Hct.from(
|
||||
lightPrimaryHct.hue,
|
||||
(lightPrimaryHct.chroma * 0.3).clamp(4, 12),
|
||||
90, // Fixed tone for light mode tonal buttons
|
||||
).toInt());
|
||||
|
||||
final darkPrimaryHct = Hct.fromInt(darkColors.primary.value);
|
||||
final darkSecondaryContainer = Color(Hct.from(
|
||||
darkPrimaryHct.hue,
|
||||
(darkPrimaryHct.chroma * 0.4).clamp(4, 16),
|
||||
25, // Fixed tone for dark mode tonal buttons
|
||||
).toInt());
|
||||
|
||||
// CRITICAL: Ensure primary has sufficient contrast against secondaryContainer
|
||||
final lightAdjustedPrimary = _ensureContrastHct(
|
||||
lightColors.primary,
|
||||
lightSecondaryContainer,
|
||||
minRatio: _minContrastRatio,
|
||||
);
|
||||
final darkAdjustedPrimary = _ensureContrastHct(
|
||||
darkColors.primary,
|
||||
darkSecondaryContainer,
|
||||
minRatio: _minContrastRatio,
|
||||
);
|
||||
|
||||
// Determine contrasting text colors
|
||||
final lightOnPrimary = getContrastingTextColor(lightAdjustedPrimary);
|
||||
final darkOnPrimary = getContrastingTextColor(darkAdjustedPrimary);
|
||||
final lightOnMiniPlayer = getContrastingTextColor(lightColors.miniPlayer);
|
||||
final darkOnMiniPlayer = getContrastingTextColor(darkColors.miniPlayer);
|
||||
|
||||
// Generate secondary container colors for FilledButton.tonal
|
||||
final hslLightPrimary = HSLColor.fromColor(lightColors.primary);
|
||||
final lightSecondaryContainer = hslLightPrimary
|
||||
.withLightness(0.9)
|
||||
.withSaturation((hslLightPrimary.saturation * 0.5).clamp(0.1, 0.3))
|
||||
.toColor();
|
||||
final hslDarkPrimary = HSLColor.fromColor(darkColors.primary);
|
||||
final darkSecondaryContainer = hslDarkPrimary
|
||||
.withLightness(0.25)
|
||||
.withSaturation((hslDarkPrimary.saturation * 0.6).clamp(0.15, 0.4))
|
||||
.toColor();
|
||||
|
||||
final lightScheme = ColorScheme(
|
||||
brightness: Brightness.light,
|
||||
primary: lightColors.primary,
|
||||
primary: lightAdjustedPrimary,
|
||||
onPrimary: lightOnPrimary,
|
||||
secondary: lightColors.primary.withOpacity(0.8),
|
||||
secondary: lightAdjustedPrimary.withOpacity(0.8),
|
||||
onSecondary: lightOnPrimary,
|
||||
secondaryContainer: lightSecondaryContainer,
|
||||
onSecondaryContainer: Colors.black87,
|
||||
@@ -437,9 +575,9 @@ class PaletteHelper {
|
||||
|
||||
final darkScheme = ColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
primary: darkColors.primary,
|
||||
primary: darkAdjustedPrimary,
|
||||
onPrimary: darkOnPrimary,
|
||||
secondary: darkColors.primary.withOpacity(0.8),
|
||||
secondary: darkAdjustedPrimary.withOpacity(0.8),
|
||||
onSecondary: darkOnPrimary,
|
||||
secondaryContainer: darkSecondaryContainer,
|
||||
onSecondaryContainer: Colors.white,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/// Utility methods for network-related operations.
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to isolate network logic.
|
||||
class NetworkUtils {
|
||||
/// Check if a hostname is a local/private network address.
|
||||
///
|
||||
/// Returns true for:
|
||||
/// - Private IPv4 ranges (192.168.x.x, 10.x.x.x, 172.16-31.x.x)
|
||||
/// - localhost (127.x.x.x)
|
||||
/// - .local domains (mDNS/Bonjour)
|
||||
/// - .ts.net domains (Tailscale VPN - treat as local since it's a VPN)
|
||||
static bool isLocalNetworkHost(String host) {
|
||||
return host.startsWith('192.168.') ||
|
||||
host.startsWith('10.') ||
|
||||
host.startsWith('172.16.') ||
|
||||
host.startsWith('172.17.') ||
|
||||
host.startsWith('172.18.') ||
|
||||
host.startsWith('172.19.') ||
|
||||
host.startsWith('172.2') ||
|
||||
host.startsWith('172.30.') ||
|
||||
host.startsWith('172.31.') ||
|
||||
host == 'localhost' ||
|
||||
host.startsWith('127.') ||
|
||||
host.endsWith('.local') ||
|
||||
host.endsWith('.ts.net'); // Tailscale - treat as local since it's a VPN
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import '../models/player.dart';
|
||||
|
||||
/// Utility for player list sorting and filtering operations.
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to isolate player sorting logic.
|
||||
class PlayerSortUtility {
|
||||
/// Sort players list based on smart sort setting.
|
||||
///
|
||||
/// Smart sort order: local player first, then playing, then on, then off.
|
||||
/// Within same status, sort alphabetically.
|
||||
///
|
||||
/// Default sort: alphabetical only.
|
||||
static void sortPlayers(
|
||||
List<Player> players, {
|
||||
required bool smartSort,
|
||||
String? builtinPlayerId,
|
||||
}) {
|
||||
if (smartSort) {
|
||||
// Smart sort: local player first, then playing, then on, then off
|
||||
players.sort((a, b) {
|
||||
// Local player always first
|
||||
final aIsLocal = builtinPlayerId != null && a.playerId == builtinPlayerId;
|
||||
final bIsLocal = builtinPlayerId != null && b.playerId == builtinPlayerId;
|
||||
if (aIsLocal && !bIsLocal) return -1;
|
||||
if (bIsLocal && !aIsLocal) return 1;
|
||||
|
||||
// Then by status: playing > on > off
|
||||
final aPriority = _statusPriority(a);
|
||||
final bPriority = _statusPriority(b);
|
||||
if (aPriority != bPriority) return aPriority.compareTo(bPriority);
|
||||
|
||||
// Within same status, sort alphabetically
|
||||
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
});
|
||||
} else {
|
||||
// Default alphabetical sort
|
||||
players.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get priority for player status (lower = higher priority).
|
||||
static int _statusPriority(Player player) {
|
||||
if (player.state == 'playing') return 0;
|
||||
if (player.powered && player.state != 'off') return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
/// Filter players to exclude unwanted players.
|
||||
///
|
||||
/// Removes:
|
||||
/// - "Music Assistant Mobile" players
|
||||
/// - MA Web UI's built-in player (ma_ prefix)
|
||||
/// - "This Device" named players without proper provider
|
||||
/// - Other device's ensemble players
|
||||
/// - Unavailable players (except builtin player)
|
||||
static List<Player> filterPlayers(
|
||||
List<Player> players, {
|
||||
required String? builtinPlayerId,
|
||||
}) {
|
||||
final result = <Player>[];
|
||||
|
||||
for (final player in players) {
|
||||
final nameLower = player.name.toLowerCase();
|
||||
|
||||
// Filter out "music assistant mobile" players
|
||||
if (nameLower.contains('music assistant mobile')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter out MA Web UI's built-in player (provider is 'builtin_player' and starts with 'ma_')
|
||||
if (player.provider == 'builtin_player' && player.playerId.startsWith('ma_')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter out "This Device" named players without proper provider
|
||||
if (nameLower == 'this device') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter out other device's ensemble players
|
||||
if (player.playerId.startsWith('ensemble_')) {
|
||||
if (builtinPlayerId == null || player.playerId != builtinPlayerId) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out unavailable players (except builtin player)
|
||||
if (!player.available) {
|
||||
if (builtinPlayerId != null && player.playerId == builtinPlayerId) {
|
||||
result.add(player);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
result.add(player);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Find the best player to auto-select based on priority.
|
||||
///
|
||||
/// Priority order:
|
||||
/// 1. Last selected player (if available)
|
||||
/// 2. Last active player from any session
|
||||
/// 3. Built-in player
|
||||
/// 4. First available player
|
||||
static Player? selectBestPlayer({
|
||||
required List<Player> availablePlayers,
|
||||
required Player? lastSelectedPlayer,
|
||||
required Player? lastActivePlayer,
|
||||
required String? builtinPlayerId,
|
||||
}) {
|
||||
// Priority 1: Last selected player (if available and in current list)
|
||||
if (lastSelectedPlayer != null) {
|
||||
final found = availablePlayers.firstWhere(
|
||||
(p) => p.playerId == lastSelectedPlayer!.playerId,
|
||||
orElse: () => _emptyPlayer,
|
||||
);
|
||||
if (found.playerId.isNotEmpty) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Last active player from any session (if available)
|
||||
if (lastActivePlayer != null) {
|
||||
final found = availablePlayers.firstWhere(
|
||||
(p) => p.playerId == lastActivePlayer!.playerId,
|
||||
orElse: () => _emptyPlayer,
|
||||
);
|
||||
if (found.playerId.isNotEmpty) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Built-in player (if available)
|
||||
if (builtinPlayerId != null) {
|
||||
final builtin = availablePlayers.firstWhere(
|
||||
(p) => p.playerId == builtinPlayerId,
|
||||
orElse: () => _emptyPlayer,
|
||||
);
|
||||
if (builtin.playerId.isNotEmpty) {
|
||||
return builtin;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: First available player
|
||||
if (availablePlayers.isNotEmpty) {
|
||||
return availablePlayers.first;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static final Player _emptyPlayer = Player(
|
||||
playerId: '',
|
||||
name: '',
|
||||
available: false,
|
||||
powered: false,
|
||||
state: 'idle',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import '../models/player.dart';
|
||||
|
||||
/// Utility for determining player sync state.
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to isolate complex sync logic
|
||||
/// and make it testable independently.
|
||||
class PlayerSyncState {
|
||||
final List<Player> availablePlayers;
|
||||
final Map<String, String> castToSendspinIdMap;
|
||||
|
||||
PlayerSyncState({
|
||||
required this.availablePlayers,
|
||||
required this.castToSendspinIdMap,
|
||||
});
|
||||
|
||||
/// Check if a player should show the "manually synced" indicator (yellow border).
|
||||
///
|
||||
/// Returns true for BOTH the leader AND children of a manually created sync group.
|
||||
/// Excludes pre-configured MA speaker groups (provider = 'player_group').
|
||||
bool isPlayerManuallySynced(String playerId) {
|
||||
final player = availablePlayers.where((p) => p.playerId == playerId).firstOrNull;
|
||||
if (player == null) return false;
|
||||
|
||||
// Group players (like "All Speakers") should NEVER have yellow border
|
||||
// They are pre-configured containers, not manually synced players
|
||||
// Check this FIRST before any other logic to prevent edge cases
|
||||
if (player.provider == 'player_group') return false;
|
||||
|
||||
// Case 1: Player is a child synced to another player
|
||||
if (player.syncedTo != null) {
|
||||
// Look up sync target - also check translated IDs for Cast+Sendspin players
|
||||
// The syncedTo might contain a Cast ID but the player list has the Sendspin version
|
||||
Player? syncTarget = availablePlayers.where((p) => p.playerId == player.syncedTo).firstOrNull;
|
||||
|
||||
// If not found, try looking up by translated Sendspin ID
|
||||
if (syncTarget == null) {
|
||||
final translatedId = castToSendspinIdMap[player.syncedTo];
|
||||
if (translatedId != null) {
|
||||
syncTarget = availablePlayers.where((p) => p.playerId == translatedId).firstOrNull;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check reverse: syncedTo might be Sendspin ID, look for Cast player
|
||||
if (syncTarget == null) {
|
||||
// Build reverse map on demand
|
||||
for (final entry in castToSendspinIdMap.entries) {
|
||||
if (entry.value == player.syncedTo) {
|
||||
syncTarget = availablePlayers.where((p) => p.playerId == entry.key).firstOrNull;
|
||||
if (syncTarget != null) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (syncTarget == null) return false;
|
||||
|
||||
// If synced to a group player, it's part of a pre-configured group
|
||||
if (syncTarget.provider == 'player_group') return false;
|
||||
|
||||
// Synced to a regular player - this is a manual sync child
|
||||
return true;
|
||||
}
|
||||
|
||||
// Case 2: Player is a leader with group members
|
||||
if (player.groupMembers != null && player.groupMembers!.length > 1) {
|
||||
// Key distinction: In a MANUAL sync, the leader's own ID is in groupMembers
|
||||
// In a PRE-CONFIGURED group (UGP), the group player's ID is NOT in groupMembers
|
||||
// (the members are the child players, not including the group itself)
|
||||
final isInOwnGroup = player.groupMembers!.contains(player.playerId);
|
||||
if (!isInOwnGroup) {
|
||||
// This is a pre-configured group player (like "All Speakers")
|
||||
return false;
|
||||
}
|
||||
// Leader's ID is in groupMembers = manual sync
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Find a player by ID, optionally translating Cast IDs to Sendspin IDs.
|
||||
Player? findPlayer(String playerId) {
|
||||
// Try direct lookup first
|
||||
var player = availablePlayers.where((p) => p.playerId == playerId).firstOrNull;
|
||||
|
||||
// If not found, try Cast-to-Sendspin translation
|
||||
if (player == null && castToSendspinIdMap.containsKey(playerId)) {
|
||||
final sendspinId = castToSendspinIdMap[playerId];
|
||||
player = availablePlayers.where((p) => p.playerId == sendspinId).firstOrNull;
|
||||
}
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
/// Find the sync target for a player, handling Cast/Sendspin ID translation.
|
||||
Player? findSyncTarget(Player player) {
|
||||
if (player.syncedTo == null) return null;
|
||||
|
||||
// Try direct lookup
|
||||
var syncTarget = availablePlayers.where((p) => p.playerId == player.syncedTo).firstOrNull;
|
||||
|
||||
// If not found, try translated Sendspin ID
|
||||
if (syncTarget == null) {
|
||||
final translatedId = castToSendspinIdMap[player.syncedTo];
|
||||
if (translatedId != null) {
|
||||
syncTarget = availablePlayers.where((p) => p.playerId == translatedId).firstOrNull;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check reverse: syncedTo might be Sendspin ID, look for Cast player
|
||||
if (syncTarget == null) {
|
||||
for (final entry in castToSendspinIdMap.entries) {
|
||||
if (entry.value == player.syncedTo) {
|
||||
syncTarget = availablePlayers.where((p) => p.playerId == entry.key).firstOrNull;
|
||||
if (syncTarget != null) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return syncTarget;
|
||||
}
|
||||
|
||||
/// Check if a player is a group player (pre-configured MA speaker group).
|
||||
bool isGroupPlayer(Player player) {
|
||||
return player.provider == 'player_group';
|
||||
}
|
||||
|
||||
/// Check if a player is a leader in a manual sync group.
|
||||
bool isManualSyncLeader(Player player) {
|
||||
if (player.groupMembers == null || player.groupMembers!.length <= 1) {
|
||||
return false;
|
||||
}
|
||||
// In a MANUAL sync, the leader's own ID is in groupMembers
|
||||
return player.groupMembers!.contains(player.playerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import '../constants/timings.dart';
|
||||
|
||||
/// Utility methods for queue playback controls.
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to isolate queue control logic.
|
||||
class QueueControls {
|
||||
/// Get the next repeat mode in the cycle.
|
||||
///
|
||||
/// Cycle order: off → all → one → off
|
||||
static String getNextRepeatMode(String? currentMode) {
|
||||
switch (currentMode) {
|
||||
case RepeatModes.off:
|
||||
case null:
|
||||
return RepeatModes.all;
|
||||
case RepeatModes.all:
|
||||
return RepeatModes.one;
|
||||
case RepeatModes.one:
|
||||
return RepeatModes.off;
|
||||
default:
|
||||
return RepeatModes.off;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a repeat mode is valid.
|
||||
static bool isValidRepeatMode(String? mode) {
|
||||
return mode == RepeatModes.off ||
|
||||
mode == RepeatModes.all ||
|
||||
mode == RepeatModes.one;
|
||||
}
|
||||
}
|
||||
@@ -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.initLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
|
||||
service.initFavoriteStatus(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(
|
||||
@@ -287,11 +332,11 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
).then((_) {
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
);
|
||||
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
},
|
||||
onLongPressStart: (details) {
|
||||
@@ -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,
|
||||
);
|
||||
@@ -365,22 +410,26 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
const SizedBox(height: 8),
|
||||
// Album title with year
|
||||
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
||||
Text(
|
||||
widget.album.nameWithYear,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.album.nameWithYear,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Artist name
|
||||
Text(
|
||||
widget.album.artistsString,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.album.artistsString,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -113,8 +113,8 @@ 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;
|
||||
// Text area: 8px gap + ~20px title + ~20px artist = ~48px
|
||||
const textAreaHeight = 48.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||
|
||||
@@ -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.initLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
|
||||
service.initFavoriteStatus(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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,11 +318,11 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
).then((_) {
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
);
|
||||
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
},
|
||||
onLongPressStart: (details) {
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -113,8 +113,8 @@ 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;
|
||||
// Text area: 8px gap + ~40px for 2-line name = ~48px
|
||||
const textAreaHeight = 48.0;
|
||||
final imageSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = imageSize; // Card width = image width (circle)
|
||||
final itemExtent = cardWidth + 16; // width + horizontal margins
|
||||
|
||||
@@ -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 = 48.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize;
|
||||
final itemExtent = cardWidth + 12;
|
||||
@@ -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.initLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.audiobook.favorite ?? false)) {
|
||||
service.initFavoriteStatus(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,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
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 '../providers/music_assistant_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import 'playlist_card.dart';
|
||||
import 'radio_station_card.dart';
|
||||
|
||||
class DiscoveryRow extends StatefulWidget {
|
||||
final String title;
|
||||
final Future<List<MediaItem>> Function() loadItems;
|
||||
final String? heroTagSuffix;
|
||||
final double? rowHeight;
|
||||
/// Optional: synchronous getter for cached data (for instant display)
|
||||
final List<MediaItem>? Function()? getCachedItems;
|
||||
|
||||
const DiscoveryRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.loadItems,
|
||||
this.heroTagSuffix,
|
||||
this.rowHeight,
|
||||
this.getCachedItems,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DiscoveryRow> createState() => _DiscoveryRowState();
|
||||
}
|
||||
|
||||
class _DiscoveryRowState extends State<DiscoveryRow> with AutomaticKeepAliveClientMixin {
|
||||
List<MediaItem> _items = [];
|
||||
bool _isLoading = true;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Get cached data synchronously BEFORE first build (no spinner flash)
|
||||
final cached = widget.getCachedItems?.call();
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
_items = cached;
|
||||
_isLoading = false;
|
||||
}
|
||||
_loadItems();
|
||||
}
|
||||
|
||||
Future<void> _loadItems() async {
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// Load fresh data (always update - fresh data may have images that cached data lacks)
|
||||
try {
|
||||
final freshItems = await widget.loadItems();
|
||||
if (mounted && freshItems.isNotEmpty) {
|
||||
setState(() {
|
||||
_items = freshItems;
|
||||
_isLoading = false;
|
||||
});
|
||||
// Pre-cache images for smooth hero animations
|
||||
_precacheImages(freshItems);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure - keep showing cached data
|
||||
}
|
||||
|
||||
if (mounted && _isLoading) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-cache images so hero animations are smooth on first tap
|
||||
void _precacheImages(List<MediaItem> items) {
|
||||
if (!mounted) return;
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// Only precache first ~10 visible items to avoid excessive network/memory use
|
||||
final itemsToCache = items.take(10);
|
||||
|
||||
for (final item in itemsToCache) {
|
||||
final imageUrl = maProvider.api?.getImageUrl(item, size: 256);
|
||||
if (imageUrl != null) {
|
||||
// Use CachedNetworkImageProvider to warm the cache
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
context,
|
||||
).catchError((_) {
|
||||
// Silently ignore precache errors
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static final _logger = DebugLogger();
|
||||
|
||||
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
|
||||
// Only show loading if we have no data at all
|
||||
if (_items.isEmpty && _isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_items.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
S.of(context)!.noAlbumsFound,
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Card layout: square artwork + text below
|
||||
// Text area: 8px gap + ~20px title + ~20px subtitle = ~48px
|
||||
const textAreaHeight = 48.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||
|
||||
return ScrollConfiguration(
|
||||
behavior: const _StretchScrollBehavior(),
|
||||
child: ListView.builder(
|
||||
clipBehavior: Clip.none,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: _items.length,
|
||||
itemExtent: itemExtent,
|
||||
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||
itemBuilder: (context, index) {
|
||||
final item = _items[index];
|
||||
final key = ValueKey(item.uri ?? item.itemId);
|
||||
|
||||
Widget card;
|
||||
if (item is Playlist) {
|
||||
card = PlaylistCard(
|
||||
playlist: item,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
imageCacheSize: 256,
|
||||
);
|
||||
} else if (item.mediaType == MediaType.radio) {
|
||||
card = RadioStationCard(
|
||||
radioStation: item,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
imageCacheSize: 256,
|
||||
);
|
||||
} else {
|
||||
// Fallback for other media types
|
||||
card = _buildFallbackCard(item, cardWidth);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Container(
|
||||
key: key,
|
||||
width: cardWidth,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||
child: card,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFallbackCard(MediaItem item, double width) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Container(
|
||||
width: width,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
_getIconForMediaType(item.mediaType),
|
||||
size: 48,
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
item.name,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getIconForMediaType(MediaType type) {
|
||||
switch (type) {
|
||||
case MediaType.album:
|
||||
return Icons.album;
|
||||
case MediaType.artist:
|
||||
return Icons.person;
|
||||
case MediaType.track:
|
||||
return Icons.music_note;
|
||||
case MediaType.playlist:
|
||||
return Icons.playlist_play;
|
||||
case MediaType.radio:
|
||||
return Icons.radio;
|
||||
case MediaType.audiobook:
|
||||
return Icons.book;
|
||||
case MediaType.podcast:
|
||||
return Icons.podcasts;
|
||||
case MediaType.podcastEpisode:
|
||||
return Icons.podcasts;
|
||||
default:
|
||||
return Icons.audiotrack;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_logger.startBuild('DiscoveryRow:${widget.title}');
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// 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
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
child: SizedBox(
|
||||
height: totalHeight,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildContent(contentHeight, colorScheme),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
_logger.endBuild('DiscoveryRow:${widget.title}');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect
|
||||
class _StretchScrollBehavior extends ScrollBehavior {
|
||||
const _StretchScrollBehavior();
|
||||
|
||||
@override
|
||||
Widget buildOverscrollIndicator(
|
||||
BuildContext context, Widget child, ScrollableDetails details) {
|
||||
return StretchingOverscrollIndicator(
|
||||
axisDirection: details.direction,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -244,7 +244,11 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
// Reset expansion notifier to match initial controller state (0.0)
|
||||
// This fixes a bug where stale progress values from a previous widget instance
|
||||
// caused the nav bar to be invisible on app restart/widget recreation
|
||||
playerExpansionNotifier.value = PlayerExpansionState(0.0, null, null);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
playerExpansionNotifier.value = PlayerExpansionState(0.0, null, null);
|
||||
}
|
||||
});
|
||||
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
@@ -1641,7 +1645,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
_slideController.addListener(animateToTarget);
|
||||
_slideController.duration = const Duration(milliseconds: 150);
|
||||
|
||||
_slideController.forward().then((_) {
|
||||
_slideController.forward().then((_) async {
|
||||
if (!mounted) return;
|
||||
|
||||
_slideController.removeListener(animateToTarget);
|
||||
|
||||
@@ -478,10 +478,10 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
// PERF: Use select() to only rebuild when connection state or player changes
|
||||
final (isConnected, hasSelectedPlayer) = context.select<MusicAssistantProvider, (bool, bool)>(
|
||||
(p) => (p.isConnected, p.selectedPlayer != null),
|
||||
);
|
||||
return Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, _) {
|
||||
final isConnected = maProvider.isConnected;
|
||||
final hasSelectedPlayer = maProvider.selectedPlayer != null;
|
||||
|
||||
// Compute whether this is a first-time user ready for welcome screen.
|
||||
// This is SYNCHRONOUS - no setState needed - so it works in the same frame.
|
||||
@@ -849,5 +849,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Only use Tooltip if an Overlay ancestor exists
|
||||
final hasOverlay = Overlay.maybeOf(context) != null;
|
||||
if (!hasOverlay) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return Tooltip(
|
||||
message: tooltip ?? 'Hi-Res Audio',
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
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() {
|
||||
if (!mounted) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,9 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
// Hint system
|
||||
bool _showHints = true;
|
||||
|
||||
// Guard to run color preload only once from didChangeDependencies
|
||||
bool _hasPreloaded = false;
|
||||
|
||||
// PERF: Pre-cached static BoxShadow to avoid allocation per frame
|
||||
static const BoxShadow _cardShadow = BoxShadow(
|
||||
color: Color(0x33000000), // 20% black
|
||||
@@ -80,9 +83,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
super.initState();
|
||||
|
||||
// Load hint settings
|
||||
SettingsService.getShowHints().then((value) {
|
||||
if (mounted) setState(() => _showHints = value);
|
||||
});
|
||||
_loadHintSettings();
|
||||
|
||||
_revealController = AnimationController(
|
||||
vsync: this,
|
||||
@@ -100,23 +101,35 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
// Preload all player track info and extract colors
|
||||
_preloadColorsForPlayers();
|
||||
|
||||
// Start reveal animation
|
||||
_revealController.duration = const Duration(milliseconds: 200);
|
||||
_revealController.forward();
|
||||
|
||||
// Auto-refresh player data
|
||||
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) {
|
||||
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) async {
|
||||
if (mounted) {
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
provider.refreshPlayers();
|
||||
provider.preloadAllPlayerTracks().then((_) => _preloadColorsForPlayers());
|
||||
await provider.preloadAllPlayerTracks();
|
||||
_preloadColorsForPlayers();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_hasPreloaded) {
|
||||
_hasPreloaded = true;
|
||||
_preloadColorsForPlayers();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadHintSettings() async {
|
||||
final value = await SettingsService.getShowHints();
|
||||
if (mounted) setState(() => _showHints = value);
|
||||
}
|
||||
|
||||
/// Extract accent colors from album art for each player
|
||||
Future<void> _preloadColorsForPlayers() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
@@ -164,7 +177,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
}
|
||||
|
||||
/// Animate dismissal and call callback when done
|
||||
void dismiss() {
|
||||
Future<void> dismiss() async {
|
||||
// Trigger single bounce on mini player when cards are ~70% collapsed
|
||||
// This creates the effect of cards "landing" on the mini player
|
||||
bool bounceFired = false;
|
||||
@@ -188,10 +201,9 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
|
||||
_revealController.duration = const Duration(milliseconds: 150);
|
||||
_revealController.addListener(checkBounce);
|
||||
_revealController.reverse().then((_) {
|
||||
_revealController.removeListener(checkBounce);
|
||||
widget.onDismiss();
|
||||
});
|
||||
await _revealController.reverse();
|
||||
_revealController.removeListener(checkBounce);
|
||||
widget.onDismiss();
|
||||
}
|
||||
|
||||
/// Check if scroll is at top (or not scrollable)
|
||||
|
||||
@@ -207,11 +207,10 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
_dropdownController.forward();
|
||||
}
|
||||
|
||||
void _closeTransferDropdown() {
|
||||
void _closeTransferDropdown() async {
|
||||
if (!_showingTransferDropdown) return;
|
||||
_dropdownController.reverse().then((_) {
|
||||
if (mounted) setState(() => _showingTransferDropdown = false);
|
||||
});
|
||||
await _dropdownController.reverse();
|
||||
if (mounted) setState(() => _showingTransferDropdown = false);
|
||||
}
|
||||
|
||||
void _handleTransferQueue(BuildContext context) {
|
||||
|
||||
@@ -14,12 +14,13 @@ class PlayerSelector extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Use select to only rebuild when player data changes (not on every provider update)
|
||||
final (selectedPlayer, availablePlayers) = context.select<MusicAssistantProvider, (dynamic, List)>(
|
||||
(p) => (p.selectedPlayer, p.availablePlayers),
|
||||
);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, _) {
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
final availablePlayers = maProvider.availablePlayers;
|
||||
|
||||
// Count players that are currently playing MA content (excluding selected player)
|
||||
// User already sees if selected player is playing, badge shows "other" activity
|
||||
// Exclude external sources (Spotify Connect, TV optical, etc.) - they're not playing MA content
|
||||
@@ -80,6 +81,8 @@ class PlayerSelector extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showPlayerSelector(BuildContext context) {
|
||||
|
||||
@@ -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.initLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.playlist.favorite ?? false)) {
|
||||
service.initFavoriteStatus(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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,11 +255,11 @@ class _PlaylistCardState extends State<PlaylistCard> {
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
).then((_) {
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
);
|
||||
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
},
|
||||
onLongPressStart: (details) {
|
||||
@@ -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,
|
||||
);
|
||||
@@ -289,22 +327,26 @@ class _PlaylistCardState extends State<PlaylistCard> {
|
||||
const SizedBox(height: 8),
|
||||
// Playlist title
|
||||
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
||||
Text(
|
||||
widget.playlist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.playlist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Owner/track count
|
||||
Text(
|
||||
widget.playlist.owner ?? (widget.playlist.trackCount != null ? '${widget.playlist.trackCount} tracks' : ''),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.playlist.owner ?? (widget.playlist.trackCount != null ? '${widget.playlist.trackCount} tracks' : ''),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -106,8 +106,8 @@ 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;
|
||||
// Text area: 8px gap + ~20px title + ~20px owner = ~48px
|
||||
const textAreaHeight = 48.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||
|
||||
@@ -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.initLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.podcast.favorite ?? false)) {
|
||||
service.initFavoriteStatus(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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,11 +261,11 @@ class _PodcastCardState extends State<PodcastCard> {
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
).then((_) {
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
);
|
||||
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
},
|
||||
onLongPressStart: (details) {
|
||||
@@ -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,
|
||||
);
|
||||
@@ -293,22 +333,26 @@ class _PodcastCardState extends State<PodcastCard> {
|
||||
const SizedBox(height: 8),
|
||||
// Podcast title
|
||||
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
||||
Text(
|
||||
widget.podcast.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.podcast.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Podcast author (if available)
|
||||
Text(
|
||||
_getAuthor(widget.podcast),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
Flexible(
|
||||
child: Text(
|
||||
_getAuthor(widget.podcast),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -114,8 +114,8 @@ 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;
|
||||
// Text area: 8px gap + ~20px title + ~20px author = ~48px
|
||||
const textAreaHeight = 48.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||
|
||||
@@ -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.initLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.radioStation.favorite ?? false)) {
|
||||
service.initFavoriteStatus(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,
|
||||
);
|
||||
|
||||