Compare commits

..
9 Commits
Author SHA1 Message Date
Chris e5cefca71f WIP on feat/mixes-local: a4554ef reverted build file 2026-02-05 07:31:10 +00:00
Chris f658393d30 index on feat/mixes-local: a4554ef reverted build file 2026-02-05 07:31:10 +00:00
relic664 a4554ef7d7 reverted build file 2026-02-03 15:43:10 -05:00
relic664 a078eb55e8 fixed library screen 2026-02-02 22:24:30 -05:00
relic664 dfe3f26515 removed redundant shelf, header 2026-02-02 22:08:36 -05:00
relic664 d3d87dceda shelf refinement 2026-02-02 21:55:32 -05:00
relic664 73bb12fc03 overlay again 2026-02-02 21:24:16 -05:00
relic664 e975e24939 adjusted overlay 2026-02-02 20:58:25 -05:00
relic664 707768e524 added support for provider mixes 2026-02-02 14:43:16 -05:00
75 changed files with 1182 additions and 6091 deletions
-3
View File
@@ -1,3 +0,0 @@
# These are the funding models supported by Ensemble
github: CollotsSpot
custom: https://ko-fi.com/collotsspot
-529
View File
@@ -1,529 +0,0 @@
# 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**
-212
View File
@@ -1,212 +0,0 @@
# 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 Complete**: Integration into main.dart completed
- All providers added to MultiProvider tree
- Shared services (CacheService, PositionTracker, ImageHelperService) properly initialized
- QueueManagerService circular dependency resolved
- Build successful (debug APK tested)
✅ **Critical Bug Fixed**: Red screen after login resolved (commit: ebf4e53)
- Fixed setState during build error in LibraryStatusMixin (using Future.microtask)
- Fixed context.select inside PageView.builder error (replaced with context.watch)
### 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
-5
View File
@@ -1,11 +1,6 @@
<div align="center">
<img src="assets/images/ensemble_logo.png" alt="Ensemble Logo" height="200">
[![GitHub release](https://img.shields.io/github/v/release/CollotsSpot/Ensemble?style=for-the-badge&logo=github)](https://github.com/CollotsSpot/Ensemble/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-purple.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub-EA4AAA?style=for-the-badge&logo=GitHub%20Sponsors&logoColor=white)](https://github.com/sponsors/CollotsSpot)
[![Ko-fi](https://img.shields.io/badge/Ko--fi-FF5E5B?style=for-the-badge&logo=Ko-fi&logoColor=white)](https://ko-fi.com/collotsspot)
---
<p><strong>An unofficial mobile client for Music Assistant</strong></p>
+1 -1
View File
@@ -30,7 +30,7 @@ if (keystorePropertiesFile.exists()) {
android {
namespace "com.collotsspot.ensemble"
compileSdk 36
compileSdk 35
ndkVersion flutter.ndkVersion
compileOptions {
-6
View File
@@ -1,11 +1,5 @@
<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 -1
View File
@@ -9,7 +9,7 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:8.9.1'
classpath 'com.android.tools.build:gradle:8.7.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
+1 -1
View File
@@ -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.11.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
+1 -1
View File
@@ -22,7 +22,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.9.1" apply false
id "com.android.application" version "8.7.0" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}
-242
View File
@@ -1,242 +0,0 @@
# 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
-62
View File
@@ -8,9 +8,6 @@ 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.
@@ -57,62 +54,3 @@ 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';
}
+3 -7
View File
@@ -2996,8 +2996,7 @@ final class $$ProfilesTableReferences
$$RecentlyPlayedTableProcessedTableManager get recentlyPlayedRefs {
final manager = $$RecentlyPlayedTableTableManager($_db, $_db.recentlyPlayed)
.filter((f) => f.profileUsername.username
.sqlEquals($_itemColumn<String>('username')!));
.filter((f) => f.profileUsername.username($_item.username));
final cache = $_typedResult.readTableOrNull(_recentlyPlayedRefsTable($_db));
return ProcessedTableManager(
@@ -3190,8 +3189,7 @@ class $$ProfilesTableTableManager extends RootTableManager<
getPrefetchedDataCallback: (items) async {
return [
if (recentlyPlayedRefs)
await $_getPrefetchedData<Profile, $ProfilesTable,
RecentlyPlayedData>(
await $_getPrefetchedData(
currentTable: table,
referencedTable: $$ProfilesTableReferences
._recentlyPlayedRefsTable(db),
@@ -3256,10 +3254,8 @@ 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.sqlEquals($_column));
.filter((f) => f.username($_item.profileUsername));
final item = $_typedResult.readTableOrNull(_profileUsernameTable($_db));
if (item == null) return manager;
return ProcessedTableManager(
+4 -11
View File
@@ -52,6 +52,7 @@
"recentlyPlayed": "Zuletzt gespielt",
"discoverArtists": "Kunstler entdecken",
"discoverAlbums": "Alben entdecken",
"playlistMixes": "Playlists entdecken",
"continueListening": "Weiterhoren",
"discoverAudiobooks": "Horbücher entdecken",
"discoverSeries": "Serien entdecken",
@@ -171,6 +172,7 @@
"noArtistsFound": "Keine Künstler gefunden",
"noTracksFound": "Keine Songs gefunden",
"noPlaylistsFound": "Keine Wiedergabelisten gefunden",
"noPlaylistMixesFound": "Keine Playlist-Mixes gefunden",
"queueIsEmpty": "Warteschlange ist leer",
"noResultsFound": "Keine Ergebnisse gefunden",
"refresh": "Aktualisieren",
@@ -216,6 +218,7 @@
"showRecentlyPlayedAlbums": "Kürzlich gespielte Alben anzeigen",
"showRandomArtists": "Zufällige Künstler zum Entdecken anzeigen",
"showRandomAlbums": "Zufällige Alben zum Entdecken anzeigen",
"showPlaylistMixes": "Playlist-Mixes von externen Anbietern anzeigen",
"showAudiobooksInProgress": "Hörbücher in Bearbeitung anzeigen",
"showRandomAudiobooks": "Zufällige Hörbücher zum Entdecken anzeigen",
"showRandomSeries": "Zufällige Hörbuchserien zum Entdecken anzeigen",
@@ -295,15 +298,5 @@
"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",
"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"
"swipeToAdjustVolume": "Wischen für Lautstärke"
}
+9 -83
View File
@@ -221,34 +221,9 @@
"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"
"playlistMixes": "Discover Playlists",
"@playlistMixes": {
"description": "Home screen section title"
},
"continueListening": "Continue Listening",
@@ -955,6 +930,11 @@
"description": "Empty playlists"
},
"noPlaylistMixesFound": "No playlist mixes found",
"@noPlaylistMixesFound": {
"description": "Empty playlist mixes"
},
"queueIsEmpty": "Queue is empty",
"@queueIsEmpty": {
"description": "Empty queue"
@@ -1115,6 +1095,7 @@
"showRecentlyPlayedAlbums": "Show recently played albums",
"showRandomArtists": "Show random artists to discover",
"showRandomAlbums": "Show random albums to discover",
"showPlaylistMixes": "Show playlist mixes from external providers",
"showAudiobooksInProgress": "Show audiobooks in progress",
"showRandomAudiobooks": "Show random audiobooks to discover",
"showRandomSeries": "Show random audiobook series to discover",
@@ -1713,60 +1694,5 @@
"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"
}
}
+4 -11
View File
@@ -51,6 +51,7 @@
"recentlyPlayed": "Reproducido recientemente",
"discoverArtists": "Descubrir artistas",
"discoverAlbums": "Descubrir álbumes",
"playlistMixes": "Descubrir listas de reproducción",
"continueListening": "Continuar escuchando",
"discoverAudiobooks": "Descubrir audiolibros",
"discoverSeries": "Descubrir series",
@@ -320,6 +321,7 @@
"noArtistsFound": "No se encontraron artistas",
"noTracksFound": "No se encontraron canciones",
"noPlaylistsFound": "No se encontraron listas de reproducción",
"noPlaylistMixesFound": "No se encontraron mezclas de playlists",
"queueIsEmpty": "La cola está vacía",
"noResultsFound": "No se encontraron resultados",
"refresh": "Actualizar",
@@ -372,6 +374,7 @@
"showRecentlyPlayedAlbums": "Mostrar álbumes reproducidos recientemente",
"showRandomArtists": "Mostrar artistas aleatorios para descubrir",
"showRandomAlbums": "Mostrar álbumes aleatorios para descubrir",
"showPlaylistMixes": "Mostrar mezclas de playlists de proveedores externos",
"showAudiobooksInProgress": "Mostrar audiolibros en progreso",
"showRandomAudiobooks": "Mostrar audiolibros aleatorios para descubrir",
"showRandomSeries": "Mostrar series de audiolibros aleatorias para descubrir",
@@ -491,15 +494,5 @@
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
"pullToSelectPlayers": "Desliza para seleccionar dispositivos",
"holdToSync": "Mantén presionado para sync",
"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"
"swipeToAdjustVolume": "Desliza para ajustar volumen"
}
+12 -11
View File
@@ -221,6 +221,11 @@
"description": "Home screen section title"
},
"playlistMixes": "Découvrir des playlists",
"@playlistMixes": {
"description": "Home screen section title"
},
"continueListening": "Continuer l'écoute",
"@continueListening": {
"description": "Home screen section title"
@@ -915,6 +920,11 @@
"description": "Empty playlists"
},
"noPlaylistMixesFound": "Aucun mix de playlists trouve",
"@noPlaylistMixesFound": {
"description": "Empty playlist mixes"
},
"queueIsEmpty": "La file d'attente est vide",
"@queueIsEmpty": {
"description": "Empty queue"
@@ -1071,6 +1081,7 @@
"showRecentlyPlayedAlbums": "Afficher les albums écoutes recemment",
"showRandomArtists": "Afficher des artistes aléatoires a découvrir",
"showRandomAlbums": "Afficher des albums aléatoires a découvrir",
"showPlaylistMixes": "Afficher les mixes de playlists provenant de fournisseurs externes",
"showAudiobooksInProgress": "Afficher les livres audio en cours",
"showRandomAudiobooks": "Afficher des livres audio aléatoires a découvrir",
"showRandomSeries": "Afficher des séries de livres audio aléatoires a découvrir",
@@ -1396,15 +1407,5 @@
"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"
}
}
+15 -99
View File
@@ -359,41 +359,11 @@ abstract class S {
/// **'Discover Albums'**
String get discoverAlbums;
/// Provider discovery mixes (e.g., Tidal playlists)
/// Home screen section title
///
/// 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;
/// **'Discover Playlists'**
String get playlistMixes;
/// Home screen section title
///
@@ -1085,6 +1055,12 @@ abstract class S {
/// **'No playlists found'**
String get noPlaylistsFound;
/// Empty playlist mixes
///
/// In en, this message translates to:
/// **'No playlist mixes found'**
String get noPlaylistMixesFound;
/// Empty queue
///
/// In en, this message translates to:
@@ -1337,6 +1313,12 @@ abstract class S {
/// **'Show random albums to discover'**
String get showRandomAlbums;
/// No description provided for @showPlaylistMixes.
///
/// In en, this message translates to:
/// **'Show playlist mixes from external providers'**
String get showPlaylistMixes;
/// No description provided for @showAudiobooksInProgress.
///
/// In en, this message translates to:
@@ -2218,72 +2200,6 @@ 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> {
+8 -52
View File
@@ -152,23 +152,7 @@ class SDe extends S {
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';
String get playlistMixes => 'Playlists entdecken';
@override
String get continueListening => 'Weiterhoren';
@@ -570,6 +554,9 @@ class SDe extends S {
@override
String get noPlaylistsFound => 'Keine Wiedergabelisten gefunden';
@override
String get noPlaylistMixesFound => 'Keine Playlist-Mixes gefunden';
@override
String get queueIsEmpty => 'Warteschlange ist leer';
@@ -701,6 +688,10 @@ class SDe extends S {
@override
String get showRandomAlbums => 'Zufällige Alben zum Entdecken anzeigen';
@override
String get showPlaylistMixes =>
'Playlist-Mixes von externen Anbietern anzeigen';
@override
String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen';
@@ -1205,39 +1196,4 @@ 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';
}
+7 -52
View File
@@ -149,23 +149,7 @@ class SEn extends S {
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';
String get playlistMixes => 'Discover Playlists';
@override
String get continueListening => 'Continue Listening';
@@ -565,6 +549,9 @@ class SEn extends S {
@override
String get noPlaylistsFound => 'No playlists found';
@override
String get noPlaylistMixesFound => 'No playlist mixes found';
@override
String get queueIsEmpty => 'Queue is empty';
@@ -695,6 +682,9 @@ class SEn extends S {
@override
String get showRandomAlbums => 'Show random albums to discover';
@override
String get showPlaylistMixes => 'Show playlist mixes from external providers';
@override
String get showAudiobooksInProgress => 'Show audiobooks in progress';
@@ -1196,39 +1186,4 @@ 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';
}
+8 -52
View File
@@ -153,23 +153,7 @@ class SEs extends S {
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';
String get playlistMixes => 'Descubrir listas de reproducción';
@override
String get continueListening => 'Continuar escuchando';
@@ -573,6 +557,9 @@ class SEs extends S {
@override
String get noPlaylistsFound => 'No se encontraron listas de reproducción';
@override
String get noPlaylistMixesFound => 'No se encontraron mezclas de playlists';
@override
String get queueIsEmpty => 'La cola está vacía';
@@ -706,6 +693,10 @@ class SEs extends S {
@override
String get showRandomAlbums => 'Mostrar álbumes aleatorios para descubrir';
@override
String get showPlaylistMixes =>
'Mostrar mezclas de playlists de proveedores externos';
@override
String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso';
@@ -1213,39 +1204,4 @@ 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';
}
+8 -52
View File
@@ -154,23 +154,7 @@ class SFr extends S {
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';
String get playlistMixes => 'Découvrir des playlists';
@override
String get continueListening => 'Continuer l\'écoute';
@@ -574,6 +558,9 @@ class SFr extends S {
@override
String get noPlaylistsFound => 'Aucune playlist trouvee';
@override
String get noPlaylistMixesFound => 'Aucun mix de playlists trouve';
@override
String get queueIsEmpty => 'La file d\'attente est vide';
@@ -708,6 +695,10 @@ class SFr extends S {
@override
String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir';
@override
String get showPlaylistMixes =>
'Afficher les mixes de playlists provenant de fournisseurs externes';
@override
String get showAudiobooksInProgress => 'Afficher les livres audio en cours';
@@ -1222,39 +1213,4 @@ 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';
}
+19 -157
View File
@@ -6,25 +6,14 @@ 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';
@@ -34,7 +23,6 @@ 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
@@ -137,21 +125,6 @@ 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;
@@ -164,91 +137,9 @@ 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 (will act as facade)
// For now, don't pass providers - MusicAssistantProvider doesn't accept them yet
// It will delegate to the new providers gradually
_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
@@ -361,21 +252,9 @@ 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, _) {
@@ -450,8 +329,17 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
builder: (context, child) {
// Wrap entire app with global player overlay
return GlobalPlayerOverlay(child: child ?? const SizedBox.shrink());
// Wrap entire app with global player overlay.
// Provide an Overlay ancestor so BottomNavigationBar/Tooltip can render safely.
return Overlay(
initialEntries: [
OverlayEntry(
builder: (context) => GlobalPlayerOverlay(
child: child ?? const SizedBox.shrink(),
),
),
],
);
},
home: const AppStartup(),
),
@@ -571,24 +459,12 @@ class _AppStartupState extends State<AppStartup> {
@override
void initState() {
super.initState();
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 {
try {
// Load onboarding state first - this is critical for determining
// when to transition to HomeScreen
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
@@ -600,22 +476,12 @@ class _AppStartupState extends State<AppStartup> {
});
_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 {
try {
final serverUrl = await SettingsService.getServerUrl();
if (!mounted) return;
@@ -673,18 +539,14 @@ class _AppStartupState extends State<AppStartup> {
if (mounted) {
setState(() {
_isConnecting = false;
});
}
}
} catch (e) {
_logger.error('AppStartup connection error: $e');
if (mounted) {
setState(() {
_isConnecting = false;
});
}
}
_connectionAttempted = true;
});
}
} else {
setState(() {
_connectionAttempted = true;
});
}
}
@override
+32
View File
@@ -10,6 +10,29 @@ enum MediaType {
podcastEpisode,
}
/// Parse a media item JSON map into the appropriate model type.
MediaItem parseMediaItemJson(Map<String, dynamic> json) {
final mediaTypeStr = (json['media_type'] as String?)?.toLowerCase();
switch (mediaTypeStr) {
case 'artist':
return Artist.fromJson(json);
case 'album':
return Album.fromJson(json);
case 'track':
return Track.fromJson(json);
case 'playlist':
return Playlist.fromJson(json);
case 'audiobook':
return Audiobook.fromJson(json);
case 'radio':
return MediaItem.fromJson(json);
case 'podcast':
case 'podcast_episode':
default:
return MediaItem.fromJson(json);
}
}
class ProviderMapping {
final String itemId;
final String providerDomain;
@@ -356,6 +379,15 @@ class Playlist extends MediaItem {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
@override
Map<String, dynamic> toJson() {
final json = super.toJson();
if (owner != null) json['owner'] = owner;
if (isEditable != null) json['is_editable'] = isEditable;
if (trackCount != null) json['track_count'] = trackCount;
return json;
}
}
class Chapter {
+1 -27
View File
@@ -15,15 +15,11 @@ class RecommendationFolder {
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)
.map(parseMediaItemJson)
.toList() ?? <MediaItem>[];
return RecommendationFolder(
@@ -45,25 +41,3 @@ class RecommendationFolder {
};
}
}
/// 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);
}
}
-287
View File
@@ -1,287 +0,0 @@
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');
}
}
}
-371
View File
@@ -1,371 +0,0 @@
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();
}
}
+370 -201
View File
@@ -20,6 +20,7 @@ import '../services/cache_service.dart';
import '../services/recently_played_service.dart';
import '../services/sync_service.dart';
import '../services/local_player_service.dart';
import '../services/metadata_service.dart';
import '../services/position_tracker.dart';
import '../services/sendspin_service.dart';
import '../services/pcm_audio_player.dart';
@@ -27,14 +28,6 @@ import '../services/offline_action_queue.dart';
import '../constants/timings.dart';
import '../services/database_service.dart';
import '../services/library_status_service.dart';
import '../services/image_helper_service.dart';
import '../services/provider_filter_service.dart';
import '../services/queue_manager_service.dart';
import '../services/search_service.dart';
import '../utils/network_utils.dart';
import '../utils/player_sort_utility.dart';
import '../utils/queue_controls.dart';
import '../utils/player_sync_state.dart';
import '../main.dart' show audioHandler;
/// Main provider that coordinates connection, player, and library state.
@@ -92,11 +85,8 @@ class MusicAssistantProvider with ChangeNotifier {
// Local player state
bool _isLocalPlayerPowered = true;
int _localPlayerVolume = VolumeConstants.max; // Tracked MA volume for builtin player (0-100)
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
bool _builtinPlayerAvailable = true; // False on MA 2.7.0b20+ (uses Sendspin instead)
// Home refresh counter - increments to signal home screen to refresh all rows
int _homeRefreshCounter = 0;
StreamSubscription? _connectionStateSubscription;
StreamSubscription? _localPlayerEventSubscription;
StreamSubscription? _playerUpdatedEventSubscription;
@@ -118,44 +108,6 @@ class MusicAssistantProvider with ChangeNotifier {
// PCM audio player for raw Sendspin audio streaming
PcmAudioPlayer? _pcmAudioPlayer;
// Image helper service for artwork URL generation
ImageHelperService? _imageHelper;
// Provider filter service (created on demand)
ProviderFilterService get _providerFilterService => ProviderFilterService(
providerFilter: _providerFilter,
playerFilter: _playerFilter,
availableMusicProviders: _availableMusicProviders,
artists: _artists,
albums: _albums,
tracks: _tracks,
podcasts: _podcasts,
radioStations: _radioStations,
);
// Player sync state utility (created on demand)
PlayerSyncState get _playerSyncState => PlayerSyncState(
availablePlayers: _availablePlayers,
castToSendspinIdMap: _castToSendspinIdMap,
);
// Queue manager service (created on demand)
QueueManagerService get _queueManager => QueueManagerService(
api: _api,
database: DatabaseService.instance,
logger: _logger,
getAvailablePlayers: () => _availablePlayers,
getCastToSendspinMap: () => _castToSendspinIdMap,
playTracks: playTracks,
);
// Search service (created on demand)
SearchService get _searchService => SearchService(
api: _api,
cacheService: _cacheService,
logger: _logger,
);
// Search state persistence
String _lastSearchQuery = '';
Map<String, List<MediaItem>> _lastSearchResults = {
@@ -164,6 +116,10 @@ class MusicAssistantProvider with ChangeNotifier {
'tracks': [],
};
// 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 = {};
// Provider filter: list of allowed provider instance IDs from MA user settings
// Empty list means all providers are allowed
List<String> _providerFilter = [];
@@ -209,7 +165,7 @@ class MusicAssistantProvider with ChangeNotifier {
List<String> get providerFilter => _providerFilter;
/// Whether provider filtering is active
bool get hasProviderFilter => _providerFilterService.hasProviderFilter;
bool get hasProviderFilter => _providerFilter.isNotEmpty;
/// Check if a media item should be visible based on provider filter
/// Returns true if:
@@ -217,33 +173,58 @@ class MusicAssistantProvider with ChangeNotifier {
/// - 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) {
return _providerFilterService.isItemAllowedByProviderFilter(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) {
return _providerFilterService.filterByProvider(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) {
return _providerFilterService.filterSearchResults(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 (empty = all players allowed)
List<String> get playerFilter => _playerFilter;
/// Whether player filtering is active
bool get hasPlayerFilter => _providerFilterService.hasPlayerFilter;
bool get hasPlayerFilter => _playerFilter.isNotEmpty;
/// Check if a player should be visible based on player filter
bool isPlayerAllowedByFilter(Player player) {
return _providerFilterService.isPlayerAllowedByFilter(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) {
return _providerFilterService.filterPlayers(players);
if (_playerFilter.isEmpty) return players;
return players.where(isPlayerAllowedByFilter).toList();
}
// ============================================================================
@@ -260,12 +241,12 @@ class MusicAssistantProvider with ChangeNotifier {
/// Get SVG icon for a provider domain
/// Returns null if no icon is available
String? getProviderIconSvg(String domain, {bool isDark = false}) {
return _imageHelper?.getProviderIconSvg(domain, isDark: isDark);
return _api?.getProviderIconSvg(domain, isDark: isDark);
}
/// Get provider manifest by domain
ProviderManifest? getProviderManifest(String domain) {
return _imageHelper?.getProviderManifest(domain);
return _api?.getProviderManifest(domain);
}
/// List of enabled provider instance IDs (empty = all enabled)
@@ -516,12 +497,67 @@ class MusicAssistantProvider with ChangeNotifier {
/// Raw unfiltered players list (for internal use)
List<Player> get availablePlayersUnfiltered => _availablePlayers;
/// 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').
/// 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) {
return _playerSyncState.isPlayerManuallySynced(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;
}
Track? get currentTrack => _currentTrack;
@@ -538,9 +574,6 @@ class MusicAssistantProvider with ChangeNotifier {
/// Whether we have cached players available (for instant UI display on app resume)
bool get hasCachedPlayers => _cacheService.hasCachedPlayers;
/// Home refresh counter - increments when home rows should refresh their data
int get homeRefreshCounter => _homeRefreshCounter;
/// Currently playing audiobook context (with chapters) - set when playing an audiobook
Audiobook? get currentAudiobook => _currentAudiobook;
@@ -738,7 +771,9 @@ class MusicAssistantProvider with ChangeNotifier {
/// Get artwork URL for a player from cache
String? getCachedArtworkUrl(String playerId, {int size = 512}) {
return _imageHelper?.getCachedArtworkUrl(playerId, size: size);
final track = getCachedTrackForPlayer(playerId);
if (track == null) return null;
return getImageUrl(track, size: size);
}
/// Clear cached track for a player (e.g., after queue transfer)
@@ -899,8 +934,8 @@ class MusicAssistantProvider with ChangeNotifier {
Future<void> _loadPodcastCoverCache() async {
try {
final cache = await SettingsService.getPodcastCoverCache();
if (cache.isNotEmpty && _imageHelper != null) {
await _imageHelper!.loadPodcastCoverCache(cache);
if (cache.isNotEmpty) {
_podcastCoverCache.addAll(cache);
_logger.log('📦 Loaded ${cache.length} podcast covers from cache (instant high-res)');
}
} catch (e) {
@@ -1006,12 +1041,6 @@ class MusicAssistantProvider with ChangeNotifier {
_api = MusicAssistantAPI(serverUrl, _authManager);
// Initialize image helper service with API reference
_imageHelper = ImageHelperService(
api: _api,
getCachedTrackForPlayer: (playerId) => _cacheService.getCachedTrackForPlayer(playerId),
);
_connectionStateSubscription?.cancel();
_connectionStateSubscription = _api!.connectionState.listen(
(state) async {
@@ -1307,19 +1336,6 @@ class MusicAssistantProvider with ChangeNotifier {
loadLibrary();
// Only invalidate home cache if it was empty (no cached recent albums = empty cache)
// This prevents jarring double-load when cache is already valid
if (_cacheService.getCachedRecentAlbums() == null) {
_logger.log('🔄 Home cache was empty, invalidating after auth...');
_cacheService.invalidateHomeCache();
_homeRefreshCounter++;
} else {
_logger.log('✅ Home cache has valid data, skipping refresh');
}
// Notify listeners to trigger UI refresh - home rows will re-fetch with valid authentication
notifyListeners();
// Process any queued offline actions now that we're connected
await _processOfflineQueue();
@@ -2359,7 +2375,7 @@ class MusicAssistantProvider with ChangeNotifier {
void _handleSendspinVolume(int volumeLevel) async {
_logger.log('🔊 Sendspin: Set volume to $volumeLevel');
_localPlayerVolume = volumeLevel;
await FlutterVolumeController.setVolume(volumeLevel / VolumeConstants.range.toDouble());
await FlutterVolumeController.setVolume(volumeLevel / 100.0);
_sendspinService?.reportState(volume: volumeLevel);
}
@@ -2617,7 +2633,7 @@ class MusicAssistantProvider with ChangeNotifier {
final volume = event['volume_level'] as int? ?? event['volume'] as int?;
if (volume != null) {
_localPlayerVolume = volume;
await FlutterVolumeController.setVolume(volume / VolumeConstants.range.toDouble());
await FlutterVolumeController.setVolume(volume / 100.0);
}
break;
@@ -3087,9 +3103,6 @@ class MusicAssistantProvider with ChangeNotifier {
// ============================================================================
Future<List<Album>> getRecentAlbumsWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
// Always fetch from API to ensure we have full album data with images
// Local/cached data is used by getCachedRecentAlbums() for instant display
if (_api == null) {
@@ -3121,9 +3134,6 @@ class MusicAssistantProvider with ChangeNotifier {
}
Future<List<Artist>> getDiscoverArtistsWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
if (_cacheService.isDiscoverArtistsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discover artists');
return _cacheService.getCachedDiscoverArtists()!;
@@ -3146,9 +3156,6 @@ class MusicAssistantProvider with ChangeNotifier {
}
Future<List<Album>> getDiscoverAlbumsWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
if (_cacheService.isDiscoverAlbumsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discover albums');
return _cacheService.getCachedDiscoverAlbums()!;
@@ -3170,79 +3177,30 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
Future<List<RecommendationFolder>> getDiscoveryFoldersWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
if (_cacheService.isDiscoveryFoldersCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discovery folders');
return _cacheService.getCachedDiscoveryFolders()!;
Future<List<RecommendationFolder>> getExternalMixesWithCache({bool forceRefresh = false}) async {
if (_cacheService.isExternalMixesCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached external mixes');
return _cacheService.getCachedExternalMixes()!;
}
if (_api == null) return _cacheService.getCachedDiscoveryFolders() ?? [];
if (_api == null) return _cacheService.getCachedExternalMixes() ?? [];
try {
_logger.log('🔄 Fetching discovery folders...');
_logger.log('🔄 Fetching external mixes...');
final recommendations = await _api!.getRecommendations();
final filteredFolders = _extractDiscoveryFolders(recommendations);
_cacheService.setCachedDiscoveryFolders(filteredFolders);
return filteredFolders;
final mixes = _extractExternalMixes(recommendations);
_cacheService.setCachedExternalMixes(mixes);
return mixes;
} catch (e) {
_logger.log('❌ Failed to fetch discovery folders: $e');
return _cacheService.getCachedDiscoveryFolders() ?? [];
_logger.log('❌ Failed to fetch external mixes: $e');
return _cacheService.getCachedExternalMixes() ?? [];
}
}
List<RecommendationFolder>? getCachedDiscoveryFolders() => _cacheService.getCachedDiscoveryFolders();
/// Get items for a specific discovery folder (for individual row rendering)
Future<List<MediaItem>> getDiscoveryFolderItems(String folderId, {bool forceRefresh = false}) async {
final folders = await getDiscoveryFoldersWithCache(forceRefresh: forceRefresh);
final folder = folders.firstWhere(
(f) => f.itemId == folderId,
orElse: () => RecommendationFolder(
itemId: folderId,
provider: 'unknown',
name: '',
items: [],
),
);
return folder.items;
}
/// Get cached items for a specific discovery folder (for instant display)
List<MediaItem>? getCachedDiscoveryFolderItems(String folderId) {
final folders = getCachedDiscoveryFolders();
if (folders == null) return null;
final folder = folders.firstWhere(
(f) => f.itemId == folderId,
orElse: () => RecommendationFolder(
itemId: folderId,
provider: 'unknown',
name: '',
items: [],
),
);
return folder.items.isEmpty ? null : folder.items;
}
/// Refresh discovery folders by clearing cache and refetching from API
Future<void> refreshDiscoveryFolders() async {
_cacheService.invalidateHomeCache();
await getDiscoveryFoldersWithCache(forceRefresh: true);
}
List<RecommendationFolder> _extractDiscoveryFolders(List<RecommendationFolder> folders) {
List<RecommendationFolder> _extractExternalMixes(List<RecommendationFolder> folders) {
final result = <RecommendationFolder>[];
for (final folder in folders) {
// Filter out "recently played" (duplicate of MA's recently played)
final name = folder.name.trim().toLowerCase();
final id = folder.itemId.trim().toLowerCase();
final isRecentlyPlayed = name == 'recently played' || id.contains('recently_played');
if (isRecentlyPlayed) continue;
// Filter items by provider and type
final providerFiltered = filterByProvider(folder.items);
final filteredItems = providerFiltered
.where((item) => item.mediaType == MediaType.playlist || item.mediaType == MediaType.radio)
@@ -3256,7 +3214,7 @@ class MusicAssistantProvider with ChangeNotifier {
provider: folder.provider,
name: folder.name,
uri: folder.uri,
items: _deduplicateItems(filteredItems),
items: _deduplicateMixes(filteredItems),
));
}
@@ -3280,11 +3238,11 @@ class MusicAssistantProvider with ChangeNotifier {
return false;
}
List<MediaItem> _deduplicateItems(List<MediaItem> items) {
List<MediaItem> _deduplicateMixes(List<MediaItem> mixes) {
final Map<String, MediaItem> deduped = {};
for (final item in items) {
final key = '${item.provider}:${item.itemId}';
deduped.putIfAbsent(key, () => item);
for (final mix in mixes) {
final key = '${mix.provider}:${mix.itemId}';
deduped.putIfAbsent(key, () => mix);
}
return deduped.values.toList();
}
@@ -3302,6 +3260,9 @@ class MusicAssistantProvider with ChangeNotifier {
/// Get cached discover albums synchronously (for instant display)
List<Album>? getCachedDiscoverAlbums() => _cacheService.getCachedDiscoverAlbums();
/// Get cached external mixes synchronously (for instant display)
List<RecommendationFolder>? getCachedExternalMixes() => _cacheService.getCachedExternalMixes();
/// Force a full library sync (for pull-to-refresh)
Future<void> forceLibrarySync() async {
if (_api == null) {
@@ -3405,9 +3366,6 @@ class MusicAssistantProvider with ChangeNotifier {
/// Get audiobooks that have progress (continue listening) with caching
Future<List<Audiobook>> getInProgressAudiobooksWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
// Check cache first
if (!forceRefresh && _cacheService.isInProgressAudiobooksCacheValid()) {
_logger.log('📦 Using cached in-progress audiobooks');
@@ -3447,9 +3405,6 @@ class MusicAssistantProvider with ChangeNotifier {
/// Get random audiobooks for discovery with caching
Future<List<Audiobook>> getDiscoverAudiobooksWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
// Check cache first
if (!forceRefresh && _cacheService.isDiscoverAudiobooksCacheValid()) {
_logger.log('📦 Using cached discover audiobooks');
@@ -3486,9 +3441,6 @@ class MusicAssistantProvider with ChangeNotifier {
/// Get random series for discovery with caching
Future<List<AudiobookSeries>> getDiscoverSeriesWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
// Check cache first
if (!forceRefresh && _cacheService.isDiscoverSeriesCacheValid()) {
_logger.log('📦 Using cached discover series');
@@ -3632,11 +3584,44 @@ class MusicAssistantProvider with ChangeNotifier {
// ============================================================================
Future<Map<String, List<MediaItem>>> searchWithCache(String query, {bool forceRefresh = false, bool libraryOnly = false}) async {
return _searchService.searchWithCache(query, forceRefresh: forceRefresh, libraryOnly: libraryOnly);
final baseKey = query.toLowerCase().trim();
if (baseKey.isEmpty) return {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
// 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) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
}
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) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
}
}
void clearAllDetailCaches() {
_searchService.clearAllDetailCaches();
_cacheService.clearAllDetailCaches();
}
/// Get cached album tracks (for instant display before background refresh)
@@ -3669,11 +3654,32 @@ class MusicAssistantProvider with ChangeNotifier {
/// Sort players list based on smart sort setting
/// Can be called synchronously with pre-fetched settings for cached players
void _sortPlayersSync(List<Player> players, bool smartSort, String? builtinPlayerId) {
PlayerSortUtility.sortPlayers(
players,
smartSort: smartSort,
builtinPlayerId: 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
int statusPriority(Player p) {
if (p.state == 'playing') return 0;
if (p.powered && p.state != 'off') return 1;
return 2;
}
final aPriority = statusPriority(a);
final bPriority = statusPriority(b);
if (aPriority != bPriority) return aPriority.compareTo(bPriority);
// Within same status, sort alphabetically
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
});
} else {
// Default alphabetical sort
players.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
}
}
Future<void> _loadAndSelectPlayers({bool forceRefresh = false, bool coldStart = false}) async {
@@ -4037,7 +4043,7 @@ class MusicAssistantProvider with ChangeNotifier {
// Update notification for builtin player using local mode method (keeps pause working)
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: ImageSizes.highRes);
final artworkUrl = _api?.getImageUrl(track, size: 512);
final artistWithPlayer = track.artistsString.isNotEmpty
? '${track.artistsString} • ${player.name}'
: player.name;
@@ -4076,7 +4082,7 @@ class MusicAssistantProvider with ChangeNotifier {
// and the player is playing (don't wait for polling to kick in)
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: ImageSizes.highRes);
final artworkUrl = _api?.getImageUrl(track, size: 512);
// Include player name in artist line: "Artist • Player Name"
final artistWithPlayer = track.artistsString.isNotEmpty
? '${track.artistsString} • ${player.name}'
@@ -4259,7 +4265,7 @@ class MusicAssistantProvider with ChangeNotifier {
return;
}
final artworkUrl = _api?.getImageUrl(track, size: ImageSizes.highRes);
final artworkUrl = _api?.getImageUrl(track, size: 512);
final artistWithPlayer = track.artistsString.isNotEmpty
? '${track.artistsString} • ${_selectedPlayer!.name}'
: _selectedPlayer!.name;
@@ -4365,7 +4371,7 @@ class MusicAssistantProvider with ChangeNotifier {
}
// Also precache the image so it's ready for swipe preview
final imageUrl = getImageUrl(track, size: ImageSizes.highRes);
final imageUrl = getImageUrl(track, size: 512);
if (imageUrl != null) {
_precacheImage(imageUrl);
}
@@ -4714,7 +4720,7 @@ class MusicAssistantProvider with ChangeNotifier {
// Update notification for ALL players
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: ImageSizes.highRes);
final artworkUrl = _api?.getImageUrl(track, size: 512);
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
final isBuiltinPlayer = builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId;
@@ -4817,7 +4823,7 @@ class MusicAssistantProvider with ChangeNotifier {
title: track.name,
artist: track.artistsString,
album: track.album?.name,
artUri: _api != null ? Uri.tryParse(_api!.getImageUrl(track, size: ImageSizes.highRes) ?? '') : null,
artUri: _api != null ? Uri.tryParse(_api!.getImageUrl(track, size: 512) ?? '') : null,
duration: track.duration,
);
audioHandler.updateLocalModeNotification(
@@ -5016,18 +5022,18 @@ class MusicAssistantProvider with ChangeNotifier {
/// Load high-resolution podcast covers from iTunes in background
/// iTunes provides 800x800 artwork for most podcasts (reduced from 1400 for efficiency)
Future<void> _loadPodcastCoversInBackground() async {
if (_api == null || _imageHelper == null) return;
if (_api == null) return;
for (final podcast in _podcasts) {
try {
// Skip if already cached (either in memory or loaded from persistence)
if (_imageHelper!.podcastCoverCache.containsKey(podcast.itemId)) continue;
if (_podcastCoverCache.containsKey(podcast.itemId)) continue;
// Try iTunes for high-res artwork
final itunesArtwork = await _api!.getITunesPodcastArtwork(podcast.name);
if (itunesArtwork != null) {
_imageHelper!.updatePodcastCoverCache(podcast.itemId, itunesArtwork);
_podcastCoverCache[podcast.itemId] = itunesArtwork;
_logger.log('🎙️ Cached iTunes artwork for ${podcast.name}');
// Persist to storage for instant display on next launch
@@ -5161,20 +5167,33 @@ class MusicAssistantProvider with ChangeNotifier {
}
String? getImageUrl(MediaItem item, {int size = 256}) {
return _imageHelper?.getImageUrl(item, size: size);
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 _imageHelper?.getPodcastImageUrl(podcast, size: size);
// 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 {
return _imageHelper?.getArtistImageUrlWithFallback(artist, size: size);
// 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);
}
// ============================================================================
@@ -5186,17 +5205,104 @@ class MusicAssistantProvider with ChangeNotifier {
}
Future<PlayerQueue?> getQueue(String playerId) async {
return _queueManager.getQueue(playerId);
// If this player is a group child, fetch the leader's queue instead
// This ensures grouped players show the same queue as their leader
String effectivePlayerId = playerId;
final player = _availablePlayers.firstWhere(
(p) => p.playerId == playerId,
orElse: () => Player(
playerId: playerId,
name: '',
available: false,
powered: false,
state: 'idle',
),
);
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
if (effectivePlayerId.startsWith('cast-') && effectivePlayerId.length >= 13) {
// Sendspin ID format: cast-7df484e3 -> need Cast UUID starting with 7df484e3
final prefix = effectivePlayerId.substring(5); // Remove "cast-"
// Reverse lookup in the map
for (final entry in _castToSendspinIdMap.entries) {
if (entry.value == effectivePlayerId) {
_logger.log('🔗 Translated Sendspin ID $effectivePlayerId to Cast UUID ${entry.key} for queue fetch');
effectivePlayerId = entry.key;
break;
}
}
// If not in map, try to find in available players
if (effectivePlayerId.startsWith('cast-')) {
for (final p in _availablePlayers) {
if (p.provider == 'chromecast' && p.playerId.startsWith(prefix)) {
_logger.log('🔗 Found Cast UUID ${p.playerId} for Sendspin ID $effectivePlayerId via player lookup');
effectivePlayerId = p.playerId;
break;
}
}
}
}
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 {
return _queueManager.getCachedQueue(playerId);
try {
if (!DatabaseService.instance.isInitialized) return null;
final cachedItems = await DatabaseService.instance.getCachedQueue(playerId);
if (cachedItems.isEmpty) return null;
final items = <QueueItem>[];
for (final cached in cachedItems) {
try {
final itemData = jsonDecode(cached.itemJson) as Map<String, dynamic>;
items.add(QueueItem.fromJson(itemData));
} catch (e) {
_logger.log('⚠️ Error parsing cached queue item: $e');
}
}
if (items.isEmpty) return null;
return PlayerQueue(
playerId: playerId,
items: items,
currentIndex: 0, // Will be updated from fresh data
);
} catch (e) {
_logger.log('⚠️ Error loading cached queue: $e');
return null;
}
}
/// Persist queue to database for app restart persistence
void _persistQueueToDatabase(String playerId, PlayerQueue queue) {
_queueManager.persistQueueToDatabase(playerId, queue);
() async {
try {
if (!DatabaseService.instance.isInitialized) return;
final itemJsonList = queue.items.map((item) => jsonEncode(item.toJson())).toList();
await DatabaseService.instance.saveQueue(playerId, itemJsonList);
_logger.log('💾 Persisted ${queue.items.length} queue items to database');
} catch (e) {
_logger.log('⚠️ Error persisting queue to database: $e');
}
}();
}
Future<void> playTrack(String playerId, Track track, {bool clearQueue = true}) async {
@@ -5346,7 +5452,42 @@ class MusicAssistantProvider with ChangeNotifier {
/// Attempt to restore the queue from cached data and start playback
Future<bool> _restoreQueueFromCache(String playerId) async {
return _queueManager.restoreQueueFromCache(playerId);
try {
final cachedQueue = await getCachedQueue(playerId);
if (cachedQueue == null || cachedQueue.items.isEmpty) {
_logger.log('⚠️ No cached queue to restore');
return false;
}
// Extract tracks from queue items
// playTracks needs either: providerMappings with available entries, OR provider+itemId
final tracks = cachedQueue.items
.map((item) => item.track)
.where((track) {
// Check for providerMappings with at least one available entry
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
return track.providerMappings!.any((m) => m.available);
}
// Fallback: provider + itemId can be used to construct URI
return track.provider.isNotEmpty && track.itemId.isNotEmpty;
})
.toList();
if (tracks.isEmpty) {
_logger.log('⚠️ Cached queue has no valid tracks');
return false;
}
_logger.log('🔄 Restoring ${tracks.length} tracks from cached queue');
// Re-queue all tracks and start playback
await playTracks(playerId, tracks, startIndex: 0);
return true;
} catch (e) {
_logger.log('❌ Error restoring queue from cache: $e');
return false;
}
}
Future<void> nextTrack(String playerId) async {
@@ -5731,7 +5872,7 @@ class MusicAssistantProvider with ChangeNotifier {
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && playerId == builtinPlayerId) {
_localPlayerVolume = volumeLevel;
await FlutterVolumeController.setVolume(volumeLevel / VolumeConstants.range.toDouble());
await FlutterVolumeController.setVolume(volumeLevel / 100.0);
}
await _api?.setVolume(playerId, volumeLevel);
} catch (e) {
@@ -5894,7 +6035,21 @@ class MusicAssistantProvider with ChangeNotifier {
}
Future<void> cycleRepeatMode(String queueId, String? currentMode) async {
final nextMode = QueueControls.getNextRepeatMode(currentMode);
String nextMode;
switch (currentMode) {
case 'off':
case null:
nextMode = 'all';
break;
case 'all':
nextMode = 'one';
break;
case 'one':
nextMode = 'off';
break;
default:
nextMode = 'off';
}
await setRepeatMode(queueId, nextMode);
}
@@ -5924,5 +6079,19 @@ class MusicAssistantProvider with ChangeNotifier {
// ============================================================================
/// Check if a hostname is a local/private network address
bool _isLocalNetworkHost(String host) => NetworkUtils.isLocalNetworkHost(host);
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
}
}
-604
View File
@@ -1,604 +0,0 @@
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();
}
}
-4
View File
@@ -18,7 +18,6 @@ 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;
@@ -75,9 +74,7 @@ class SleepTimerProvider with ChangeNotifier {
// Start display update timer (every second for countdown)
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!_disposed) {
notifyListeners(); // Update UI with remaining time
}
});
notifyListeners();
@@ -126,7 +123,6 @@ class SleepTimerProvider with ChangeNotifier {
@override
void dispose() {
_disposed = true;
_cancelInternal();
super.dispose();
}
-166
View File
@@ -1,166 +0,0 @@
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();
}
}
+4 -9
View File
@@ -935,7 +935,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 12.0),
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1298,13 +1298,12 @@ 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: () async {
onTap: () {
Navigator.pop(context);
// Add to queue on selected player
final player = maProvider.selectedPlayer;
if (player != null) {
try {
await maProvider.addTracksToQueue(player.playerId, _tracks);
maProvider.addTracksToQueue(player.playerId, _tracks).then((_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -1313,11 +1312,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
);
}
} catch (e) {
if (mounted) {
_showError(S.of(context)!.failedToAddToQueue(e.toString()));
}
}
});
} else {
_showError(S.of(context)!.noPlayerSelected);
}
+5 -5
View File
@@ -882,9 +882,9 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
backgroundColor: colorScheme.background,
body: LayoutBuilder(
builder: (context, constraints) {
// 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;
// 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;
return CustomScrollView(
slivers: [
@@ -942,7 +942,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -959,7 +959,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
),
),
),
Spacing.vGap8,
Spacing.vGap16,
if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[
InkWell(
onTap: () {
+47 -51
View File
@@ -14,7 +14,6 @@ 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';
@@ -298,9 +297,9 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
backgroundColor: colorScheme.surface,
body: LayoutBuilder(
builder: (context, constraints) {
// 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;
// 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;
return CustomScrollView(
slivers: [
@@ -316,6 +315,49 @@ 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,
@@ -368,12 +410,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Author info on the left
Expanded(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -394,47 +431,6 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
],
),
),
// 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,
),
),
),
),
],
),
),
),
// Audiobooks Section Header
SliverToBoxAdapter(
+1 -1
View File
@@ -652,7 +652,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
+1
View File
@@ -364,6 +364,7 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
padding: EdgeInsets.only(bottom: BottomSpacing.navBarOnly),
child: FloatingActionButton(
onPressed: () {
setState(() {});
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
+30 -305
View File
@@ -2,7 +2,6 @@ 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';
@@ -17,7 +16,7 @@ import '../widgets/series_row.dart';
import '../widgets/playlist_row.dart';
import '../widgets/radio_station_row.dart';
import '../widgets/podcast_row.dart';
import '../widgets/discovery_row.dart';
import '../widgets/mixes_row.dart';
import '../widgets/common/disconnected_state.dart';
import 'settings_screen.dart';
import 'search_screen.dart';
@@ -36,6 +35,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
bool _showRecentAlbums = true;
bool _showDiscoverArtists = true;
bool _showDiscoverAlbums = true;
bool _showExternalMixes = false;
// Favorites rows (default off)
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
@@ -49,11 +49,6 @@ 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;
@@ -63,7 +58,6 @@ 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() {
@@ -74,18 +68,12 @@ 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
// Clean up provider listener if attached
_providerListeningTo?.removeListener(_onProviderChanged);
_providerListeningTo = null;
WidgetsBinding.instance.removeObserver(this);
@@ -110,36 +98,22 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
});
}
/// Called when MusicAssistantProvider changes (for cache load detection and home refresh)
/// Called when MusicAssistantProvider changes (for cache load detection)
void _onProviderChanged() {
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(); // 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 (!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;
setState(() {
_refreshKey = UniqueKey();
});
}
_lastArtistCount = currentCount;
}
}
/// Called when SyncService status changes
void _onSyncChanged() {
@@ -174,6 +148,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
final showRecent = await SettingsService.getShowRecentAlbums();
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
final showExternalMixes = await SettingsService.getShowExternalMixes();
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
@@ -184,12 +159,12 @@ 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;
_showDiscoverArtists = showDiscArtists;
_showDiscoverAlbums = showDiscAlbums;
_showExternalMixes = showExternalMixes;
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
@@ -200,91 +175,8 @@ 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 {
@@ -438,38 +330,16 @@ 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;
case 'discover-albums': return _showDiscoverAlbums;
case 'external-mixes': return _showExternalMixes;
case 'continue-listening': return _showContinueListeningAudiobooks;
case 'discover-audiobooks': return _showDiscoverAudiobooks;
case 'discover-series': return _showDiscoverSeries;
@@ -483,57 +353,6 @@ 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
@@ -541,7 +360,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
builder: (context, constraints) {
// Each row is always 1/3 of screen height
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
final maxHeight = constraints.maxHeight.isFinite
? constraints.maxHeight
: MediaQuery.of(context).size.height;
final availableHeight = (maxHeight - BottomSpacing.withMiniPlayer).clamp(0.0, double.infinity);
// Account for margins between rows (2px each)
// Only adjust for margins when ≤3 rows (so they fit exactly without scroll)
@@ -549,7 +371,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
const marginSize = 2.0;
final enabledRows = _countEnabledRows();
final marginsInView = enabledRows > 1 && enabledRows <= 3 ? (enabledRows - 1) * marginSize : 0.0;
final rowHeight = (availableHeight - marginsInView) / 3;
final rawRowHeight = (availableHeight - marginsInView) / 3;
final rowHeight = rawRowHeight.isFinite && rawRowHeight > 0 ? rawRowHeight : 237.0;
// Use Android 12+ stretch overscroll effect
return NotificationListener<ScrollNotification>(
@@ -572,14 +395,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.only(bottom: BottomSpacing.withMiniPlayer),
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),
),
children: _buildOrderedRows(provider, rowHeight),
),
),
),
@@ -589,54 +408,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
}
/// Build rows in the user's configured order
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight, bool isEmpty) {
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight) {
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) {
@@ -645,42 +419,6 @@ 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));
}
}
@@ -717,6 +455,16 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
getCachedAlbums: () => provider.getCachedDiscoverAlbums(),
rowHeight: rowHeight,
);
case 'external-mixes':
if (!_showExternalMixes) return null;
return MixesRow(
key: const ValueKey('external-mixes'),
title: S.of(context)!.playlistMixes,
loadMixes: () => provider.getExternalMixesWithCache(),
getCachedMixes: () => provider.getCachedExternalMixes(),
heroTagSuffix: 'home',
rowHeight: rowHeight,
);
case 'continue-listening':
if (!_showContinueListeningAudiobooks) return null;
return AudiobookRow(
@@ -796,29 +544,6 @@ 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;
}
}
+26 -29
View File
@@ -1210,21 +1210,14 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// Mark as loading to avoid duplicate requests
_authorImages[displayName] = null;
// Fetch in background using primary author name
_fetchAuthorImage(displayName, lookupName);
}
}
}
Future<void> _fetchAuthorImage(String displayName, String lookupName) async {
try {
final imageUrl = await MetadataService.getAuthorImageUrl(lookupName);
MetadataService.getAuthorImageUrl(lookupName).then((imageUrl) {
if (mounted && imageUrl != null) {
setState(() {
_authorImages[displayName] = imageUrl;
});
}
} catch (e) {
// Ignore image fetch errors - authors will show without images
});
}
}
}
@@ -1513,7 +1506,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
itemCount: _tabCount,
// Use default physics - custom physics may cause issues with Flutter 3.38
physics: const PageScrollPhysics(),
itemBuilder: (context, index) => _buildTabAtIndex(context, l10n, index),
itemBuilder: (context, index) => Builder(
builder: (context) => _buildTabAtIndex(context, l10n, index),
),
),
),
),
@@ -3420,12 +3415,13 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
Widget _buildPodcastsTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// PERF: Use watch() - cannot use select() inside PageView.builder
// PERF: Use select() to only rebuild when podcasts, loading state, or enabled providers changes
// Use podcastsUnfiltered to avoid double-filtering with MA's provider filter
final maProvider = context.watch<MusicAssistantProvider>();
final allPodcasts = maProvider.podcastsUnfiltered;
final isLoading = maProvider.isLoadingPodcasts;
final enabledProviders = maProvider.enabledProviderIds.toSet();
final (allPodcasts, isLoading, enabledProviders) = context.select<MusicAssistantProvider, (List<MediaItem>, bool, Set<String>)>(
(p) => (p.podcastsUnfiltered, p.isLoadingPodcasts, p.enabledProviderIds.toSet()),
);
// Use read() for methods that don't need reactive updates
final maProvider = context.read<MusicAssistantProvider>();
if (isLoading) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
@@ -3723,12 +3719,13 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
Widget _buildRadioStationsTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// PERF: Use watch() - cannot use select() inside PageView.builder
// PERF: Use select() to only rebuild when radio stations, loading state, or enabled providers changes
// Use radioStationsUnfiltered to avoid double-filtering with MA's provider filter
final maProvider = context.watch<MusicAssistantProvider>();
final allRadioStations = maProvider.radioStationsUnfiltered;
final isLoading = maProvider.isLoadingRadio;
final enabledProviders = maProvider.enabledProviderIds.toSet();
final (allRadioStations, isLoading, enabledProviders) = context.select<MusicAssistantProvider, (List<MediaItem>, bool, Set<String>)>(
(p) => (p.radioStationsUnfiltered, p.isLoadingRadio, p.enabledProviderIds.toSet()),
);
// Use read() for methods that don't need reactive updates
final maProvider = context.read<MusicAssistantProvider>();
if (isLoading) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
@@ -3970,11 +3967,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// ============ ARTISTS TAB ============
Widget _buildArtistsTab(BuildContext context, S l10n) {
// Use watch instead of select - cannot use select inside PageView.builder
// Use select to only rebuild when specific fields change
// SyncService changes are handled by _onSyncServiceChanged listener
final provider = context.watch<MusicAssistantProvider>();
final isLoading = provider.isLoading;
final enabledProviders = provider.enabledProviderIds.toSet();
final (isLoading, enabledProviders) = context.select<MusicAssistantProvider, (bool, Set<String>)>(
(p) => (p.isLoading, p.enabledProviderIds.toSet()),
);
final colorScheme = Theme.of(context).colorScheme;
final syncService = SyncService.instance;
@@ -4207,11 +4204,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// ============ ALBUMS TAB ============
Widget _buildAlbumsTab(BuildContext context, S l10n) {
// Use watch instead of select - cannot use select inside PageView.builder
// Use select to only rebuild when specific fields change
// SyncService changes are handled by _onSyncServiceChanged listener
final provider = context.watch<MusicAssistantProvider>();
final isLoading = provider.isLoading;
final enabledProviders = provider.enabledProviderIds.toSet();
final (isLoading, enabledProviders) = context.select<MusicAssistantProvider, (bool, Set<String>)>(
(p) => (p.isLoading, p.enabledProviderIds.toSet()),
);
final colorScheme = Theme.of(context).colorScheme;
final syncService = SyncService.instance;
+4 -9
View File
@@ -590,13 +590,12 @@ 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: () async {
onTap: () {
Navigator.pop(context);
// Add to queue on selected player
final player = maProvider.selectedPlayer;
if (player != null) {
try {
await maProvider.addTracksToQueue(player.playerId, _tracks);
maProvider.addTracksToQueue(player.playerId, _tracks).then((_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -605,11 +604,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
),
);
}
} catch (e) {
if (mounted) {
_showError(S.of(context)!.failedToAddToQueue(e.toString()));
}
}
});
} else {
_showError(S.of(context)!.noPlayerSelected);
}
@@ -887,7 +882,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 12.0),
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
+1 -1
View File
@@ -736,7 +736,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0),
padding: const EdgeInsets.all(16),
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.fromLTRB(24.0, 8.0, 24.0, 24.0),
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
+19 -35
View File
@@ -108,9 +108,6 @@ 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;
@@ -362,8 +359,6 @@ 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);
});
}
@@ -380,10 +375,6 @@ 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;
@@ -409,11 +400,6 @@ 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
@@ -442,11 +428,6 @@ 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
@@ -461,13 +442,7 @@ class SearchScreenState extends State<SearchScreen> {
}
}
// 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;
}
if (mounted) {
// Combine all results
var combinedResults = {
...results,
@@ -485,7 +460,6 @@ class SearchScreenState extends State<SearchScreen> {
_searchResults = combinedResults;
_isSearching = false;
_hasSearched = true;
_hasPendingSearch = false;
_cachedListItems.clear(); // PERF: Clear cache on new results
_cachedAvailableFilters = null;
});
@@ -501,15 +475,13 @@ class SearchScreenState extends State<SearchScreen> {
if (keepFocus && _focusNode.hasFocus) {
_focusNode.requestFocus();
}
}
} catch (e) {
_logger.log('Search error: $e');
if (!mounted) return;
// Race condition fix: Only update error state if this is still the latest search
if (searchId == _currentSearchId) {
if (mounted) {
setState(() {
_isSearching = false;
_hasSearched = true;
_hasPendingSearch = false;
_searchError = S.of(context)!.searchFailed;
});
if (keepFocus && _focusNode.hasFocus) {
@@ -598,9 +570,9 @@ class SearchScreenState extends State<SearchScreen> {
),
const SizedBox(width: 8),
// Library-only toggle with circle background
Tooltip(
message: S.of(context)!.libraryOnly,
child: SizedBox(
Builder(
builder: (context) {
final control = SizedBox(
width: 44,
height: 44,
child: Material(
@@ -626,7 +598,19 @@ class SearchScreenState extends State<SearchScreen> {
),
),
),
),
);
// Avoid crash when Overlay isn't available (e.g., certain overlay contexts).
final hasOverlay = Overlay.maybeOf(context) != null;
if (!hasOverlay) {
return control;
}
return Tooltip(
message: S.of(context)!.libraryOnly,
child: control,
);
},
),
],
),
+18 -529
View File
@@ -1,10 +1,6 @@
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';
@@ -26,11 +22,11 @@ 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;
bool _showDiscoverAlbums = true;
bool _showExternalMixes = false;
// Favorites rows (default off)
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
@@ -51,47 +47,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _showHints = true;
// Display settings
bool _showProviderIcons = true;
// Discovery folders state
List<RecommendationFolder> _discoveryFolders = [];
Map<String, bool> _discoveryRowEnabled = {};
bool _isLoadingDiscoveryFolders = false;
/// Filtered row order for the draggable list (excludes discovery-mixes)
/// Shows rows in home order, plus any discovery rows from provider that aren't in home order yet
List<String> get _filteredRowOrder {
final rows = <String>[];
// First, add all rows from home order (excluding discovery-mixes)
for (final rowId in _homeRowOrder) {
if (rowId == 'discovery-mixes') continue;
rows.add(rowId);
}
// Then, 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 {
@@ -109,6 +69,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final showRecent = await SettingsService.getShowRecentAlbums();
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
final showExternalMixes = await SettingsService.getShowExternalMixes();
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
@@ -134,14 +95,12 @@ 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;
_showDiscoverArtists = showDiscArtists;
_showDiscoverAlbums = showDiscAlbums;
_showExternalMixes = showExternalMixes;
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
@@ -156,12 +115,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
_volumePrecisionMode = volumePrecision;
_showHints = showHints;
_showProviderIcons = showProviderIcons;
_discoveryRowEnabled = discoveryRowPrefs;
});
}
// Load discovery folders after initial state is set
_loadDiscoveryFolders();
}
@override
@@ -171,84 +126,9 @@ 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};
@@ -256,8 +136,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
return {'title': s.discoverArtists, 'subtitle': s.showRandomArtists};
case 'discover-albums':
return {'title': s.discoverAlbums, 'subtitle': s.showRandomAlbums};
case 'discovery-mixes':
return {'title': s.discoveryMixes, 'subtitle': s.discoveryMixesDescription};
case 'external-mixes':
return {'title': s.playlistMixes, 'subtitle': s.showPlaylistMixes};
case 'continue-listening':
return {'title': s.continueListening, 'subtitle': s.showAudiobooksInProgress};
case 'discover-audiobooks':
@@ -283,11 +163,6 @@ 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;
@@ -295,6 +170,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
return _showDiscoverArtists;
case 'discover-albums':
return _showDiscoverAlbums;
case 'external-mixes':
return _showExternalMixes;
case 'continue-listening':
return _showContinueListeningAudiobooks;
case 'discover-audiobooks':
@@ -321,14 +198,6 @@ 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;
@@ -342,6 +211,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showDiscoverAlbums = value;
SettingsService.setShowDiscoverAlbums(value);
break;
case 'external-mixes':
_showExternalMixes = value;
SettingsService.setShowExternalMixes(value);
break;
case 'continue-listening':
_showContinueListeningAudiobooks = value;
SettingsService.setShowContinueListeningAudiobooks(value);
@@ -983,7 +856,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 16),
// Reorderable home rows list (exclude discovery-mixes from list)
// Reorderable home rows list
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -994,42 +867,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: _filteredRowOrder.length,
itemCount: _homeRowOrder.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(() {
// 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);
}
}
if (newIndex > oldIndex) newIndex--;
final item = _homeRowOrder.removeAt(oldIndex);
_homeRowOrder.insert(newIndex, item);
});
SettingsService.setHomeRowOrder(_homeRowOrder);
},
itemBuilder: (context, index) {
final rowId = _filteredRowOrder[index];
final rowId = _homeRowOrder[index];
final rowInfo = _getRowInfo(rowId);
final isEnabled = _getRowEnabled(rowId);
return Container(
key: ValueKey(rowId),
decoration: BoxDecoration(
border: index < _filteredRowOrder.length - 1
border: index < _homeRowOrder.length - 1
? Border(bottom: BorderSide(color: colorScheme.outline.withOpacity(0.2)))
: null,
),
@@ -1160,149 +1015,6 @@ 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,
@@ -1380,227 +1092,4 @@ 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),
),
],
),
),
),
);
}
}
+28 -84
View File
@@ -1,7 +1,7 @@
import 'dart:convert';
import '../models/media_item.dart';
import '../models/player.dart';
import '../models/recommendation_folder.dart';
import '../models/player.dart';
import '../services/debug_logger.dart';
import '../services/database_service.dart';
import '../constants/timings.dart';
@@ -11,7 +11,6 @@ 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
@@ -22,14 +21,14 @@ class CacheService {
List<Album>? _cachedRecentAlbums;
List<Artist>? _cachedDiscoverArtists;
List<Album>? _cachedDiscoverAlbums;
List<RecommendationFolder>? _cachedDiscoveryFolders;
List<RecommendationFolder>? _cachedExternalMixes;
List<Audiobook>? _cachedInProgressAudiobooks;
List<Audiobook>? _cachedDiscoverAudiobooks;
List<AudiobookSeries>? _cachedDiscoverSeries;
DateTime? _recentAlbumsLastFetched;
DateTime? _discoverArtistsLastFetched;
DateTime? _discoverAlbumsLastFetched;
DateTime? _discoveryFoldersLastFetched;
DateTime? _externalMixesLastFetched;
DateTime? _inProgressAudiobooksLastFetched;
DateTime? _discoverAudiobooksLastFetched;
DateTime? _discoverSeriesLastFetched;
@@ -59,14 +58,6 @@ 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;
@@ -81,11 +72,6 @@ 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');
@@ -107,11 +93,6 @@ 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');
@@ -133,11 +114,6 @@ 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');
@@ -145,30 +121,25 @@ class CacheService {
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
}
/// Check if discovery folders cache is valid
bool isDiscoveryFoldersCacheValid({bool forceRefresh = false}) {
/// Check if external mixes cache is valid
bool isExternalMixesCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedDiscoveryFolders != null &&
_discoveryFoldersLastFetched != null &&
now.difference(_discoveryFoldersLastFetched!) < Timings.homeRowCacheDuration;
return _cachedExternalMixes != null &&
_externalMixesLastFetched != null &&
now.difference(_externalMixesLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached discovery folders
List<RecommendationFolder>? getCachedDiscoveryFolders() => _cachedDiscoveryFolders;
/// Get cached external mixes
List<RecommendationFolder>? getCachedExternalMixes() => _cachedExternalMixes;
/// 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');
/// Set cached external mixes
void setCachedExternalMixes(List<RecommendationFolder> mixes) {
_cachedExternalMixes = mixes;
_externalMixesLastFetched = DateTime.now();
_logger.log('✅ Cached ${mixes.length} external mix shelves');
// Persist to database for instant load on next launch
_persistHomeRowToDatabase('discovery_folders', folders.map((f) => f.toJson()).toList());
_persistHomeRowToDatabase('external_mixes', mixes.map((m) => m.toJson()).toList());
}
/// Check if in-progress audiobooks cache is valid
@@ -185,11 +156,6 @@ 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');
@@ -209,11 +175,6 @@ 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');
@@ -233,11 +194,6 @@ 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');
@@ -259,7 +215,7 @@ class CacheService {
_recentAlbumsLastFetched = null;
_discoverArtistsLastFetched = null;
_discoverAlbumsLastFetched = null;
_discoveryFoldersLastFetched = null;
_externalMixesLastFetched = null;
_inProgressAudiobooksLastFetched = null;
_discoverAudiobooksLastFetched = null;
_discoverSeriesLastFetched = null;
@@ -289,6 +245,8 @@ class CacheService {
_discoverArtistsLastFetched = null;
_cachedDiscoverAlbums = null;
_discoverAlbumsLastFetched = null;
_cachedExternalMixes = null;
_externalMixesLastFetched = null;
_cachedRecentAlbums = null;
_recentAlbumsLastFetched = null;
_searchCache.clear();
@@ -517,22 +475,10 @@ 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;
@@ -581,24 +527,22 @@ class CacheService {
}
}
// Load discovery folders
final foldersData = await DatabaseService.instance.getHomeRowCache('discovery_folders');
if (foldersData != null) {
// Load external mixes
final mixesData = await DatabaseService.instance.getHomeRowCache('external_mixes');
if (mixesData != null) {
try {
final items = (jsonDecode(foldersData.itemsJson) as List)
final items = (jsonDecode(mixesData.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');
_cachedExternalMixes = items;
_externalMixesLastFetched = mixesData.lastUpdated;
_logger.log('📦 Loaded ${items.length} external mix shelves from database');
} catch (e) {
_logger.log('⚠️ Failed to parse discovery folders: $e');
_logger.log('⚠️ Failed to parse external mixes: $e');
}
}
} catch (e) {
_logger.log('⚠️ Error loading home rows from database: $e');
} finally {
_homeRowsLoaded = true;
}
}
-97
View File
@@ -1,97 +0,0 @@
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();
}
}
+1 -2
View File
@@ -106,7 +106,6 @@ class LibraryStatusService extends ChangeNotifier {
required bool Function(dynamic item) getFavorite,
required String Function(dynamic item) getProvider,
required String Function(dynamic item) getItemId,
bool notify = true,
}) {
bool changed = false;
@@ -131,7 +130,7 @@ class LibraryStatusService extends ChangeNotifier {
}
}
if (changed && notify) {
if (changed) {
notifyListeners();
}
}
+24 -24
View File
@@ -215,7 +215,6 @@ 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'));
@@ -414,7 +413,8 @@ class MusicAssistantAPI {
args: args,
);
final items = response['result'] as List<dynamic>?;
final items = response['result'] as List<dynamic>? ??
response['items'] as List<dynamic>?;
if (items == null) {
return [];
}
@@ -594,6 +594,28 @@ class MusicAssistantAPI {
}
}
// ============ RECOMMENDATIONS ============
/// Get recommendation folders (mixes, recommendations, etc.)
Future<List<RecommendationFolder>> getRecommendations() async {
try {
final response = await _sendCommand(
'music/recommendations',
);
final items = response['result'] as List<dynamic>?;
if (items == null) return [];
return items
.whereType<Map<String, dynamic>>()
.map(RecommendationFolder.fromJson)
.toList();
} catch (e) {
_logger.log('Error getting recommendations: $e');
return [];
}
}
// ============ PODCASTS ============
/// Get all podcasts from the library
@@ -1196,28 +1218,6 @@ 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 {
-319
View File
@@ -1,319 +0,0 @@
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(' ');
}
}
-221
View File
@@ -1,221 +0,0 @@
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);
}
}
-96
View File
@@ -1,96 +0,0 @@
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': [],
};
}
}
-3
View File
@@ -657,12 +657,9 @@ class SendspinService {
void _updateState(SendspinConnectionState newState) {
if (_state != newState) {
_state = newState;
// Don't try to emit if disposed or controller is closed
if (!_isDisposed && !_stateController.isClosed) {
_stateController.add(newState);
}
}
}
/// Disconnect from server using Sendspin's client/goodbye message
Future<void> disconnect() async {
+15 -51
View File
@@ -94,7 +94,7 @@ class SettingsService {
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 _keyShowExternalMixes = 'show_external_mixes';
static const String _keyShowContinueListeningAudiobooks = 'show_continue_listening_audiobooks';
static const String _keyShowDiscoverAudiobooks = 'show_discover_audiobooks';
static const String _keyShowDiscoverSeries = 'show_discover_series';
@@ -107,11 +107,21 @@ 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 (only these 3 rows enabled by default on first launch)
// Default row order
static const List<String> defaultHomeRowOrder = [
'recent-albums',
'discover-artists',
'discover-albums',
'external-mixes',
'continue-listening',
'discover-audiobooks',
'discover-series',
'favorite-albums',
'favorite-artists',
'favorite-tracks',
'favorite-playlists',
'favorite-radio-stations',
'favorite-podcasts',
];
// View Mode Settings
@@ -169,9 +179,6 @@ 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);
@@ -386,9 +393,8 @@ 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);
static Future<bool> getShowExternalMixes() => _getBool(_keyShowExternalMixes, defaultValue: false);
static Future<void> setShowExternalMixes(bool show) => _setBool(_keyShowExternalMixes, show);
// Home Screen Audiobook Rows (default off - optional)
static Future<bool> getShowContinueListeningAudiobooks() => _getBool(_keyShowContinueListeningAudiobooks, defaultValue: false);
@@ -938,7 +944,7 @@ class SettingsService {
/// for fine-grained control (10x more precise)
static Future<bool> getVolumePrecisionMode() async {
final prefs = await _getPrefs();
return prefs.getBool(_keyVolumePrecisionMode) ?? false;
return prefs.getBool(_keyVolumePrecisionMode) ?? true;
}
/// Set whether volume precision mode is enabled
@@ -975,46 +981,4 @@ 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);
}
}
-27
View File
@@ -1,27 +0,0 @@
/// 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
}
}
-162
View File
@@ -1,162 +0,0 @@
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',
);
}
-135
View File
@@ -1,135 +0,0 @@
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);
}
}
-30
View File
@@ -1,30 +0,0 @@
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;
}
}
+2 -2
View File
@@ -332,12 +332,12 @@ class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
initialImageUrl: imageUrl,
),
),
);
).then((_) {
// Reset after navigation debounce delay
Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isNavigating = false;
});
});
},
onLongPressStart: (details) {
MediaContextMenu.show(
+2 -2
View File
@@ -114,7 +114,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0;
const textAreaHeight = 52.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
@@ -158,7 +158,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
// Total row height includes title + content
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
+2 -2
View File
@@ -318,12 +318,12 @@ class _ArtistCardState extends State<ArtistCard> with LibraryStatusMixin {
initialImageUrl: imageUrl,
),
),
);
).then((_) {
// Reset after navigation debounce delay
Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isNavigating = false;
});
});
},
onLongPressStart: (details) {
MediaContextMenu.show(
+2 -2
View File
@@ -114,7 +114,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
// Card layout: circle image + name below
// Text area: 8px gap + ~36px for 2-line name = ~44px
const textAreaHeight = 44.0;
const textAreaHeight = 52.0;
final imageSize = contentHeight - textAreaHeight;
final cardWidth = imageSize; // Card width = image width (circle)
final itemExtent = cardWidth + 16; // width + horizontal margins
@@ -154,7 +154,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
// Total row height includes title + content
final totalHeight = widget.rowHeight ?? 207.0; // Default: 44 title + 163 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
+2 -2
View File
@@ -121,7 +121,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
);
}
const textAreaHeight = 44.0;
const textAreaHeight = 52.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize;
final itemExtent = cardWidth + 12;
@@ -162,7 +162,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
final maProvider = context.read<MusicAssistantProvider>();
final totalHeight = widget.rowHeight ?? 237.0;
const titleHeight = 44.0;
const titleHeight = 52.0;
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
-282
View File
@@ -1,282 +0,0 @@
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 + ~18px title + ~18px subtitle = ~44px
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
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),
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,
);
}
}
+2 -17
View File
@@ -417,7 +417,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_controller.removeListener(_recordAnimationFrame);
_lyricsScrollController.removeListener(_onLyricsScroll);
_expansionNotifyTimer?.cancel();
_controller.dispose();
_queuePanelController.dispose();
_sleepTimerPanelController.dispose();
@@ -616,8 +615,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// PERF Phase 4: Track last notified value to avoid unnecessary object creation
double _lastNotifiedProgress = -1;
Timer? _expansionNotifyTimer;
double? _pendingProgress;
void _notifyExpansionProgress() {
final progress = _controller.value;
@@ -626,24 +623,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
if ((progress - _lastNotifiedProgress).abs() >= 0.01 ||
progress == 0.0 || progress == 1.0) {
_lastNotifiedProgress = progress;
_pendingProgress = progress;
// Cancel any pending timer to avoid queue buildup
_expansionNotifyTimer?.cancel();
// Debounce with a short delay to ensure we're outside build phase
_expansionNotifyTimer = Timer(const Duration(milliseconds: 1), () {
if (mounted && _pendingProgress != null) {
playerExpansionNotifier.value = PlayerExpansionState(
_pendingProgress!,
progress,
_currentExpandedBgColor,
_currentExpandedPrimaryColor,
);
}
_expansionNotifyTimer = null;
_pendingProgress = null;
});
}
}
void _subscribeToPositionTracker() {
@@ -1656,7 +1641,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_slideController.addListener(animateToTarget);
_slideController.duration = const Duration(milliseconds: 150);
_slideController.forward().then((_) async {
_slideController.forward().then((_) {
if (!mounted) return;
_slideController.removeListener(animateToTarget);
+12 -4
View File
@@ -69,9 +69,7 @@ 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(
final content = Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
border: Border.all(color: color, width: 1.5),
@@ -108,7 +106,17 @@ class HiResBadge extends StatelessWidget {
),
],
),
),
);
// Avoid crash when Overlay isn't available (e.g., certain overlay contexts).
final hasOverlay = Overlay.maybeOf(context) != null;
if (!hasOverlay) {
return content;
}
return Tooltip(
message: tooltip ?? 'Hi-Res Audio',
child: content,
);
}
}
+1 -10
View File
@@ -109,8 +109,6 @@ mixin LibraryStatusMixin<T extends StatefulWidget> on State<T> {
/// Whether the item is currently a favorite
bool get isFavorite => LibraryStatusService.instance.isFavorite(libraryItemKey);
bool _isStatusUpdatePending = false;
@override
void initState() {
super.initState();
@@ -124,17 +122,10 @@ mixin LibraryStatusMixin<T extends StatefulWidget> on State<T> {
}
void _onLibraryStatusChanged() {
// Use addPostFrameCallback to ensure setState is called AFTER the build phase
// This prevents "setState during build" errors
if (_isStatusUpdatePending) return;
_isStatusUpdatePending = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
// Trigger rebuild to pick up new status values
if (mounted) {
setState(() {});
}
_isStatusUpdatePending = false;
});
}
/// Set library status optimistically and return previous value for rollback
+230
View File
@@ -0,0 +1,230 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart';
import '../models/recommendation_folder.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import 'playlist_card.dart';
import 'radio_station_card.dart';
class MixesRow extends StatefulWidget {
final String title;
final Future<List<RecommendationFolder>> Function() loadMixes;
final List<RecommendationFolder>? Function()? getCachedMixes;
final String? heroTagSuffix;
final double? rowHeight;
const MixesRow({
super.key,
required this.title,
required this.loadMixes,
this.getCachedMixes,
this.heroTagSuffix,
this.rowHeight,
});
@override
State<MixesRow> createState() => _MixesRowState();
}
class _MixesRowState extends State<MixesRow> with AutomaticKeepAliveClientMixin {
List<RecommendationFolder> _mixes = [];
bool _isLoading = true;
bool _hasLoaded = false;
static final _logger = DebugLogger();
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
final cached = widget.getCachedMixes?.call();
if (cached != null && cached.isNotEmpty) {
_mixes = cached;
_isLoading = false;
}
_loadMixes();
}
Future<void> _loadMixes() async {
if (_hasLoaded) return;
_hasLoaded = true;
try {
final freshMixes = await widget.loadMixes();
if (mounted && freshMixes.isNotEmpty) {
setState(() {
_mixes = freshMixes;
_isLoading = false;
});
_precacheMixImages(freshMixes);
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
void _precacheMixImages(List<RecommendationFolder> mixes) {
if (!mounted) return;
final maProvider = context.read<MusicAssistantProvider>();
final itemsToCache = <MediaItem>[];
for (final folder in mixes) {
itemsToCache.addAll(folder.items);
if (itemsToCache.length >= 10) break;
}
for (final mix in itemsToCache.take(10)) {
final imageUrl = maProvider.api?.getImageUrl(mix, size: 256);
if (imageUrl != null) {
precacheImage(
CachedNetworkImageProvider(imageUrl),
context,
).catchError((_) => false);
}
}
}
Widget _buildShelfContent(List<MediaItem> items, double contentHeight) {
const textAreaHeight = 52.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize;
final itemExtent = cardWidth + 12;
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
clipBehavior: Clip.none,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: items.length,
itemExtent: itemExtent,
cacheExtent: 500,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemBuilder: (context, index) {
final mix = items[index];
final key = ValueKey(mix.uri ?? mix.itemId);
Widget card;
if (mix is Playlist) {
card = PlaylistCard(
playlist: mix,
heroTagSuffix: widget.heroTagSuffix,
imageCacheSize: 256,
);
} else if (mix.mediaType == MediaType.radio) {
card = RadioStationCard(
radioStation: mix,
heroTagSuffix: widget.heroTagSuffix,
imageCacheSize: 256,
);
} else {
card = const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Container(
key: key,
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: card,
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
_logger.startBuild('MixesRow:${widget.title}');
super.build(context);
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
final baseRowHeight = widget.rowHeight ?? 237.0;
const titleHeight = 52.0;
final contentHeight = baseRowHeight - titleHeight;
final visibleFolders = _mixes.where((folder) {
final name = folder.name.trim().toLowerCase();
final id = folder.itemId.trim().toLowerCase();
final isRecentlyPlayed = name == 'recently played' || id.contains('recently_played');
return !isRecentlyPlayed;
}).toList();
final result = RepaintBoundary(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_mixes.isEmpty && _isLoading)
SizedBox(
height: baseRowHeight - titleHeight,
child: const Center(child: CircularProgressIndicator()),
)
else if (_mixes.isEmpty || visibleFolders.isEmpty)
SizedBox(
height: baseRowHeight - titleHeight,
child: Center(
child: Text(
S.of(context)!.noPlaylistMixesFound,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
),
)
else
...List<Widget>.generate(visibleFolders.length * 2 - 1, (index) {
if (index.isOdd) return const SizedBox(height: 8.0);
final folder = visibleFolders[index ~/ 2];
return SizedBox(
height: baseRowHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Text(
folder.name,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
),
),
),
Expanded(
child: _buildShelfContent(folder.items, contentHeight),
),
],
),
);
}),
],
),
);
_logger.endBuild('MixesRow:${widget.title}');
return result;
}
}
class _StretchScrollBehavior extends ScrollBehavior {
const _StretchScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context, Widget child, ScrollableDetails details) {
return StretchingOverscrollIndicator(
axisDirection: details.direction,
child: child,
);
}
}
+8 -11
View File
@@ -80,7 +80,9 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
super.initState();
// Load hint settings
_loadHintSettings();
SettingsService.getShowHints().then((value) {
if (mounted) setState(() => _showHints = value);
});
_revealController = AnimationController(
vsync: this,
@@ -106,21 +108,15 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
_revealController.forward();
// Auto-refresh player data
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) async {
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) {
if (mounted) {
final provider = context.read<MusicAssistantProvider>();
provider.refreshPlayers();
await provider.preloadAllPlayerTracks();
_preloadColorsForPlayers();
provider.preloadAllPlayerTracks().then((_) => _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>();
@@ -168,7 +164,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
}
/// Animate dismissal and call callback when done
Future<void> dismiss() async {
void dismiss() {
// Trigger single bounce on mini player when cards are ~70% collapsed
// This creates the effect of cards "landing" on the mini player
bool bounceFired = false;
@@ -192,9 +188,10 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
_revealController.duration = const Duration(milliseconds: 150);
_revealController.addListener(checkBounce);
await _revealController.reverse();
_revealController.reverse().then((_) {
_revealController.removeListener(checkBounce);
widget.onDismiss();
});
}
/// Check if scroll is at top (or not scrollable)
+3 -2
View File
@@ -207,10 +207,11 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
_dropdownController.forward();
}
void _closeTransferDropdown() async {
void _closeTransferDropdown() {
if (!_showingTransferDropdown) return;
await _dropdownController.reverse();
_dropdownController.reverse().then((_) {
if (mounted) setState(() => _showingTransferDropdown = false);
});
}
void _handleTransferQueue(BuildContext context) {
+2 -2
View File
@@ -255,12 +255,12 @@ class _PlaylistCardState extends State<PlaylistCard> with LibraryStatusMixin {
initialImageUrl: imageUrl,
),
),
);
).then((_) {
// Reset after navigation debounce delay
Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isNavigating = false;
});
});
},
onLongPressStart: (details) {
MediaContextMenu.show(
+2 -2
View File
@@ -107,7 +107,7 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
// Card layout: square artwork + text below (same as AlbumRow)
// Text area: 8px gap + ~18px title + ~18px owner = ~44px
const textAreaHeight = 44.0;
const textAreaHeight = 52.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
@@ -152,7 +152,7 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
// Total row height includes title + content (same as AlbumRow)
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
+2 -2
View File
@@ -261,12 +261,12 @@ class _PodcastCardState extends State<PodcastCard> with LibraryStatusMixin {
initialImageUrl: imageUrl,
),
),
);
).then((_) {
// Reset after navigation debounce delay
Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isNavigating = false;
});
});
},
onLongPressStart: (details) {
MediaContextMenu.show(
+2 -2
View File
@@ -115,7 +115,7 @@ class _PodcastRowState extends State<PodcastRow> with AutomaticKeepAliveClientMi
// Card layout: square artwork + text below (same as AlbumRow)
// Text area: 8px gap + ~18px title + ~18px author = ~44px
const textAreaHeight = 44.0;
const textAreaHeight = 52.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
@@ -160,7 +160,7 @@ class _PodcastRowState extends State<PodcastRow> with AutomaticKeepAliveClientMi
// Total row height includes title + content (same as AlbumRow)
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
+2 -2
View File
@@ -107,7 +107,7 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
// Card layout: circle image + name below (same as ArtistRow)
// Text area: 8px gap + ~36px for 2-line name = ~44px
const textAreaHeight = 44.0;
const textAreaHeight = 52.0;
final imageSize = contentHeight - textAreaHeight;
final cardWidth = imageSize; // Card width = image width (circle)
final itemExtent = cardWidth + 16; // width + horizontal margins
@@ -148,7 +148,7 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
// Total row height includes title + content (same as ArtistRow)
final totalHeight = widget.rowHeight ?? 207.0; // Default: 44 title + 163 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
+2 -2
View File
@@ -179,7 +179,7 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
);
}
const textAreaHeight = 44.0;
const textAreaHeight = 52.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize;
final itemExtent = cardWidth + 12;
@@ -231,7 +231,7 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
final maProvider = context.read<MusicAssistantProvider>();
final totalHeight = widget.rowHeight ?? 237.0;
const titleHeight = 44.0;
const titleHeight = 52.0;
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
+2 -2
View File
@@ -109,7 +109,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0;
const textAreaHeight = 52.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12;
@@ -150,7 +150,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
// Total row height includes title + content
final totalHeight = widget.rowHeight ?? 224.0; // Default: 44 title + 180 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
+1 -33
View File
@@ -1106,30 +1106,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
url: "https://pub.dev"
source: hosted
version: "6.3.28"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
url: "https://pub.dev"
source: hosted
version: "6.3.6"
url_launcher_linux:
dependency: transitive
description:
@@ -1138,14 +1114,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
@@ -1284,4 +1252,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.0"
flutter: ">=3.24.0"