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"> <div align="center">
<img src="assets/images/ensemble_logo.png" alt="Ensemble Logo" height="200"> <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> <p><strong>An unofficial mobile client for Music Assistant</strong></p>
+1 -1
View File
@@ -30,7 +30,7 @@ if (keystorePropertiesFile.exists()) {
android { android {
namespace "com.collotsspot.ensemble" namespace "com.collotsspot.ensemble"
compileSdk 36 compileSdk 35
ndkVersion flutter.ndkVersion ndkVersion flutter.ndkVersion
compileOptions { compileOptions {
-6
View File
@@ -1,11 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/> <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.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
+1 -1
View File
@@ -9,7 +9,7 @@ buildscript {
} }
dependencies { 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" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
} }
} }
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists 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 { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" 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 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) /// Player state polling interval (for selected player updates)
static const Duration playerPollingInterval = Duration(seconds: 5); 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 /// Local player state report interval
/// Note: Sendspin protocol only requires state on changes, not periodic reports. /// 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. /// 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 /// Default limit for recent/random items
static const int defaultRecentLimit = 10; 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 { $$RecentlyPlayedTableProcessedTableManager get recentlyPlayedRefs {
final manager = $$RecentlyPlayedTableTableManager($_db, $_db.recentlyPlayed) final manager = $$RecentlyPlayedTableTableManager($_db, $_db.recentlyPlayed)
.filter((f) => f.profileUsername.username .filter((f) => f.profileUsername.username($_item.username));
.sqlEquals($_itemColumn<String>('username')!));
final cache = $_typedResult.readTableOrNull(_recentlyPlayedRefsTable($_db)); final cache = $_typedResult.readTableOrNull(_recentlyPlayedRefsTable($_db));
return ProcessedTableManager( return ProcessedTableManager(
@@ -3190,8 +3189,7 @@ class $$ProfilesTableTableManager extends RootTableManager<
getPrefetchedDataCallback: (items) async { getPrefetchedDataCallback: (items) async {
return [ return [
if (recentlyPlayedRefs) if (recentlyPlayedRefs)
await $_getPrefetchedData<Profile, $ProfilesTable, await $_getPrefetchedData(
RecentlyPlayedData>(
currentTable: table, currentTable: table,
referencedTable: $$ProfilesTableReferences referencedTable: $$ProfilesTableReferences
._recentlyPlayedRefsTable(db), ._recentlyPlayedRefsTable(db),
@@ -3256,10 +3254,8 @@ final class $$RecentlyPlayedTableReferences extends BaseReferences<
db.recentlyPlayed.profileUsername, db.profiles.username)); db.recentlyPlayed.profileUsername, db.profiles.username));
$$ProfilesTableProcessedTableManager get profileUsername { $$ProfilesTableProcessedTableManager get profileUsername {
final $_column = $_itemColumn<String>('profile_username')!;
final manager = $$ProfilesTableTableManager($_db, $_db.profiles) final manager = $$ProfilesTableTableManager($_db, $_db.profiles)
.filter((f) => f.username.sqlEquals($_column)); .filter((f) => f.username($_item.profileUsername));
final item = $_typedResult.readTableOrNull(_profileUsernameTable($_db)); final item = $_typedResult.readTableOrNull(_profileUsernameTable($_db));
if (item == null) return manager; if (item == null) return manager;
return ProcessedTableManager( return ProcessedTableManager(
+4 -11
View File
@@ -52,6 +52,7 @@
"recentlyPlayed": "Zuletzt gespielt", "recentlyPlayed": "Zuletzt gespielt",
"discoverArtists": "Kunstler entdecken", "discoverArtists": "Kunstler entdecken",
"discoverAlbums": "Alben entdecken", "discoverAlbums": "Alben entdecken",
"playlistMixes": "Playlists entdecken",
"continueListening": "Weiterhoren", "continueListening": "Weiterhoren",
"discoverAudiobooks": "Horbücher entdecken", "discoverAudiobooks": "Horbücher entdecken",
"discoverSeries": "Serien entdecken", "discoverSeries": "Serien entdecken",
@@ -171,6 +172,7 @@
"noArtistsFound": "Keine Künstler gefunden", "noArtistsFound": "Keine Künstler gefunden",
"noTracksFound": "Keine Songs gefunden", "noTracksFound": "Keine Songs gefunden",
"noPlaylistsFound": "Keine Wiedergabelisten gefunden", "noPlaylistsFound": "Keine Wiedergabelisten gefunden",
"noPlaylistMixesFound": "Keine Playlist-Mixes gefunden",
"queueIsEmpty": "Warteschlange ist leer", "queueIsEmpty": "Warteschlange ist leer",
"noResultsFound": "Keine Ergebnisse gefunden", "noResultsFound": "Keine Ergebnisse gefunden",
"refresh": "Aktualisieren", "refresh": "Aktualisieren",
@@ -216,6 +218,7 @@
"showRecentlyPlayedAlbums": "Kürzlich gespielte Alben anzeigen", "showRecentlyPlayedAlbums": "Kürzlich gespielte Alben anzeigen",
"showRandomArtists": "Zufällige Künstler zum Entdecken anzeigen", "showRandomArtists": "Zufällige Künstler zum Entdecken anzeigen",
"showRandomAlbums": "Zufällige Alben zum Entdecken anzeigen", "showRandomAlbums": "Zufällige Alben zum Entdecken anzeigen",
"showPlaylistMixes": "Playlist-Mixes von externen Anbietern anzeigen",
"showAudiobooksInProgress": "Hörbücher in Bearbeitung anzeigen", "showAudiobooksInProgress": "Hörbücher in Bearbeitung anzeigen",
"showRandomAudiobooks": "Zufällige Hörbücher zum Entdecken anzeigen", "showRandomAudiobooks": "Zufällige Hörbücher zum Entdecken anzeigen",
"showRandomSeries": "Zufällige Hörbuchserien zum Entdecken anzeigen", "showRandomSeries": "Zufällige Hörbuchserien zum Entdecken anzeigen",
@@ -295,15 +298,5 @@
"showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen", "showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen",
"pullToSelectPlayers": "Ziehen um Geräte auszuwählen", "pullToSelectPlayers": "Ziehen um Geräte auszuwählen",
"holdToSync": "Gedrückt halten zum Sync", "holdToSync": "Gedrückt halten zum Sync",
"swipeToAdjustVolume": "Wischen für Lautstärke", "swipeToAdjustVolume": "Wischen für Lautstärke"
"aboutAndSupport": "Über & Unterstützung",
"supportDevelopment": "Entwicklung unterstützen",
"ensembleIsUnofficial": "Ensemble ist ein inoffizieller, von der Community erstellter Client. Erwäge, die Entwicklung zu unterstützen.",
"sponsorOnGitHub": "Auf GitHub sponsoren",
"buyMeAKoFi": "Kauf mir einen Ko-fi",
"aboutEnsemble": "Über Ensemble",
"version": "Version",
"visitGitHubRepo": "GitHub-Repository besuchen",
"reportABug": "Einen Fehler melden"
} }
+9 -83
View File
@@ -221,34 +221,9 @@
"description": "Home screen section title" "description": "Home screen section title"
}, },
"discoveryMixes": "Discovery Mixes", "playlistMixes": "Discover Playlists",
"@discoveryMixes": { "@playlistMixes": {
"description": "Provider discovery mixes (e.g., Tidal playlists)" "description": "Home screen section title"
},
"discoveryMixesDescription": "Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen",
"@discoveryMixesDescription": {
"description": "Description for discovery mixes setting"
},
"discoveryRows": "Discovery rows from Music Assistant",
"@discoveryRows": {
"description": "Label for discovery rows section"
},
"refreshDiscoveryRows": "Refresh available rows",
"@refreshDiscoveryRows": {
"description": "Button to refresh discovery rows"
},
"noDiscoveryRows": "No discovery rows available",
"@noDiscoveryRows": {
"description": "Empty state when no discovery rows available"
},
"discoveryRowBadge": "Music Assistant",
"@discoveryRowBadge": {
"description": "Badge label for discovery rows"
}, },
"continueListening": "Continue Listening", "continueListening": "Continue Listening",
@@ -955,6 +930,11 @@
"description": "Empty playlists" "description": "Empty playlists"
}, },
"noPlaylistMixesFound": "No playlist mixes found",
"@noPlaylistMixesFound": {
"description": "Empty playlist mixes"
},
"queueIsEmpty": "Queue is empty", "queueIsEmpty": "Queue is empty",
"@queueIsEmpty": { "@queueIsEmpty": {
"description": "Empty queue" "description": "Empty queue"
@@ -1115,6 +1095,7 @@
"showRecentlyPlayedAlbums": "Show recently played albums", "showRecentlyPlayedAlbums": "Show recently played albums",
"showRandomArtists": "Show random artists to discover", "showRandomArtists": "Show random artists to discover",
"showRandomAlbums": "Show random albums to discover", "showRandomAlbums": "Show random albums to discover",
"showPlaylistMixes": "Show playlist mixes from external providers",
"showAudiobooksInProgress": "Show audiobooks in progress", "showAudiobooksInProgress": "Show audiobooks in progress",
"showRandomAudiobooks": "Show random audiobooks to discover", "showRandomAudiobooks": "Show random audiobooks to discover",
"showRandomSeries": "Show random audiobook series to discover", "showRandomSeries": "Show random audiobook series to discover",
@@ -1713,60 +1694,5 @@
"selectPlayerToAddToQueue": "Select player to add to queue", "selectPlayerToAddToQueue": "Select player to add to queue",
"@selectPlayerToAddToQueue": { "@selectPlayerToAddToQueue": {
"description": "Hint for selecting player to add to queue" "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", "recentlyPlayed": "Reproducido recientemente",
"discoverArtists": "Descubrir artistas", "discoverArtists": "Descubrir artistas",
"discoverAlbums": "Descubrir álbumes", "discoverAlbums": "Descubrir álbumes",
"playlistMixes": "Descubrir listas de reproducción",
"continueListening": "Continuar escuchando", "continueListening": "Continuar escuchando",
"discoverAudiobooks": "Descubrir audiolibros", "discoverAudiobooks": "Descubrir audiolibros",
"discoverSeries": "Descubrir series", "discoverSeries": "Descubrir series",
@@ -320,6 +321,7 @@
"noArtistsFound": "No se encontraron artistas", "noArtistsFound": "No se encontraron artistas",
"noTracksFound": "No se encontraron canciones", "noTracksFound": "No se encontraron canciones",
"noPlaylistsFound": "No se encontraron listas de reproducción", "noPlaylistsFound": "No se encontraron listas de reproducción",
"noPlaylistMixesFound": "No se encontraron mezclas de playlists",
"queueIsEmpty": "La cola está vacía", "queueIsEmpty": "La cola está vacía",
"noResultsFound": "No se encontraron resultados", "noResultsFound": "No se encontraron resultados",
"refresh": "Actualizar", "refresh": "Actualizar",
@@ -372,6 +374,7 @@
"showRecentlyPlayedAlbums": "Mostrar álbumes reproducidos recientemente", "showRecentlyPlayedAlbums": "Mostrar álbumes reproducidos recientemente",
"showRandomArtists": "Mostrar artistas aleatorios para descubrir", "showRandomArtists": "Mostrar artistas aleatorios para descubrir",
"showRandomAlbums": "Mostrar álbumes aleatorios para descubrir", "showRandomAlbums": "Mostrar álbumes aleatorios para descubrir",
"showPlaylistMixes": "Mostrar mezclas de playlists de proveedores externos",
"showAudiobooksInProgress": "Mostrar audiolibros en progreso", "showAudiobooksInProgress": "Mostrar audiolibros en progreso",
"showRandomAudiobooks": "Mostrar audiolibros aleatorios para descubrir", "showRandomAudiobooks": "Mostrar audiolibros aleatorios para descubrir",
"showRandomSeries": "Mostrar series de audiolibros aleatorias para descubrir", "showRandomSeries": "Mostrar series de audiolibros aleatorias para descubrir",
@@ -491,15 +494,5 @@
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones", "showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
"pullToSelectPlayers": "Desliza para seleccionar dispositivos", "pullToSelectPlayers": "Desliza para seleccionar dispositivos",
"holdToSync": "Mantén presionado para sync", "holdToSync": "Mantén presionado para sync",
"swipeToAdjustVolume": "Desliza para ajustar volumen", "swipeToAdjustVolume": "Desliza para ajustar volumen"
"aboutAndSupport": "Acerca de y Soporte",
"supportDevelopment": "Apoyar el Desarrollo",
"ensembleIsUnofficial": "Ensemble es un cliente no oficial creado por la comunidad. Considera apoyar su desarrollo.",
"sponsorOnGitHub": "Patrocina en GitHub",
"buyMeAKoFi": "Cómprame un Ko-fi",
"aboutEnsemble": "Acerca de Ensemble",
"version": "Versión",
"visitGitHubRepo": "Visitar Repositorio GitHub",
"reportABug": "Reportar un Error"
} }
+12 -11
View File
@@ -221,6 +221,11 @@
"description": "Home screen section title" "description": "Home screen section title"
}, },
"playlistMixes": "Découvrir des playlists",
"@playlistMixes": {
"description": "Home screen section title"
},
"continueListening": "Continuer l'écoute", "continueListening": "Continuer l'écoute",
"@continueListening": { "@continueListening": {
"description": "Home screen section title" "description": "Home screen section title"
@@ -915,6 +920,11 @@
"description": "Empty playlists" "description": "Empty playlists"
}, },
"noPlaylistMixesFound": "Aucun mix de playlists trouve",
"@noPlaylistMixesFound": {
"description": "Empty playlist mixes"
},
"queueIsEmpty": "La file d'attente est vide", "queueIsEmpty": "La file d'attente est vide",
"@queueIsEmpty": { "@queueIsEmpty": {
"description": "Empty queue" "description": "Empty queue"
@@ -1071,6 +1081,7 @@
"showRecentlyPlayedAlbums": "Afficher les albums écoutes recemment", "showRecentlyPlayedAlbums": "Afficher les albums écoutes recemment",
"showRandomArtists": "Afficher des artistes aléatoires a découvrir", "showRandomArtists": "Afficher des artistes aléatoires a découvrir",
"showRandomAlbums": "Afficher des albums 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", "showAudiobooksInProgress": "Afficher les livres audio en cours",
"showRandomAudiobooks": "Afficher des livres audio aléatoires a découvrir", "showRandomAudiobooks": "Afficher des livres audio aléatoires a découvrir",
"showRandomSeries": "Afficher des séries de 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" "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'** /// **'Discover Albums'**
String get discoverAlbums; String get discoverAlbums;
/// Provider discovery mixes (e.g., Tidal playlists) /// Home screen section title
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Discovery Mixes'** /// **'Discover Playlists'**
String get discoveryMixes; String get playlistMixes;
/// Description for discovery mixes setting
///
/// In en, this message translates to:
/// **'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen'**
String get discoveryMixesDescription;
/// Label for discovery rows section
///
/// In en, this message translates to:
/// **'Discovery rows from Music Assistant'**
String get discoveryRows;
/// Button to refresh discovery rows
///
/// In en, this message translates to:
/// **'Refresh available rows'**
String get refreshDiscoveryRows;
/// Empty state when no discovery rows available
///
/// In en, this message translates to:
/// **'No discovery rows available'**
String get noDiscoveryRows;
/// Badge label for discovery rows
///
/// In en, this message translates to:
/// **'Music Assistant'**
String get discoveryRowBadge;
/// Home screen section title /// Home screen section title
/// ///
@@ -1085,6 +1055,12 @@ abstract class S {
/// **'No playlists found'** /// **'No playlists found'**
String get noPlaylistsFound; String get noPlaylistsFound;
/// Empty playlist mixes
///
/// In en, this message translates to:
/// **'No playlist mixes found'**
String get noPlaylistMixesFound;
/// Empty queue /// Empty queue
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1337,6 +1313,12 @@ abstract class S {
/// **'Show random albums to discover'** /// **'Show random albums to discover'**
String get showRandomAlbums; 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. /// No description provided for @showAudiobooksInProgress.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2218,72 +2200,6 @@ abstract class S {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Select player to add to queue'** /// **'Select player to add to queue'**
String get selectPlayerToAddToQueue; 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> { class _SDelegate extends LocalizationsDelegate<S> {
+8 -52
View File
@@ -152,23 +152,7 @@ class SDe extends S {
String get discoverAlbums => 'Alben entdecken'; String get discoverAlbums => 'Alben entdecken';
@override @override
String get discoveryMixes => 'Discovery Mixes'; String get playlistMixes => 'Playlists entdecken';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override @override
String get continueListening => 'Weiterhoren'; String get continueListening => 'Weiterhoren';
@@ -570,6 +554,9 @@ class SDe extends S {
@override @override
String get noPlaylistsFound => 'Keine Wiedergabelisten gefunden'; String get noPlaylistsFound => 'Keine Wiedergabelisten gefunden';
@override
String get noPlaylistMixesFound => 'Keine Playlist-Mixes gefunden';
@override @override
String get queueIsEmpty => 'Warteschlange ist leer'; String get queueIsEmpty => 'Warteschlange ist leer';
@@ -701,6 +688,10 @@ class SDe extends S {
@override @override
String get showRandomAlbums => 'Zufällige Alben zum Entdecken anzeigen'; String get showRandomAlbums => 'Zufällige Alben zum Entdecken anzeigen';
@override
String get showPlaylistMixes =>
'Playlist-Mixes von externen Anbietern anzeigen';
@override @override
String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen'; String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen';
@@ -1205,39 +1196,4 @@ class SDe extends S {
@override @override
String get selectPlayerToAddToQueue => 'Select player to add to queue'; 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'; String get discoverAlbums => 'Discover Albums';
@override @override
String get discoveryMixes => 'Discovery Mixes'; String get playlistMixes => 'Discover Playlists';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override @override
String get continueListening => 'Continue Listening'; String get continueListening => 'Continue Listening';
@@ -565,6 +549,9 @@ class SEn extends S {
@override @override
String get noPlaylistsFound => 'No playlists found'; String get noPlaylistsFound => 'No playlists found';
@override
String get noPlaylistMixesFound => 'No playlist mixes found';
@override @override
String get queueIsEmpty => 'Queue is empty'; String get queueIsEmpty => 'Queue is empty';
@@ -695,6 +682,9 @@ class SEn extends S {
@override @override
String get showRandomAlbums => 'Show random albums to discover'; String get showRandomAlbums => 'Show random albums to discover';
@override
String get showPlaylistMixes => 'Show playlist mixes from external providers';
@override @override
String get showAudiobooksInProgress => 'Show audiobooks in progress'; String get showAudiobooksInProgress => 'Show audiobooks in progress';
@@ -1196,39 +1186,4 @@ class SEn extends S {
@override @override
String get selectPlayerToAddToQueue => 'Select player to add to queue'; 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'; String get discoverAlbums => 'Descubrir álbumes';
@override @override
String get discoveryMixes => 'Discovery Mixes'; String get playlistMixes => 'Descubrir listas de reproducción';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override @override
String get continueListening => 'Continuar escuchando'; String get continueListening => 'Continuar escuchando';
@@ -573,6 +557,9 @@ class SEs extends S {
@override @override
String get noPlaylistsFound => 'No se encontraron listas de reproducción'; String get noPlaylistsFound => 'No se encontraron listas de reproducción';
@override
String get noPlaylistMixesFound => 'No se encontraron mezclas de playlists';
@override @override
String get queueIsEmpty => 'La cola está vacía'; String get queueIsEmpty => 'La cola está vacía';
@@ -706,6 +693,10 @@ class SEs extends S {
@override @override
String get showRandomAlbums => 'Mostrar álbumes aleatorios para descubrir'; String get showRandomAlbums => 'Mostrar álbumes aleatorios para descubrir';
@override
String get showPlaylistMixes =>
'Mostrar mezclas de playlists de proveedores externos';
@override @override
String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso'; String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso';
@@ -1213,39 +1204,4 @@ class SEs extends S {
@override @override
String get selectPlayerToAddToQueue => 'Select player to add to queue'; 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'; String get discoverAlbums => 'Découvrir des albums';
@override @override
String get discoveryMixes => 'Discovery Mixes'; String get playlistMixes => 'Découvrir des playlists';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override @override
String get continueListening => 'Continuer l\'écoute'; String get continueListening => 'Continuer l\'écoute';
@@ -574,6 +558,9 @@ class SFr extends S {
@override @override
String get noPlaylistsFound => 'Aucune playlist trouvee'; String get noPlaylistsFound => 'Aucune playlist trouvee';
@override
String get noPlaylistMixesFound => 'Aucun mix de playlists trouve';
@override @override
String get queueIsEmpty => 'La file d\'attente est vide'; String get queueIsEmpty => 'La file d\'attente est vide';
@@ -708,6 +695,10 @@ class SFr extends S {
@override @override
String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir'; 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 @override
String get showAudiobooksInProgress => 'Afficher les livres audio en cours'; String get showAudiobooksInProgress => 'Afficher les livres audio en cours';
@@ -1222,39 +1213,4 @@ class SFr extends S {
@override @override
String get selectPlayerToAddToQueue => 'Select player to add to queue'; 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:audio_service/audio_service.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/app_localizations.dart'; import 'l10n/app_localizations.dart';
import 'providers/connection_provider.dart';
import 'providers/library_provider.dart';
import 'providers/locale_provider.dart'; import 'providers/locale_provider.dart';
import 'providers/local_player_provider.dart';
import 'providers/music_assistant_provider.dart'; import 'providers/music_assistant_provider.dart';
import 'providers/navigation_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/home_screen.dart';
import 'screens/login_screen.dart'; import 'screens/login_screen.dart';
import 'services/cache_service.dart';
import 'services/settings_service.dart'; import 'services/settings_service.dart';
import 'services/database_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/profile_service.dart';
import 'services/provider_filter_service.dart';
import 'services/sync_service.dart'; import 'services/sync_service.dart';
import 'services/audio/massiv_audio_handler.dart'; import 'services/audio/massiv_audio_handler.dart';
import 'services/auth/auth_manager.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/theme_provider.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
import 'theme/system_theme_helper.dart'; import 'theme/system_theme_helper.dart';
import 'utils/player_sync_state.dart';
import 'widgets/global_player_overlay.dart'; import 'widgets/global_player_overlay.dart';
// Global audio handler instance // Global audio handler instance
@@ -137,21 +125,6 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
late MusicAssistantProvider _musicProvider; late MusicAssistantProvider _musicProvider;
late ThemeProvider _themeProvider; late ThemeProvider _themeProvider;
late LocaleProvider _localeProvider; 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(); final _hardwareVolumeService = HardwareVolumeService();
StreamSubscription? _volumeUpSub; StreamSubscription? _volumeUpSub;
StreamSubscription? _volumeDownSub; StreamSubscription? _volumeDownSub;
@@ -164,91 +137,9 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_musicProvider = MusicAssistantProvider();
_themeProvider = ThemeProvider(); _themeProvider = ThemeProvider();
_localeProvider = LocaleProvider(); _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); WidgetsBinding.instance.addObserver(this);
_initHardwareVolumeControl(); _initHardwareVolumeControl();
// Listen to player selection changes to toggle volume interception // Listen to player selection changes to toggle volume interception
@@ -361,21 +252,9 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiProvider( return MultiProvider(
providers: [ providers: [
// Core facade provider (will be removed after migration)
ChangeNotifierProvider.value(value: _musicProvider), ChangeNotifierProvider.value(value: _musicProvider),
// Theme providers
ChangeNotifierProvider.value(value: _themeProvider), ChangeNotifierProvider.value(value: _themeProvider),
ChangeNotifierProvider.value(value: _localeProvider), 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>( child: Consumer2<ThemeProvider, LocaleProvider>(
builder: (context, themeProvider, localeProvider, _) { builder: (context, themeProvider, localeProvider, _) {
@@ -450,8 +329,17 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
theme: AppTheme.lightTheme(colorScheme: lightColorScheme), theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme), darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
builder: (context, child) { builder: (context, child) {
// Wrap entire app with global player overlay // Wrap entire app with global player overlay.
return GlobalPlayerOverlay(child: child ?? const SizedBox.shrink()); // 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(), home: const AppStartup(),
), ),
@@ -571,24 +459,12 @@ class _AppStartupState extends State<AppStartup> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
try {
_loadSettingsFirst(); _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. /// Load settings BEFORE checking connection.
/// This ensures we know if this is a first-time user before transitioning. /// This ensures we know if this is a first-time user before transitioning.
Future<void> _loadSettingsFirst() async { Future<void> _loadSettingsFirst() async {
try {
// Load onboarding state first - this is critical for determining // Load onboarding state first - this is critical for determining
// when to transition to HomeScreen // when to transition to HomeScreen
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding(); _hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
@@ -600,22 +476,12 @@ class _AppStartupState extends State<AppStartup> {
}); });
_logger.log('🚀 AppStartup: Settings loaded, hasCompletedOnboarding=$_hasCompletedOnboarding'); _logger.log('🚀 AppStartup: Settings loaded, hasCompletedOnboarding=$_hasCompletedOnboarding');
} catch (e) {
_logger.error('AppStartup settings load error: $e');
if (mounted) {
setState(() {
_settingsLoaded = true;
_hasCompletedOnboarding = true; // Default to true on error
});
}
}
// Now check connection // Now check connection
_checkAndConnect(); _checkAndConnect();
} }
Future<void> _checkAndConnect() async { Future<void> _checkAndConnect() async {
try {
final serverUrl = await SettingsService.getServerUrl(); final serverUrl = await SettingsService.getServerUrl();
if (!mounted) return; if (!mounted) return;
@@ -673,18 +539,14 @@ class _AppStartupState extends State<AppStartup> {
if (mounted) { if (mounted) {
setState(() { setState(() {
_isConnecting = false; _isConnecting = false;
});
}
}
} catch (e) {
_logger.error('AppStartup connection error: $e');
if (mounted) {
setState(() {
_isConnecting = false;
});
}
}
_connectionAttempted = true; _connectionAttempted = true;
});
}
} else {
setState(() {
_connectionAttempted = true;
});
}
} }
@override @override
+32
View File
@@ -10,6 +10,29 @@ enum MediaType {
podcastEpisode, 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 { class ProviderMapping {
final String itemId; final String itemId;
final String providerDomain; final String providerDomain;
@@ -356,6 +379,15 @@ class Playlist extends MediaItem {
if (provider == 'library') return true; if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false; 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 { class Chapter {
+1 -27
View File
@@ -15,15 +15,11 @@ class RecommendationFolder {
required this.items, 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) { factory RecommendationFolder.fromJson(Map<String, dynamic> json) {
final itemsJson = json['items'] as List<dynamic>?; final itemsJson = json['items'] as List<dynamic>?;
final parsedItems = itemsJson final parsedItems = itemsJson
?.whereType<Map<String, dynamic>>() ?.whereType<Map<String, dynamic>>()
.map(_parseMediaItemJson) .map(parseMediaItemJson)
.toList() ?? <MediaItem>[]; .toList() ?? <MediaItem>[];
return RecommendationFolder( 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/recently_played_service.dart';
import '../services/sync_service.dart'; import '../services/sync_service.dart';
import '../services/local_player_service.dart'; import '../services/local_player_service.dart';
import '../services/metadata_service.dart';
import '../services/position_tracker.dart'; import '../services/position_tracker.dart';
import '../services/sendspin_service.dart'; import '../services/sendspin_service.dart';
import '../services/pcm_audio_player.dart'; import '../services/pcm_audio_player.dart';
@@ -27,14 +28,6 @@ import '../services/offline_action_queue.dart';
import '../constants/timings.dart'; import '../constants/timings.dart';
import '../services/database_service.dart'; import '../services/database_service.dart';
import '../services/library_status_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; import '../main.dart' show audioHandler;
/// Main provider that coordinates connection, player, and library state. /// Main provider that coordinates connection, player, and library state.
@@ -92,11 +85,8 @@ class MusicAssistantProvider with ChangeNotifier {
// Local player state // Local player state
bool _isLocalPlayerPowered = true; 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) 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? _connectionStateSubscription;
StreamSubscription? _localPlayerEventSubscription; StreamSubscription? _localPlayerEventSubscription;
StreamSubscription? _playerUpdatedEventSubscription; StreamSubscription? _playerUpdatedEventSubscription;
@@ -118,44 +108,6 @@ class MusicAssistantProvider with ChangeNotifier {
// PCM audio player for raw Sendspin audio streaming // PCM audio player for raw Sendspin audio streaming
PcmAudioPlayer? _pcmAudioPlayer; 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 // Search state persistence
String _lastSearchQuery = ''; String _lastSearchQuery = '';
Map<String, List<MediaItem>> _lastSearchResults = { Map<String, List<MediaItem>> _lastSearchResults = {
@@ -164,6 +116,10 @@ class MusicAssistantProvider with ChangeNotifier {
'tracks': [], '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 // Provider filter: list of allowed provider instance IDs from MA user settings
// Empty list means all providers are allowed // Empty list means all providers are allowed
List<String> _providerFilter = []; List<String> _providerFilter = [];
@@ -209,7 +165,7 @@ class MusicAssistantProvider with ChangeNotifier {
List<String> get providerFilter => _providerFilter; List<String> get providerFilter => _providerFilter;
/// Whether provider filtering is active /// 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 /// Check if a media item should be visible based on provider filter
/// Returns true if: /// 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 has at least one provider mapping in the allowed list
/// - The item's primary provider is in the allowed list /// - The item's primary provider is in the allowed list
bool isItemAllowedByProviderFilter(MediaItem item) { 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 /// Filter a list of media items based on provider filter
List<T> filterByProvider<T extends MediaItem>(List<T> items) { 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 /// Filter search results map based on provider filter
Map<String, List<MediaItem>> filterSearchResults(Map<String, List<MediaItem>> results) { 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) /// Player filter from MA user settings (empty = all players allowed)
List<String> get playerFilter => _playerFilter; List<String> get playerFilter => _playerFilter;
/// Whether player filtering is active /// 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 /// Check if a player should be visible based on player filter
bool isPlayerAllowedByFilter(Player player) { 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 /// Filter a list of players based on player filter
List<Player> filterPlayers(List<Player> players) { 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 /// Get SVG icon for a provider domain
/// Returns null if no icon is available /// Returns null if no icon is available
String? getProviderIconSvg(String domain, {bool isDark = false}) { String? getProviderIconSvg(String domain, {bool isDark = false}) {
return _imageHelper?.getProviderIconSvg(domain, isDark: isDark); return _api?.getProviderIconSvg(domain, isDark: isDark);
} }
/// Get provider manifest by domain /// Get provider manifest by domain
ProviderManifest? getProviderManifest(String domain) { ProviderManifest? getProviderManifest(String domain) {
return _imageHelper?.getProviderManifest(domain); return _api?.getProviderManifest(domain);
} }
/// List of enabled provider instance IDs (empty = all enabled) /// List of enabled provider instance IDs (empty = all enabled)
@@ -516,12 +497,67 @@ class MusicAssistantProvider with ChangeNotifier {
/// Raw unfiltered players list (for internal use) /// Raw unfiltered players list (for internal use)
List<Player> get availablePlayersUnfiltered => _availablePlayers; List<Player> get availablePlayersUnfiltered => _availablePlayers;
/// Check if a player should show the "manually synced" indicator (yellow border). /// 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
/// Returns true for BOTH the leader AND children of a manually created sync group. /// Excludes pre-configured MA speaker groups (provider = 'player_group')
/// Excludes pre-configured MA speaker groups (provider = 'player_group').
bool isPlayerManuallySynced(String playerId) { 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; 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) /// Whether we have cached players available (for instant UI display on app resume)
bool get hasCachedPlayers => _cacheService.hasCachedPlayers; 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 /// Currently playing audiobook context (with chapters) - set when playing an audiobook
Audiobook? get currentAudiobook => _currentAudiobook; Audiobook? get currentAudiobook => _currentAudiobook;
@@ -738,7 +771,9 @@ class MusicAssistantProvider with ChangeNotifier {
/// Get artwork URL for a player from cache /// Get artwork URL for a player from cache
String? getCachedArtworkUrl(String playerId, {int size = 512}) { 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) /// Clear cached track for a player (e.g., after queue transfer)
@@ -899,8 +934,8 @@ class MusicAssistantProvider with ChangeNotifier {
Future<void> _loadPodcastCoverCache() async { Future<void> _loadPodcastCoverCache() async {
try { try {
final cache = await SettingsService.getPodcastCoverCache(); final cache = await SettingsService.getPodcastCoverCache();
if (cache.isNotEmpty && _imageHelper != null) { if (cache.isNotEmpty) {
await _imageHelper!.loadPodcastCoverCache(cache); _podcastCoverCache.addAll(cache);
_logger.log('📦 Loaded ${cache.length} podcast covers from cache (instant high-res)'); _logger.log('📦 Loaded ${cache.length} podcast covers from cache (instant high-res)');
} }
} catch (e) { } catch (e) {
@@ -1006,12 +1041,6 @@ class MusicAssistantProvider with ChangeNotifier {
_api = MusicAssistantAPI(serverUrl, _authManager); _api = MusicAssistantAPI(serverUrl, _authManager);
// Initialize image helper service with API reference
_imageHelper = ImageHelperService(
api: _api,
getCachedTrackForPlayer: (playerId) => _cacheService.getCachedTrackForPlayer(playerId),
);
_connectionStateSubscription?.cancel(); _connectionStateSubscription?.cancel();
_connectionStateSubscription = _api!.connectionState.listen( _connectionStateSubscription = _api!.connectionState.listen(
(state) async { (state) async {
@@ -1307,19 +1336,6 @@ class MusicAssistantProvider with ChangeNotifier {
loadLibrary(); 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 // Process any queued offline actions now that we're connected
await _processOfflineQueue(); await _processOfflineQueue();
@@ -2359,7 +2375,7 @@ class MusicAssistantProvider with ChangeNotifier {
void _handleSendspinVolume(int volumeLevel) async { void _handleSendspinVolume(int volumeLevel) async {
_logger.log('🔊 Sendspin: Set volume to $volumeLevel'); _logger.log('🔊 Sendspin: Set volume to $volumeLevel');
_localPlayerVolume = volumeLevel; _localPlayerVolume = volumeLevel;
await FlutterVolumeController.setVolume(volumeLevel / VolumeConstants.range.toDouble()); await FlutterVolumeController.setVolume(volumeLevel / 100.0);
_sendspinService?.reportState(volume: volumeLevel); _sendspinService?.reportState(volume: volumeLevel);
} }
@@ -2617,7 +2633,7 @@ class MusicAssistantProvider with ChangeNotifier {
final volume = event['volume_level'] as int? ?? event['volume'] as int?; final volume = event['volume_level'] as int? ?? event['volume'] as int?;
if (volume != null) { if (volume != null) {
_localPlayerVolume = volume; _localPlayerVolume = volume;
await FlutterVolumeController.setVolume(volume / VolumeConstants.range.toDouble()); await FlutterVolumeController.setVolume(volume / 100.0);
} }
break; break;
@@ -3087,9 +3103,6 @@ class MusicAssistantProvider with ChangeNotifier {
// ============================================================================ // ============================================================================
Future<List<Album>> getRecentAlbumsWithCache({bool forceRefresh = false}) async { 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 // Always fetch from API to ensure we have full album data with images
// Local/cached data is used by getCachedRecentAlbums() for instant display // Local/cached data is used by getCachedRecentAlbums() for instant display
if (_api == null) { if (_api == null) {
@@ -3121,9 +3134,6 @@ class MusicAssistantProvider with ChangeNotifier {
} }
Future<List<Artist>> getDiscoverArtistsWithCache({bool forceRefresh = false}) async { 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)) { if (_cacheService.isDiscoverArtistsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discover artists'); _logger.log('📦 Using cached discover artists');
return _cacheService.getCachedDiscoverArtists()!; return _cacheService.getCachedDiscoverArtists()!;
@@ -3146,9 +3156,6 @@ class MusicAssistantProvider with ChangeNotifier {
} }
Future<List<Album>> getDiscoverAlbumsWithCache({bool forceRefresh = false}) async { 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)) { if (_cacheService.isDiscoverAlbumsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discover albums'); _logger.log('📦 Using cached discover albums');
return _cacheService.getCachedDiscoverAlbums()!; return _cacheService.getCachedDiscoverAlbums()!;
@@ -3170,79 +3177,30 @@ class MusicAssistantProvider with ChangeNotifier {
} }
} }
Future<List<RecommendationFolder>> getDiscoveryFoldersWithCache({bool forceRefresh = false}) async { Future<List<RecommendationFolder>> getExternalMixesWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache if (_cacheService.isExternalMixesCacheValid(forceRefresh: forceRefresh)) {
await _cacheService.ensureHomeRowsLoaded(); _logger.log('📦 Using cached external mixes');
return _cacheService.getCachedExternalMixes()!;
if (_cacheService.isDiscoveryFoldersCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discovery folders');
return _cacheService.getCachedDiscoveryFolders()!;
} }
if (_api == null) return _cacheService.getCachedDiscoveryFolders() ?? []; if (_api == null) return _cacheService.getCachedExternalMixes() ?? [];
try { try {
_logger.log('🔄 Fetching discovery folders...'); _logger.log('🔄 Fetching external mixes...');
final recommendations = await _api!.getRecommendations(); final recommendations = await _api!.getRecommendations();
final filteredFolders = _extractDiscoveryFolders(recommendations); final mixes = _extractExternalMixes(recommendations);
_cacheService.setCachedDiscoveryFolders(filteredFolders); _cacheService.setCachedExternalMixes(mixes);
return filteredFolders; return mixes;
} catch (e) { } catch (e) {
_logger.log('❌ Failed to fetch discovery folders: $e'); _logger.log('❌ Failed to fetch external mixes: $e');
return _cacheService.getCachedDiscoveryFolders() ?? []; return _cacheService.getCachedExternalMixes() ?? [];
} }
} }
List<RecommendationFolder>? getCachedDiscoveryFolders() => _cacheService.getCachedDiscoveryFolders(); List<RecommendationFolder> _extractExternalMixes(List<RecommendationFolder> folders) {
/// 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) {
final result = <RecommendationFolder>[]; final result = <RecommendationFolder>[];
for (final folder in folders) { 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 providerFiltered = filterByProvider(folder.items);
final filteredItems = providerFiltered final filteredItems = providerFiltered
.where((item) => item.mediaType == MediaType.playlist || item.mediaType == MediaType.radio) .where((item) => item.mediaType == MediaType.playlist || item.mediaType == MediaType.radio)
@@ -3256,7 +3214,7 @@ class MusicAssistantProvider with ChangeNotifier {
provider: folder.provider, provider: folder.provider,
name: folder.name, name: folder.name,
uri: folder.uri, uri: folder.uri,
items: _deduplicateItems(filteredItems), items: _deduplicateMixes(filteredItems),
)); ));
} }
@@ -3280,11 +3238,11 @@ class MusicAssistantProvider with ChangeNotifier {
return false; return false;
} }
List<MediaItem> _deduplicateItems(List<MediaItem> items) { List<MediaItem> _deduplicateMixes(List<MediaItem> mixes) {
final Map<String, MediaItem> deduped = {}; final Map<String, MediaItem> deduped = {};
for (final item in items) { for (final mix in mixes) {
final key = '${item.provider}:${item.itemId}'; final key = '${mix.provider}:${mix.itemId}';
deduped.putIfAbsent(key, () => item); deduped.putIfAbsent(key, () => mix);
} }
return deduped.values.toList(); return deduped.values.toList();
} }
@@ -3302,6 +3260,9 @@ class MusicAssistantProvider with ChangeNotifier {
/// Get cached discover albums synchronously (for instant display) /// Get cached discover albums synchronously (for instant display)
List<Album>? getCachedDiscoverAlbums() => _cacheService.getCachedDiscoverAlbums(); 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) /// Force a full library sync (for pull-to-refresh)
Future<void> forceLibrarySync() async { Future<void> forceLibrarySync() async {
if (_api == null) { if (_api == null) {
@@ -3405,9 +3366,6 @@ class MusicAssistantProvider with ChangeNotifier {
/// Get audiobooks that have progress (continue listening) with caching /// Get audiobooks that have progress (continue listening) with caching
Future<List<Audiobook>> getInProgressAudiobooksWithCache({bool forceRefresh = false}) async { Future<List<Audiobook>> getInProgressAudiobooksWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
// Check cache first // Check cache first
if (!forceRefresh && _cacheService.isInProgressAudiobooksCacheValid()) { if (!forceRefresh && _cacheService.isInProgressAudiobooksCacheValid()) {
_logger.log('📦 Using cached in-progress audiobooks'); _logger.log('📦 Using cached in-progress audiobooks');
@@ -3447,9 +3405,6 @@ class MusicAssistantProvider with ChangeNotifier {
/// Get random audiobooks for discovery with caching /// Get random audiobooks for discovery with caching
Future<List<Audiobook>> getDiscoverAudiobooksWithCache({bool forceRefresh = false}) async { Future<List<Audiobook>> getDiscoverAudiobooksWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
// Check cache first // Check cache first
if (!forceRefresh && _cacheService.isDiscoverAudiobooksCacheValid()) { if (!forceRefresh && _cacheService.isDiscoverAudiobooksCacheValid()) {
_logger.log('📦 Using cached discover audiobooks'); _logger.log('📦 Using cached discover audiobooks');
@@ -3486,9 +3441,6 @@ class MusicAssistantProvider with ChangeNotifier {
/// Get random series for discovery with caching /// Get random series for discovery with caching
Future<List<AudiobookSeries>> getDiscoverSeriesWithCache({bool forceRefresh = false}) async { Future<List<AudiobookSeries>> getDiscoverSeriesWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
// Check cache first // Check cache first
if (!forceRefresh && _cacheService.isDiscoverSeriesCacheValid()) { if (!forceRefresh && _cacheService.isDiscoverSeriesCacheValid()) {
_logger.log('📦 Using cached discover series'); _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 { 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() { void clearAllDetailCaches() {
_searchService.clearAllDetailCaches(); _cacheService.clearAllDetailCaches();
} }
/// Get cached album tracks (for instant display before background refresh) /// 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 /// Sort players list based on smart sort setting
/// Can be called synchronously with pre-fetched settings for cached players /// Can be called synchronously with pre-fetched settings for cached players
void _sortPlayersSync(List<Player> players, bool smartSort, String? builtinPlayerId) { void _sortPlayersSync(List<Player> players, bool smartSort, String? builtinPlayerId) {
PlayerSortUtility.sortPlayers( if (smartSort) {
players, // Smart sort: local player first, then playing, then on, then off
smartSort: smartSort, players.sort((a, b) {
builtinPlayerId: builtinPlayerId, // 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 { 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) // Update notification for builtin player using local mode method (keeps pause working)
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) { if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
final track = _currentTrack!; final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: ImageSizes.highRes); final artworkUrl = _api?.getImageUrl(track, size: 512);
final artistWithPlayer = track.artistsString.isNotEmpty final artistWithPlayer = track.artistsString.isNotEmpty
? '${track.artistsString} • ${player.name}' ? '${track.artistsString} • ${player.name}'
: 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) // and the player is playing (don't wait for polling to kick in)
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) { if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
final track = _currentTrack!; 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" // Include player name in artist line: "Artist • Player Name"
final artistWithPlayer = track.artistsString.isNotEmpty final artistWithPlayer = track.artistsString.isNotEmpty
? '${track.artistsString} • ${player.name}' ? '${track.artistsString} • ${player.name}'
@@ -4259,7 +4265,7 @@ class MusicAssistantProvider with ChangeNotifier {
return; return;
} }
final artworkUrl = _api?.getImageUrl(track, size: ImageSizes.highRes); final artworkUrl = _api?.getImageUrl(track, size: 512);
final artistWithPlayer = track.artistsString.isNotEmpty final artistWithPlayer = track.artistsString.isNotEmpty
? '${track.artistsString} • ${_selectedPlayer!.name}' ? '${track.artistsString} • ${_selectedPlayer!.name}'
: _selectedPlayer!.name; : _selectedPlayer!.name;
@@ -4365,7 +4371,7 @@ class MusicAssistantProvider with ChangeNotifier {
} }
// Also precache the image so it's ready for swipe preview // 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) { if (imageUrl != null) {
_precacheImage(imageUrl); _precacheImage(imageUrl);
} }
@@ -4714,7 +4720,7 @@ class MusicAssistantProvider with ChangeNotifier {
// Update notification for ALL players // Update notification for ALL players
final track = _currentTrack!; final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: ImageSizes.highRes); final artworkUrl = _api?.getImageUrl(track, size: 512);
final builtinPlayerId = await SettingsService.getBuiltinPlayerId(); final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
final isBuiltinPlayer = builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId; final isBuiltinPlayer = builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId;
@@ -4817,7 +4823,7 @@ class MusicAssistantProvider with ChangeNotifier {
title: track.name, title: track.name,
artist: track.artistsString, artist: track.artistsString,
album: track.album?.name, 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, duration: track.duration,
); );
audioHandler.updateLocalModeNotification( audioHandler.updateLocalModeNotification(
@@ -5016,18 +5022,18 @@ class MusicAssistantProvider with ChangeNotifier {
/// Load high-resolution podcast covers from iTunes in background /// Load high-resolution podcast covers from iTunes in background
/// iTunes provides 800x800 artwork for most podcasts (reduced from 1400 for efficiency) /// iTunes provides 800x800 artwork for most podcasts (reduced from 1400 for efficiency)
Future<void> _loadPodcastCoversInBackground() async { Future<void> _loadPodcastCoversInBackground() async {
if (_api == null || _imageHelper == null) return; if (_api == null) return;
for (final podcast in _podcasts) { for (final podcast in _podcasts) {
try { try {
// Skip if already cached (either in memory or loaded from persistence) // 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 // Try iTunes for high-res artwork
final itunesArtwork = await _api!.getITunesPodcastArtwork(podcast.name); final itunesArtwork = await _api!.getITunesPodcastArtwork(podcast.name);
if (itunesArtwork != null) { if (itunesArtwork != null) {
_imageHelper!.updatePodcastCoverCache(podcast.itemId, itunesArtwork); _podcastCoverCache[podcast.itemId] = itunesArtwork;
_logger.log('🎙️ Cached iTunes artwork for ${podcast.name}'); _logger.log('🎙️ Cached iTunes artwork for ${podcast.name}');
// Persist to storage for instant display on next launch // Persist to storage for instant display on next launch
@@ -5161,20 +5167,33 @@ class MusicAssistantProvider with ChangeNotifier {
} }
String? getImageUrl(MediaItem item, {int size = 256}) { 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 /// Get best available podcast cover URL
/// Returns cached iTunes URL (800x800) if available, otherwise falls back to MA imageproxy /// 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 /// The cache is persisted to storage and loaded on app start for instant high-res display
String? getPodcastImageUrl(MediaItem podcast, {int size = 256}) { 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) /// Get artist image URL with fallback to external sources (Deezer, Fanart.tv)
/// Returns a Future since fallback requires async API calls /// Returns a Future since fallback requires async API calls
Future<String?> getArtistImageUrlWithFallback(Artist artist, {int size = 256}) async { 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 { 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) /// Get cached queue for instant display (before API refresh)
Future<PlayerQueue?> getCachedQueue(String playerId) async { 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 /// Persist queue to database for app restart persistence
void _persistQueueToDatabase(String playerId, PlayerQueue queue) { 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 { 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 /// Attempt to restore the queue from cached data and start playback
Future<bool> _restoreQueueFromCache(String playerId) async { 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 { Future<void> nextTrack(String playerId) async {
@@ -5731,7 +5872,7 @@ class MusicAssistantProvider with ChangeNotifier {
final builtinPlayerId = await SettingsService.getBuiltinPlayerId(); final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && playerId == builtinPlayerId) { if (builtinPlayerId != null && playerId == builtinPlayerId) {
_localPlayerVolume = volumeLevel; _localPlayerVolume = volumeLevel;
await FlutterVolumeController.setVolume(volumeLevel / VolumeConstants.range.toDouble()); await FlutterVolumeController.setVolume(volumeLevel / 100.0);
} }
await _api?.setVolume(playerId, volumeLevel); await _api?.setVolume(playerId, volumeLevel);
} catch (e) { } catch (e) {
@@ -5894,7 +6035,21 @@ class MusicAssistantProvider with ChangeNotifier {
} }
Future<void> cycleRepeatMode(String queueId, String? currentMode) async { 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); await setRepeatMode(queueId, nextMode);
} }
@@ -5924,5 +6079,19 @@ class MusicAssistantProvider with ChangeNotifier {
// ============================================================================ // ============================================================================
/// Check if a hostname is a local/private network address /// 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; DateTime? _sleepTimerEndTime;
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
Timer? _sleepTimerDisplayTimer; Timer? _sleepTimerDisplayTimer;
bool _disposed = false;
/// Callback to pause playback when timer expires /// Callback to pause playback when timer expires
OnSleepTimerExpired? onExpired; OnSleepTimerExpired? onExpired;
@@ -75,9 +74,7 @@ class SleepTimerProvider with ChangeNotifier {
// Start display update timer (every second for countdown) // Start display update timer (every second for countdown)
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) { _sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!_disposed) {
notifyListeners(); // Update UI with remaining time notifyListeners(); // Update UI with remaining time
}
}); });
notifyListeners(); notifyListeners();
@@ -126,7 +123,6 @@ class SleepTimerProvider with ChangeNotifier {
@override @override
void dispose() { void dispose() {
_disposed = true;
_cancelInternal(); _cancelInternal();
super.dispose(); 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( SliverToBoxAdapter(
child: Padding( 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -1298,13 +1298,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
ListTile( ListTile(
leading: Icon(Icons.playlist_add, color: colorScheme.onSurface), leading: Icon(Icons.playlist_add, color: colorScheme.onSurface),
title: Text(S.of(context)!.addToQueue, style: TextStyle(color: colorScheme.onSurface)), title: Text(S.of(context)!.addToQueue, style: TextStyle(color: colorScheme.onSurface)),
onTap: () async { onTap: () {
Navigator.pop(context); Navigator.pop(context);
// Add to queue on selected player // Add to queue on selected player
final player = maProvider.selectedPlayer; final player = maProvider.selectedPlayer;
if (player != null) { if (player != null) {
try { maProvider.addTracksToQueue(player.playerId, _tracks).then((_) {
await maProvider.addTracksToQueue(player.playerId, _tracks);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -1313,11 +1312,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
), ),
); );
} }
} catch (e) { });
if (mounted) {
_showError(S.of(context)!.failedToAddToQueue(e.toString()));
}
}
} else { } else {
_showError(S.of(context)!.noPlayerSelected); _showError(S.of(context)!.noPlayerSelected);
} }
+5 -5
View File
@@ -882,9 +882,9 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
backgroundColor: colorScheme.background, backgroundColor: colorScheme.background,
body: LayoutBuilder( body: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// Responsive cover size: 50% of screen width, clamped between 140-200 (smaller for circular artist image) // Responsive cover size: 70% of screen width, clamped between 160-280 (smaller for circular artist image)
final coverSize = (constraints.maxWidth * 0.5).clamp(140.0, 200.0); final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
final expandedHeight = coverSize + 80; final expandedHeight = coverSize + 100;
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
@@ -942,7 +942,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0), padding: const EdgeInsets.all(24.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -959,7 +959,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
), ),
), ),
), ),
Spacing.vGap8, Spacing.vGap16,
if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[ if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[
InkWell( InkWell(
onTap: () { onTap: () {
+47 -51
View File
@@ -14,7 +14,6 @@ import '../services/debug_logger.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
import 'audiobook_detail_screen.dart'; import 'audiobook_detail_screen.dart';
import '../widgets/provider_icon.dart'; import '../widgets/provider_icon.dart';
@@ -298,9 +297,9 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
body: LayoutBuilder( body: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// Responsive cover size: 50% of screen width, clamped between 140-200 (matches artist detail) // Responsive cover size: 70% of screen width, clamped between 160-280 (same as artist detail)
final coverSize = (constraints.maxWidth * 0.5).clamp(140.0, 200.0); final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
final expandedHeight = coverSize + 60; final expandedHeight = coverSize + 100;
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
@@ -316,6 +315,49 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
}, },
color: colorScheme.onSurface, 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( flexibleSpace: FlexibleSpaceBar(
background: Column( background: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -368,12 +410,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0), padding: const EdgeInsets.all(24.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Author info on the left
Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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 // Audiobooks Section Header
SliverToBoxAdapter( SliverToBoxAdapter(
+1 -1
View File
@@ -652,7 +652,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0), padding: const EdgeInsets.all(24.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
+1
View File
@@ -364,6 +364,7 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
padding: EdgeInsets.only(bottom: BottomSpacing.navBarOnly), padding: EdgeInsets.only(bottom: BottomSpacing.navBarOnly),
child: FloatingActionButton( child: FloatingActionButton(
onPressed: () { onPressed: () {
setState(() {});
if (_scrollController.hasClients) { if (_scrollController.hasClients) {
_scrollController.animateTo( _scrollController.animateTo(
_scrollController.position.maxScrollExtent, _scrollController.position.maxScrollExtent,
+30 -305
View File
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../models/recommendation_folder.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
@@ -17,7 +16,7 @@ import '../widgets/series_row.dart';
import '../widgets/playlist_row.dart'; import '../widgets/playlist_row.dart';
import '../widgets/radio_station_row.dart'; import '../widgets/radio_station_row.dart';
import '../widgets/podcast_row.dart'; import '../widgets/podcast_row.dart';
import '../widgets/discovery_row.dart'; import '../widgets/mixes_row.dart';
import '../widgets/common/disconnected_state.dart'; import '../widgets/common/disconnected_state.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'search_screen.dart'; import 'search_screen.dart';
@@ -36,6 +35,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
bool _showRecentAlbums = true; bool _showRecentAlbums = true;
bool _showDiscoverArtists = true; bool _showDiscoverArtists = true;
bool _showDiscoverAlbums = true; bool _showDiscoverAlbums = true;
bool _showExternalMixes = false;
// Favorites rows (default off) // Favorites rows (default off)
bool _showFavoriteAlbums = false; bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false; bool _showFavoriteArtists = false;
@@ -49,11 +49,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
bool _showDiscoverSeries = false; bool _showDiscoverSeries = false;
// Row order (loaded from settings) // Row order (loaded from settings)
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder); 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 @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
@@ -63,7 +58,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
SyncStatus? _lastSyncStatus; SyncStatus? _lastSyncStatus;
int _lastArtistCount = 0; int _lastArtistCount = 0;
MusicAssistantProvider? _providerListeningTo; MusicAssistantProvider? _providerListeningTo;
int _lastHomeRefreshCounter = 0; // Track home refresh counter to force row refreshes
@override @override
void initState() { void initState() {
@@ -74,18 +68,12 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
SyncService.instance.addListener(_onSyncChanged); SyncService.instance.addListener(_onSyncChanged);
_lastSyncStatus = SyncService.instance.status; _lastSyncStatus = SyncService.instance.status;
_checkInitialFavoriteState(); _checkInitialFavoriteState();
// Always listen to provider for home refresh counter changes
final provider = context.read<MusicAssistantProvider>();
_lastHomeRefreshCounter = provider.homeRefreshCounter;
_providerListeningTo = provider;
provider.addListener(_onProviderChanged);
} }
@override @override
void dispose() { void dispose() {
SyncService.instance.removeListener(_onSyncChanged); SyncService.instance.removeListener(_onSyncChanged);
// Clean up provider listener // Clean up provider listener if attached
_providerListeningTo?.removeListener(_onProviderChanged); _providerListeningTo?.removeListener(_onProviderChanged);
_providerListeningTo = null; _providerListeningTo = null;
WidgetsBinding.instance.removeObserver(this); 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() { void _onProviderChanged() {
if (!mounted || _providerListeningTo == null) return; if (!mounted || !_hadEmptyFavoritesOnLoad || _providerListeningTo == null) return;
final currentCount = _providerListeningTo!.artists.length;
final provider = _providerListeningTo!; // If artists went from 0 to non-zero, refresh
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 (_lastArtistCount == 0 && currentCount > 0) { if (_lastArtistCount == 0 && currentCount > 0) {
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows'); _logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
_hadEmptyFavoritesOnLoad = false; _hadEmptyFavoritesOnLoad = false;
_providerListeningTo!.removeListener(_onProviderChanged);
_providerListeningTo = null;
setState(() { setState(() {
_refreshKey = UniqueKey(); _refreshKey = UniqueKey();
}); });
} }
_lastArtistCount = currentCount; _lastArtistCount = currentCount;
} }
}
/// Called when SyncService status changes /// Called when SyncService status changes
void _onSyncChanged() { void _onSyncChanged() {
@@ -174,6 +148,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
final showRecent = await SettingsService.getShowRecentAlbums(); final showRecent = await SettingsService.getShowRecentAlbums();
final showDiscArtists = await SettingsService.getShowDiscoverArtists(); final showDiscArtists = await SettingsService.getShowDiscoverArtists();
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums(); final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
final showExternalMixes = await SettingsService.getShowExternalMixes();
final showFavAlbums = await SettingsService.getShowFavoriteAlbums(); final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists(); final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks(); final showFavTracks = await SettingsService.getShowFavoriteTracks();
@@ -184,12 +159,12 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks(); final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries(); final showDiscSeries = await SettingsService.getShowDiscoverSeries();
final rowOrder = await SettingsService.getHomeRowOrder(); final rowOrder = await SettingsService.getHomeRowOrder();
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) { if (mounted) {
setState(() { setState(() {
_showRecentAlbums = showRecent; _showRecentAlbums = showRecent;
_showDiscoverArtists = showDiscArtists; _showDiscoverArtists = showDiscArtists;
_showDiscoverAlbums = showDiscAlbums; _showDiscoverAlbums = showDiscAlbums;
_showExternalMixes = showExternalMixes;
_showFavoriteAlbums = showFavAlbums; _showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists; _showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks; _showFavoriteTracks = showFavTracks;
@@ -200,91 +175,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_showDiscoverAudiobooks = showDiscAudiobooks; _showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries; _showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder; _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 { Future<void> _onRefresh() async {
@@ -438,38 +330,16 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
for (final rowId in _homeRowOrder) { for (final rowId in _homeRowOrder) {
if (_isRowEnabled(rowId)) count++; 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; return count;
} }
/// Check if a specific row is enabled /// Check if a specific row is enabled
bool _isRowEnabled(String rowId) { 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) { switch (rowId) {
case 'recent-albums': return _showRecentAlbums; case 'recent-albums': return _showRecentAlbums;
case 'discover-artists': return _showDiscoverArtists; case 'discover-artists': return _showDiscoverArtists;
case 'discover-albums': return _showDiscoverAlbums; case 'discover-albums': return _showDiscoverAlbums;
case 'external-mixes': return _showExternalMixes;
case 'continue-listening': return _showContinueListeningAudiobooks; case 'continue-listening': return _showContinueListeningAudiobooks;
case 'discover-audiobooks': return _showDiscoverAudiobooks; case 'discover-audiobooks': return _showDiscoverAudiobooks;
case 'discover-series': return _showDiscoverSeries; 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( Widget _buildConnectedView(
BuildContext context, MusicAssistantProvider provider) { BuildContext context, MusicAssistantProvider provider) {
// Use LayoutBuilder to get available screen height // Use LayoutBuilder to get available screen height
@@ -541,7 +360,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
builder: (context, constraints) { builder: (context, constraints) {
// Each row is always 1/3 of screen height // Each row is always 1/3 of screen height
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll // 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) // Account for margins between rows (2px each)
// Only adjust for margins when ≤3 rows (so they fit exactly without scroll) // 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; const marginSize = 2.0;
final enabledRows = _countEnabledRows(); final enabledRows = _countEnabledRows();
final marginsInView = enabledRows > 1 && enabledRows <= 3 ? (enabledRows - 1) * marginSize : 0.0; 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 // Use Android 12+ stretch overscroll effect
return NotificationListener<ScrollNotification>( return NotificationListener<ScrollNotification>(
@@ -572,14 +395,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.only(bottom: BottomSpacing.withMiniPlayer), padding: EdgeInsets.only(bottom: BottomSpacing.withMiniPlayer),
child: SizedBox(
// Ensure minimum height for pull-to-refresh when empty
height: enabledRows == 0 ? availableHeight : null,
child: Column( child: Column(
key: _refreshKey, key: _refreshKey,
crossAxisAlignment: CrossAxisAlignment.start, 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 /// 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>[]; 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) { for (final rowId in _homeRowOrder) {
final widget = _buildRowWidget(rowId, provider, rowHeight); final widget = _buildRowWidget(rowId, provider, rowHeight);
if (widget != null) { if (widget != null) {
@@ -645,42 +419,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
rows.add(const SizedBox(height: 2.0)); rows.add(const SizedBox(height: 2.0));
} }
rows.add(widget); 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(), getCachedAlbums: () => provider.getCachedDiscoverAlbums(),
rowHeight: rowHeight, 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': case 'continue-listening':
if (!_showContinueListeningAudiobooks) return null; if (!_showContinueListeningAudiobooks) return null;
return AudiobookRow( return AudiobookRow(
@@ -796,29 +544,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
rowHeight: rowHeight, rowHeight: rowHeight,
); );
default: 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; return null;
} }
} }
+26 -29
View File
@@ -1210,21 +1210,14 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// Mark as loading to avoid duplicate requests // Mark as loading to avoid duplicate requests
_authorImages[displayName] = null; _authorImages[displayName] = null;
// Fetch in background using primary author name // Fetch in background using primary author name
_fetchAuthorImage(displayName, lookupName); MetadataService.getAuthorImageUrl(lookupName).then((imageUrl) {
}
}
}
Future<void> _fetchAuthorImage(String displayName, String lookupName) async {
try {
final imageUrl = await MetadataService.getAuthorImageUrl(lookupName);
if (mounted && imageUrl != null) { if (mounted && imageUrl != null) {
setState(() { setState(() {
_authorImages[displayName] = imageUrl; _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, itemCount: _tabCount,
// Use default physics - custom physics may cause issues with Flutter 3.38 // Use default physics - custom physics may cause issues with Flutter 3.38
physics: const PageScrollPhysics(), 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) { Widget _buildPodcastsTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; 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 // Use podcastsUnfiltered to avoid double-filtering with MA's provider filter
final maProvider = context.watch<MusicAssistantProvider>(); final (allPodcasts, isLoading, enabledProviders) = context.select<MusicAssistantProvider, (List<MediaItem>, bool, Set<String>)>(
final allPodcasts = maProvider.podcastsUnfiltered; (p) => (p.podcastsUnfiltered, p.isLoadingPodcasts, p.enabledProviderIds.toSet()),
final isLoading = maProvider.isLoadingPodcasts; );
final enabledProviders = maProvider.enabledProviderIds.toSet(); // Use read() for methods that don't need reactive updates
final maProvider = context.read<MusicAssistantProvider>();
if (isLoading) { if (isLoading) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary)); return Center(child: CircularProgressIndicator(color: colorScheme.primary));
@@ -3723,12 +3719,13 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
Widget _buildRadioStationsTab(BuildContext context, S l10n) { Widget _buildRadioStationsTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; 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 // Use radioStationsUnfiltered to avoid double-filtering with MA's provider filter
final maProvider = context.watch<MusicAssistantProvider>(); final (allRadioStations, isLoading, enabledProviders) = context.select<MusicAssistantProvider, (List<MediaItem>, bool, Set<String>)>(
final allRadioStations = maProvider.radioStationsUnfiltered; (p) => (p.radioStationsUnfiltered, p.isLoadingRadio, p.enabledProviderIds.toSet()),
final isLoading = maProvider.isLoadingRadio; );
final enabledProviders = maProvider.enabledProviderIds.toSet(); // Use read() for methods that don't need reactive updates
final maProvider = context.read<MusicAssistantProvider>();
if (isLoading) { if (isLoading) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary)); return Center(child: CircularProgressIndicator(color: colorScheme.primary));
@@ -3970,11 +3967,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// ============ ARTISTS TAB ============ // ============ ARTISTS TAB ============
Widget _buildArtistsTab(BuildContext context, S l10n) { 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 // SyncService changes are handled by _onSyncServiceChanged listener
final provider = context.watch<MusicAssistantProvider>(); final (isLoading, enabledProviders) = context.select<MusicAssistantProvider, (bool, Set<String>)>(
final isLoading = provider.isLoading; (p) => (p.isLoading, p.enabledProviderIds.toSet()),
final enabledProviders = provider.enabledProviderIds.toSet(); );
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final syncService = SyncService.instance; final syncService = SyncService.instance;
@@ -4207,11 +4204,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// ============ ALBUMS TAB ============ // ============ ALBUMS TAB ============
Widget _buildAlbumsTab(BuildContext context, S l10n) { 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 // SyncService changes are handled by _onSyncServiceChanged listener
final provider = context.watch<MusicAssistantProvider>(); final (isLoading, enabledProviders) = context.select<MusicAssistantProvider, (bool, Set<String>)>(
final isLoading = provider.isLoading; (p) => (p.isLoading, p.enabledProviderIds.toSet()),
final enabledProviders = provider.enabledProviderIds.toSet(); );
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final syncService = SyncService.instance; final syncService = SyncService.instance;
+4 -9
View File
@@ -590,13 +590,12 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
ListTile( ListTile(
leading: Icon(Icons.playlist_add, color: colorScheme.onSurface), leading: Icon(Icons.playlist_add, color: colorScheme.onSurface),
title: Text(S.of(context)!.addToQueue, style: TextStyle(color: colorScheme.onSurface)), title: Text(S.of(context)!.addToQueue, style: TextStyle(color: colorScheme.onSurface)),
onTap: () async { onTap: () {
Navigator.pop(context); Navigator.pop(context);
// Add to queue on selected player // Add to queue on selected player
final player = maProvider.selectedPlayer; final player = maProvider.selectedPlayer;
if (player != null) { if (player != null) {
try { maProvider.addTracksToQueue(player.playerId, _tracks).then((_) {
await maProvider.addTracksToQueue(player.playerId, _tracks);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -605,11 +604,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
), ),
); );
} }
} catch (e) { });
if (mounted) {
_showError(S.of(context)!.failedToAddToQueue(e.toString()));
}
}
} else { } else {
_showError(S.of(context)!.noPlayerSelected); _showError(S.of(context)!.noPlayerSelected);
} }
@@ -887,7 +882,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
+1 -1
View File
@@ -736,7 +736,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
@@ -330,7 +330,7 @@ class _PodcastEpisodeDetailScreenState extends State<PodcastEpisodeDetailScreen>
// Header content with title, podcast name, metadata, and actions // Header content with title, podcast name, metadata, and actions
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0), padding: const EdgeInsets.all(24.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
+19 -35
View File
@@ -108,9 +108,6 @@ class SearchScreenState extends State<SearchScreen> {
}; };
bool _isSearching = false; bool _isSearching = false;
bool _hasSearched = 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 // PERF: Use ValueNotifier to avoid full screen rebuild on filter change
final ValueNotifier<String> _activeFilterNotifier = ValueNotifier('all'); final ValueNotifier<String> _activeFilterNotifier = ValueNotifier('all');
String? _searchError; String? _searchError;
@@ -362,8 +359,6 @@ class SearchScreenState extends State<SearchScreen> {
void _onSearchChanged(String query) { void _onSearchChanged(String query) {
_debounceTimer?.cancel(); _debounceTimer?.cancel();
_debounceTimer = Timer(Timings.searchDebounce, () { _debounceTimer = Timer(Timings.searchDebounce, () {
// Race condition fix: Check mounted before proceeding
if (!mounted) return;
_performSearch(query, keepFocus: true); _performSearch(query, keepFocus: true);
}); });
} }
@@ -380,10 +375,6 @@ class SearchScreenState extends State<SearchScreen> {
return; return;
} }
// Race condition fix: Generate unique ID for this search
final searchId = ++_currentSearchId;
_hasPendingSearch = true;
setState(() { setState(() {
_isSearching = true; _isSearching = true;
_searchError = null; _searchError = null;
@@ -409,11 +400,6 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) { } catch (e) {
_logger.log('Global radio search failed: $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 // 3. Combine and deduplicate by name
@@ -442,11 +428,6 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) { } catch (e) {
_logger.log('Global podcast search failed: $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 // 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) {
if (!mounted) return;
if (searchId != _currentSearchId) {
_logger.log('⏭️ Ignoring stale search results for "$query"');
return;
}
// Combine all results // Combine all results
var combinedResults = { var combinedResults = {
...results, ...results,
@@ -485,7 +460,6 @@ class SearchScreenState extends State<SearchScreen> {
_searchResults = combinedResults; _searchResults = combinedResults;
_isSearching = false; _isSearching = false;
_hasSearched = true; _hasSearched = true;
_hasPendingSearch = false;
_cachedListItems.clear(); // PERF: Clear cache on new results _cachedListItems.clear(); // PERF: Clear cache on new results
_cachedAvailableFilters = null; _cachedAvailableFilters = null;
}); });
@@ -501,15 +475,13 @@ class SearchScreenState extends State<SearchScreen> {
if (keepFocus && _focusNode.hasFocus) { if (keepFocus && _focusNode.hasFocus) {
_focusNode.requestFocus(); _focusNode.requestFocus();
} }
}
} catch (e) { } catch (e) {
_logger.log('Search error: $e'); _logger.log('Search error: $e');
if (!mounted) return; if (mounted) {
// Race condition fix: Only update error state if this is still the latest search
if (searchId == _currentSearchId) {
setState(() { setState(() {
_isSearching = false; _isSearching = false;
_hasSearched = true; _hasSearched = true;
_hasPendingSearch = false;
_searchError = S.of(context)!.searchFailed; _searchError = S.of(context)!.searchFailed;
}); });
if (keepFocus && _focusNode.hasFocus) { if (keepFocus && _focusNode.hasFocus) {
@@ -598,9 +570,9 @@ class SearchScreenState extends State<SearchScreen> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
// Library-only toggle with circle background // Library-only toggle with circle background
Tooltip( Builder(
message: S.of(context)!.libraryOnly, builder: (context) {
child: SizedBox( final control = SizedBox(
width: 44, width: 44,
height: 44, height: 44,
child: Material( 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:flutter/material.dart';
import 'package:provider/provider.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 '../l10n/app_localizations.dart';
import '../models/recommendation_folder.dart';
import '../providers/locale_provider.dart'; import '../providers/locale_provider.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/music_assistant_api.dart'; import '../services/music_assistant_api.dart';
@@ -26,11 +22,11 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
final _lastFmApiKeyController = TextEditingController(); final _lastFmApiKeyController = TextEditingController();
final _audioDbApiKeyController = TextEditingController(); final _audioDbApiKeyController = TextEditingController();
String _appVersion = '';
// Main rows (default on) // Main rows (default on)
bool _showRecentAlbums = true; bool _showRecentAlbums = true;
bool _showDiscoverArtists = true; bool _showDiscoverArtists = true;
bool _showDiscoverAlbums = true; bool _showDiscoverAlbums = true;
bool _showExternalMixes = false;
// Favorites rows (default off) // Favorites rows (default off)
bool _showFavoriteAlbums = false; bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false; bool _showFavoriteArtists = false;
@@ -51,47 +47,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _showHints = true; bool _showHints = true;
// Display settings // Display settings
bool _showProviderIcons = true; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
_loadSettings(); _loadSettings();
_loadAppVersion();
}
Future<void> _loadAppVersion() async {
final info = await PackageInfo.fromPlatform();
if (mounted) {
setState(() {
_appVersion = '${info.version}+${info.buildNumber}';
});
}
} }
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
@@ -109,6 +69,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final showRecent = await SettingsService.getShowRecentAlbums(); final showRecent = await SettingsService.getShowRecentAlbums();
final showDiscArtists = await SettingsService.getShowDiscoverArtists(); final showDiscArtists = await SettingsService.getShowDiscoverArtists();
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums(); final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
final showExternalMixes = await SettingsService.getShowExternalMixes();
final showFavAlbums = await SettingsService.getShowFavoriteAlbums(); final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists(); final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks(); final showFavTracks = await SettingsService.getShowFavoriteTracks();
@@ -134,14 +95,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Load display settings // Load display settings
final showProviderIcons = await SettingsService.getShowProviderIcons(); final showProviderIcons = await SettingsService.getShowProviderIcons();
// Load discovery row preferences
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) { if (mounted) {
setState(() { setState(() {
_showRecentAlbums = showRecent; _showRecentAlbums = showRecent;
_showDiscoverArtists = showDiscArtists; _showDiscoverArtists = showDiscArtists;
_showDiscoverAlbums = showDiscAlbums; _showDiscoverAlbums = showDiscAlbums;
_showExternalMixes = showExternalMixes;
_showFavoriteAlbums = showFavAlbums; _showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists; _showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks; _showFavoriteTracks = showFavTracks;
@@ -156,12 +115,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
_volumePrecisionMode = volumePrecision; _volumePrecisionMode = volumePrecision;
_showHints = showHints; _showHints = showHints;
_showProviderIcons = showProviderIcons; _showProviderIcons = showProviderIcons;
_discoveryRowEnabled = discoveryRowPrefs;
}); });
} }
// Load discovery folders after initial state is set
_loadDiscoveryFolders();
} }
@override @override
@@ -171,84 +126,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
super.dispose(); 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 // Helper to get row display info
Map<String, String> _getRowInfo(String rowId) { Map<String, String> _getRowInfo(String rowId) {
final s = S.of(context)!; 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) { switch (rowId) {
case 'recent-albums': case 'recent-albums':
return {'title': s.recentlyPlayed, 'subtitle': s.showRecentlyPlayedAlbums}; return {'title': s.recentlyPlayed, 'subtitle': s.showRecentlyPlayedAlbums};
@@ -256,8 +136,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
return {'title': s.discoverArtists, 'subtitle': s.showRandomArtists}; return {'title': s.discoverArtists, 'subtitle': s.showRandomArtists};
case 'discover-albums': case 'discover-albums':
return {'title': s.discoverAlbums, 'subtitle': s.showRandomAlbums}; return {'title': s.discoverAlbums, 'subtitle': s.showRandomAlbums};
case 'discovery-mixes': case 'external-mixes':
return {'title': s.discoveryMixes, 'subtitle': s.discoveryMixesDescription}; return {'title': s.playlistMixes, 'subtitle': s.showPlaylistMixes};
case 'continue-listening': case 'continue-listening':
return {'title': s.continueListening, 'subtitle': s.showAudiobooksInProgress}; return {'title': s.continueListening, 'subtitle': s.showAudiobooksInProgress};
case 'discover-audiobooks': case 'discover-audiobooks':
@@ -283,11 +163,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Helper to get row enabled state // Helper to get row enabled state
bool _getRowEnabled(String rowId) { bool _getRowEnabled(String rowId) {
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
return _discoveryRowEnabled[itemId] ?? false;
}
switch (rowId) { switch (rowId) {
case 'recent-albums': case 'recent-albums':
return _showRecentAlbums; return _showRecentAlbums;
@@ -295,6 +170,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
return _showDiscoverArtists; return _showDiscoverArtists;
case 'discover-albums': case 'discover-albums':
return _showDiscoverAlbums; return _showDiscoverAlbums;
case 'external-mixes':
return _showExternalMixes;
case 'continue-listening': case 'continue-listening':
return _showContinueListeningAudiobooks; return _showContinueListeningAudiobooks;
case 'discover-audiobooks': case 'discover-audiobooks':
@@ -321,14 +198,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Helper to set row enabled state // Helper to set row enabled state
void _setRowEnabled(String rowId, bool value) { void _setRowEnabled(String rowId, bool value) {
setState(() { 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) { switch (rowId) {
case 'recent-albums': case 'recent-albums':
_showRecentAlbums = value; _showRecentAlbums = value;
@@ -342,6 +211,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showDiscoverAlbums = value; _showDiscoverAlbums = value;
SettingsService.setShowDiscoverAlbums(value); SettingsService.setShowDiscoverAlbums(value);
break; break;
case 'external-mixes':
_showExternalMixes = value;
SettingsService.setShowExternalMixes(value);
break;
case 'continue-listening': case 'continue-listening':
_showContinueListeningAudiobooks = value; _showContinueListeningAudiobooks = value;
SettingsService.setShowContinueListeningAudiobooks(value); SettingsService.setShowContinueListeningAudiobooks(value);
@@ -983,7 +856,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Reorderable home rows list (exclude discovery-mixes from list) // Reorderable home rows list
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3), color: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -994,42 +867,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false, buildDefaultDragHandles: false,
itemCount: _filteredRowOrder.length, itemCount: _homeRowOrder.length,
onReorder: (oldIndex, newIndex) { 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(() { setState(() {
// Update _homeRowOrder with the new order if (newIndex > oldIndex) newIndex--;
_homeRowOrder.clear(); final item = _homeRowOrder.removeAt(oldIndex);
_homeRowOrder.insert(newIndex, item);
// Add discovery rows that are enabled
for (final rowId in displayRows) {
if (rowId.startsWith('discovery:')) {
// Only add enabled discovery rows to home order
final itemId = rowId.substring('discovery:'.length);
if (_discoveryRowEnabled[itemId] ?? false) {
_homeRowOrder.add(rowId);
}
} else {
_homeRowOrder.add(rowId);
}
}
}); });
SettingsService.setHomeRowOrder(_homeRowOrder); SettingsService.setHomeRowOrder(_homeRowOrder);
}, },
itemBuilder: (context, index) { itemBuilder: (context, index) {
final rowId = _filteredRowOrder[index]; final rowId = _homeRowOrder[index];
final rowInfo = _getRowInfo(rowId); final rowInfo = _getRowInfo(rowId);
final isEnabled = _getRowEnabled(rowId); final isEnabled = _getRowEnabled(rowId);
return Container( return Container(
key: ValueKey(rowId), key: ValueKey(rowId),
decoration: BoxDecoration( decoration: BoxDecoration(
border: index < _filteredRowOrder.length - 1 border: index < _homeRowOrder.length - 1
? Border(bottom: BorderSide(color: colorScheme.outline.withOpacity(0.2))) ? Border(bottom: BorderSide(color: colorScheme.outline.withOpacity(0.2)))
: null, : null,
), ),
@@ -1160,149 +1015,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32), 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( SizedBox(
width: double.infinity, width: double.infinity,
height: 50, 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 'dart:convert';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../models/player.dart';
import '../models/recommendation_folder.dart'; import '../models/recommendation_folder.dart';
import '../models/player.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/database_service.dart'; import '../services/database_service.dart';
import '../constants/timings.dart'; import '../constants/timings.dart';
@@ -11,7 +11,6 @@ import '../constants/timings.dart';
class CacheService { class CacheService {
final DebugLogger _logger = DebugLogger(); final DebugLogger _logger = DebugLogger();
bool _homeRowsLoaded = false; bool _homeRowsLoaded = false;
Future<void>? _homeRowsLoadFuture;
// Cache size limits to prevent unbounded memory growth // Cache size limits to prevent unbounded memory growth
static const int _maxDetailCacheSize = 50; // Max albums/playlists/artists cached static const int _maxDetailCacheSize = 50; // Max albums/playlists/artists cached
@@ -22,14 +21,14 @@ class CacheService {
List<Album>? _cachedRecentAlbums; List<Album>? _cachedRecentAlbums;
List<Artist>? _cachedDiscoverArtists; List<Artist>? _cachedDiscoverArtists;
List<Album>? _cachedDiscoverAlbums; List<Album>? _cachedDiscoverAlbums;
List<RecommendationFolder>? _cachedDiscoveryFolders; List<RecommendationFolder>? _cachedExternalMixes;
List<Audiobook>? _cachedInProgressAudiobooks; List<Audiobook>? _cachedInProgressAudiobooks;
List<Audiobook>? _cachedDiscoverAudiobooks; List<Audiobook>? _cachedDiscoverAudiobooks;
List<AudiobookSeries>? _cachedDiscoverSeries; List<AudiobookSeries>? _cachedDiscoverSeries;
DateTime? _recentAlbumsLastFetched; DateTime? _recentAlbumsLastFetched;
DateTime? _discoverArtistsLastFetched; DateTime? _discoverArtistsLastFetched;
DateTime? _discoverAlbumsLastFetched; DateTime? _discoverAlbumsLastFetched;
DateTime? _discoveryFoldersLastFetched; DateTime? _externalMixesLastFetched;
DateTime? _inProgressAudiobooksLastFetched; DateTime? _inProgressAudiobooksLastFetched;
DateTime? _discoverAudiobooksLastFetched; DateTime? _discoverAudiobooksLastFetched;
DateTime? _discoverSeriesLastFetched; DateTime? _discoverSeriesLastFetched;
@@ -59,14 +58,6 @@ class CacheService {
// HOME SCREEN ROW CACHING // 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 /// Check if recent albums cache is valid
bool isRecentAlbumsCacheValid({bool forceRefresh = false}) { bool isRecentAlbumsCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false; if (forceRefresh) return false;
@@ -81,11 +72,6 @@ class CacheService {
/// Set cached recent albums /// Set cached recent albums
void setCachedRecentAlbums(List<Album> 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; _cachedRecentAlbums = albums;
_recentAlbumsLastFetched = DateTime.now(); _recentAlbumsLastFetched = DateTime.now();
_logger.log('✅ Cached ${albums.length} recent albums'); _logger.log('✅ Cached ${albums.length} recent albums');
@@ -107,11 +93,6 @@ class CacheService {
/// Set cached discover artists /// Set cached discover artists
void setCachedDiscoverArtists(List<Artist> 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; _cachedDiscoverArtists = artists;
_discoverArtistsLastFetched = DateTime.now(); _discoverArtistsLastFetched = DateTime.now();
_logger.log('✅ Cached ${artists.length} discover artists'); _logger.log('✅ Cached ${artists.length} discover artists');
@@ -133,11 +114,6 @@ class CacheService {
/// Set cached discover albums /// Set cached discover albums
void setCachedDiscoverAlbums(List<Album> 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; _cachedDiscoverAlbums = albums;
_discoverAlbumsLastFetched = DateTime.now(); _discoverAlbumsLastFetched = DateTime.now();
_logger.log('✅ Cached ${albums.length} discover albums'); _logger.log('✅ Cached ${albums.length} discover albums');
@@ -145,30 +121,25 @@ class CacheService {
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList()); _persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
} }
/// Check if discovery folders cache is valid /// Check if external mixes cache is valid
bool isDiscoveryFoldersCacheValid({bool forceRefresh = false}) { bool isExternalMixesCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false; if (forceRefresh) return false;
final now = DateTime.now(); final now = DateTime.now();
return _cachedDiscoveryFolders != null && return _cachedExternalMixes != null &&
_discoveryFoldersLastFetched != null && _externalMixesLastFetched != null &&
now.difference(_discoveryFoldersLastFetched!) < Timings.homeRowCacheDuration; now.difference(_externalMixesLastFetched!) < Timings.homeRowCacheDuration;
} }
/// Get cached discovery folders /// Get cached external mixes
List<RecommendationFolder>? getCachedDiscoveryFolders() => _cachedDiscoveryFolders; List<RecommendationFolder>? getCachedExternalMixes() => _cachedExternalMixes;
/// Set cached discovery folders /// Set cached external mixes
void setCachedDiscoveryFolders(List<RecommendationFolder> folders) { void setCachedExternalMixes(List<RecommendationFolder> mixes) {
// Don't cache empty results (prevents persisting "not authenticated" errors) _cachedExternalMixes = mixes;
if (folders.isEmpty) { _externalMixesLastFetched = DateTime.now();
_logger.log('⚠️ Not caching empty discovery folders result'); _logger.log('✅ Cached ${mixes.length} external mix shelves');
return;
}
_cachedDiscoveryFolders = folders;
_discoveryFoldersLastFetched = DateTime.now();
_logger.log('✅ Cached ${folders.length} discovery folders');
// Persist to database for instant load on next launch // 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 /// Check if in-progress audiobooks cache is valid
@@ -185,11 +156,6 @@ class CacheService {
/// Set cached in-progress audiobooks /// Set cached in-progress audiobooks
void setCachedInProgressAudiobooks(List<Audiobook> 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; _cachedInProgressAudiobooks = audiobooks;
_inProgressAudiobooksLastFetched = DateTime.now(); _inProgressAudiobooksLastFetched = DateTime.now();
_logger.log('✅ Cached ${audiobooks.length} in-progress audiobooks'); _logger.log('✅ Cached ${audiobooks.length} in-progress audiobooks');
@@ -209,11 +175,6 @@ class CacheService {
/// Set cached discover audiobooks /// Set cached discover audiobooks
void setCachedDiscoverAudiobooks(List<Audiobook> 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; _cachedDiscoverAudiobooks = audiobooks;
_discoverAudiobooksLastFetched = DateTime.now(); _discoverAudiobooksLastFetched = DateTime.now();
_logger.log('✅ Cached ${audiobooks.length} discover audiobooks'); _logger.log('✅ Cached ${audiobooks.length} discover audiobooks');
@@ -233,11 +194,6 @@ class CacheService {
/// Set cached discover series /// Set cached discover series
void setCachedDiscoverSeries(List<AudiobookSeries> 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; _cachedDiscoverSeries = series;
_discoverSeriesLastFetched = DateTime.now(); _discoverSeriesLastFetched = DateTime.now();
_logger.log('✅ Cached ${series.length} discover series'); _logger.log('✅ Cached ${series.length} discover series');
@@ -259,7 +215,7 @@ class CacheService {
_recentAlbumsLastFetched = null; _recentAlbumsLastFetched = null;
_discoverArtistsLastFetched = null; _discoverArtistsLastFetched = null;
_discoverAlbumsLastFetched = null; _discoverAlbumsLastFetched = null;
_discoveryFoldersLastFetched = null; _externalMixesLastFetched = null;
_inProgressAudiobooksLastFetched = null; _inProgressAudiobooksLastFetched = null;
_discoverAudiobooksLastFetched = null; _discoverAudiobooksLastFetched = null;
_discoverSeriesLastFetched = null; _discoverSeriesLastFetched = null;
@@ -289,6 +245,8 @@ class CacheService {
_discoverArtistsLastFetched = null; _discoverArtistsLastFetched = null;
_cachedDiscoverAlbums = null; _cachedDiscoverAlbums = null;
_discoverAlbumsLastFetched = null; _discoverAlbumsLastFetched = null;
_cachedExternalMixes = null;
_externalMixesLastFetched = null;
_cachedRecentAlbums = null; _cachedRecentAlbums = null;
_recentAlbumsLastFetched = null; _recentAlbumsLastFetched = null;
_searchCache.clear(); _searchCache.clear();
@@ -517,22 +475,10 @@ class CacheService {
// ============================================================================ // ============================================================================
/// Load home row data from database for instant display on startup /// 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 { Future<void> loadHomeRowsFromDatabase() async {
if (_homeRowsLoadFuture != null) {
// Already loading, return the existing future
return _homeRowsLoadFuture!;
}
if (_homeRowsLoaded) return; 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 { try {
if (!DatabaseService.instance.isInitialized) return; if (!DatabaseService.instance.isInitialized) return;
@@ -581,24 +527,22 @@ class CacheService {
} }
} }
// Load discovery folders // Load external mixes
final foldersData = await DatabaseService.instance.getHomeRowCache('discovery_folders'); final mixesData = await DatabaseService.instance.getHomeRowCache('external_mixes');
if (foldersData != null) { if (mixesData != null) {
try { try {
final items = (jsonDecode(foldersData.itemsJson) as List) final items = (jsonDecode(mixesData.itemsJson) as List)
.map((json) => RecommendationFolder.fromJson(json as Map<String, dynamic>)) .map((json) => RecommendationFolder.fromJson(json as Map<String, dynamic>))
.toList(); .toList();
_cachedDiscoveryFolders = items; _cachedExternalMixes = items;
_discoveryFoldersLastFetched = foldersData.lastUpdated; _externalMixesLastFetched = mixesData.lastUpdated;
_logger.log('📦 Loaded ${items.length} discovery folders from database'); _logger.log('📦 Loaded ${items.length} external mix shelves from database');
} catch (e) { } catch (e) {
_logger.log('⚠️ Failed to parse discovery folders: $e'); _logger.log('⚠️ Failed to parse external mixes: $e');
} }
} }
} catch (e) { } catch (e) {
_logger.log('⚠️ Error loading home rows from database: $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 bool Function(dynamic item) getFavorite,
required String Function(dynamic item) getProvider, required String Function(dynamic item) getProvider,
required String Function(dynamic item) getItemId, required String Function(dynamic item) getItemId,
bool notify = true,
}) { }) {
bool changed = false; bool changed = false;
@@ -131,7 +130,7 @@ class LibraryStatusService extends ChangeNotifier {
} }
} }
if (changed && notify) { if (changed) {
notifyListeners(); notifyListeners();
} }
} }
+24 -24
View File
@@ -215,7 +215,6 @@ class MusicAssistantAPI {
}, },
onDone: () { onDone: () {
_logger.log('WebSocket connection closed'); _logger.log('WebSocket connection closed');
_isAuthenticated = false; // Reset auth state on disconnect
_updateConnectionState(MAConnectionState.disconnected); _updateConnectionState(MAConnectionState.disconnected);
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) { if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
_connectionCompleter!.completeError(Exception('Connection closed')); _connectionCompleter!.completeError(Exception('Connection closed'));
@@ -414,7 +413,8 @@ class MusicAssistantAPI {
args: args, args: args,
); );
final items = response['result'] as List<dynamic>?; final items = response['result'] as List<dynamic>? ??
response['items'] as List<dynamic>?;
if (items == null) { if (items == null) {
return []; 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 ============ // ============ PODCASTS ============
/// Get all podcasts from the library /// 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 /// Get the audiobookshelf provider instance ID
Future<String?> getAudiobookshelfProviderId() async { Future<String?> getAudiobookshelfProviderId() async {
try { 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) { void _updateState(SendspinConnectionState newState) {
if (_state != newState) { if (_state != newState) {
_state = newState; _state = newState;
// Don't try to emit if disposed or controller is closed
if (!_isDisposed && !_stateController.isClosed) {
_stateController.add(newState); _stateController.add(newState);
} }
} }
}
/// Disconnect from server using Sendspin's client/goodbye message /// Disconnect from server using Sendspin's client/goodbye message
Future<void> disconnect() async { Future<void> disconnect() async {
+15 -51
View File
@@ -94,7 +94,7 @@ class SettingsService {
static const String _keyShowRecentAlbums = 'show_recent_albums'; static const String _keyShowRecentAlbums = 'show_recent_albums';
static const String _keyShowDiscoverArtists = 'show_discover_artists'; static const String _keyShowDiscoverArtists = 'show_discover_artists';
static const String _keyShowDiscoverAlbums = 'show_discover_albums'; 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 _keyShowContinueListeningAudiobooks = 'show_continue_listening_audiobooks';
static const String _keyShowDiscoverAudiobooks = 'show_discover_audiobooks'; static const String _keyShowDiscoverAudiobooks = 'show_discover_audiobooks';
static const String _keyShowDiscoverSeries = 'show_discover_series'; 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 _keyShowOnlyArtistsWithAlbums = 'show_only_artists_with_albums'; // Library artists filter
static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs 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 = [ static const List<String> defaultHomeRowOrder = [
'recent-albums', 'recent-albums',
'discover-artists', 'discover-artists',
'discover-albums', '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 // View Mode Settings
@@ -169,9 +179,6 @@ class SettingsService {
// Podcast Cover Cache (iTunes URLs for high-res artwork) // Podcast Cover Cache (iTunes URLs for high-res artwork)
static const String _keyPodcastCoverCache = 'podcast_cover_cache'; 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 { static Future<String?> getServerUrl() async {
final prefs = await _getPrefs(); final prefs = await _getPrefs();
return prefs.getString(_keyServerUrl); return prefs.getString(_keyServerUrl);
@@ -386,9 +393,8 @@ class SettingsService {
static Future<bool> getShowDiscoverAlbums() => _getBool(_keyShowDiscoverAlbums, defaultValue: true); static Future<bool> getShowDiscoverAlbums() => _getBool(_keyShowDiscoverAlbums, defaultValue: true);
static Future<void> setShowDiscoverAlbums(bool show) => _setBool(_keyShowDiscoverAlbums, show); static Future<void> setShowDiscoverAlbums(bool show) => _setBool(_keyShowDiscoverAlbums, show);
// Home Screen Discovery Folders (default off - provider mixes like Tidal) static Future<bool> getShowExternalMixes() => _getBool(_keyShowExternalMixes, defaultValue: false);
static Future<bool> getShowDiscoveryFolders() => _getBool(_keyShowDiscoveryFolders, defaultValue: false); static Future<void> setShowExternalMixes(bool show) => _setBool(_keyShowExternalMixes, show);
static Future<void> setShowDiscoveryFolders(bool show) => _setBool(_keyShowDiscoveryFolders, show);
// Home Screen Audiobook Rows (default off - optional) // Home Screen Audiobook Rows (default off - optional)
static Future<bool> getShowContinueListeningAudiobooks() => _getBool(_keyShowContinueListeningAudiobooks, defaultValue: false); static Future<bool> getShowContinueListeningAudiobooks() => _getBool(_keyShowContinueListeningAudiobooks, defaultValue: false);
@@ -938,7 +944,7 @@ class SettingsService {
/// for fine-grained control (10x more precise) /// for fine-grained control (10x more precise)
static Future<bool> getVolumePrecisionMode() async { static Future<bool> getVolumePrecisionMode() async {
final prefs = await _getPrefs(); final prefs = await _getPrefs();
return prefs.getBool(_keyVolumePrecisionMode) ?? false; return prefs.getBool(_keyVolumePrecisionMode) ?? true;
} }
/// Set whether volume precision mode is enabled /// Set whether volume precision mode is enabled
@@ -975,46 +981,4 @@ class SettingsService {
cache[podcastId] = url; cache[podcastId] = url;
await setPodcastCoverCache(cache); 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, initialImageUrl: imageUrl,
), ),
), ),
); ).then((_) {
// Reset after navigation debounce delay // Reset after navigation debounce delay
Future.delayed(Timings.navigationDebounce, () { Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isNavigating = false; if (mounted) _isNavigating = false;
}); });
});
}, },
onLongPressStart: (details) { onLongPressStart: (details) {
MediaContextMenu.show( MediaContextMenu.show(
+2 -2
View File
@@ -114,7 +114,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
// Card layout: square artwork + text below // Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px // Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0; const textAreaHeight = 52.0;
final artworkSize = contentHeight - textAreaHeight; final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square) final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins final itemExtent = cardWidth + 12; // width + horizontal margins
@@ -158,7 +158,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
// Total row height includes title + content // Total row height includes title + content
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 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 contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary( final result = RepaintBoundary(
+2 -2
View File
@@ -318,12 +318,12 @@ class _ArtistCardState extends State<ArtistCard> with LibraryStatusMixin {
initialImageUrl: imageUrl, initialImageUrl: imageUrl,
), ),
), ),
); ).then((_) {
// Reset after navigation debounce delay // Reset after navigation debounce delay
Future.delayed(Timings.navigationDebounce, () { Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isNavigating = false; if (mounted) _isNavigating = false;
}); });
});
}, },
onLongPressStart: (details) { onLongPressStart: (details) {
MediaContextMenu.show( MediaContextMenu.show(
+2 -2
View File
@@ -114,7 +114,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
// Card layout: circle image + name below // Card layout: circle image + name below
// Text area: 8px gap + ~36px for 2-line name = ~44px // Text area: 8px gap + ~36px for 2-line name = ~44px
const textAreaHeight = 44.0; const textAreaHeight = 52.0;
final imageSize = contentHeight - textAreaHeight; final imageSize = contentHeight - textAreaHeight;
final cardWidth = imageSize; // Card width = image width (circle) final cardWidth = imageSize; // Card width = image width (circle)
final itemExtent = cardWidth + 16; // width + horizontal margins final itemExtent = cardWidth + 16; // width + horizontal margins
@@ -154,7 +154,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
// Total row height includes title + content // Total row height includes title + content
final totalHeight = widget.rowHeight ?? 207.0; // Default: 44 title + 163 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 contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary( 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 artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; final cardWidth = artworkSize;
final itemExtent = cardWidth + 12; final itemExtent = cardWidth + 12;
@@ -162,7 +162,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final totalHeight = widget.rowHeight ?? 237.0; final totalHeight = widget.rowHeight ?? 237.0;
const titleHeight = 44.0; const titleHeight = 52.0;
final contentHeight = totalHeight - titleHeight; final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary( 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); _controller.removeListener(_recordAnimationFrame);
_lyricsScrollController.removeListener(_onLyricsScroll); _lyricsScrollController.removeListener(_onLyricsScroll);
_expansionNotifyTimer?.cancel();
_controller.dispose(); _controller.dispose();
_queuePanelController.dispose(); _queuePanelController.dispose();
_sleepTimerPanelController.dispose(); _sleepTimerPanelController.dispose();
@@ -616,8 +615,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// PERF Phase 4: Track last notified value to avoid unnecessary object creation // PERF Phase 4: Track last notified value to avoid unnecessary object creation
double _lastNotifiedProgress = -1; double _lastNotifiedProgress = -1;
Timer? _expansionNotifyTimer;
double? _pendingProgress;
void _notifyExpansionProgress() { void _notifyExpansionProgress() {
final progress = _controller.value; final progress = _controller.value;
@@ -626,24 +623,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
if ((progress - _lastNotifiedProgress).abs() >= 0.01 || if ((progress - _lastNotifiedProgress).abs() >= 0.01 ||
progress == 0.0 || progress == 1.0) { progress == 0.0 || progress == 1.0) {
_lastNotifiedProgress = progress; _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( playerExpansionNotifier.value = PlayerExpansionState(
_pendingProgress!, progress,
_currentExpandedBgColor, _currentExpandedBgColor,
_currentExpandedPrimaryColor, _currentExpandedPrimaryColor,
); );
} }
_expansionNotifyTimer = null;
_pendingProgress = null;
});
}
} }
void _subscribeToPositionTracker() { void _subscribeToPositionTracker() {
@@ -1656,7 +1641,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_slideController.addListener(animateToTarget); _slideController.addListener(animateToTarget);
_slideController.duration = const Duration(milliseconds: 150); _slideController.duration = const Duration(milliseconds: 150);
_slideController.forward().then((_) async { _slideController.forward().then((_) {
if (!mounted) return; if (!mounted) return;
_slideController.removeListener(animateToTarget); _slideController.removeListener(animateToTarget);
+12 -4
View File
@@ -69,9 +69,7 @@ class HiResBadge extends StatelessWidget {
final baseColor = primaryColor ?? colorScheme.primary; final baseColor = primaryColor ?? colorScheme.primary;
final color = Color.lerp(baseColor, Colors.white, 0.5)!; final color = Color.lerp(baseColor, Colors.white, 0.5)!;
return Tooltip( final content = Container(
message: tooltip ?? 'Hi-Res Audio',
child: Container(
padding: const EdgeInsets.all(2), padding: const EdgeInsets.all(2),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: color, width: 1.5), 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 /// Whether the item is currently a favorite
bool get isFavorite => LibraryStatusService.instance.isFavorite(libraryItemKey); bool get isFavorite => LibraryStatusService.instance.isFavorite(libraryItemKey);
bool _isStatusUpdatePending = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -124,17 +122,10 @@ mixin LibraryStatusMixin<T extends StatefulWidget> on State<T> {
} }
void _onLibraryStatusChanged() { void _onLibraryStatusChanged() {
// Use addPostFrameCallback to ensure setState is called AFTER the build phase // Trigger rebuild to pick up new status values
// This prevents "setState during build" errors
if (_isStatusUpdatePending) return;
_isStatusUpdatePending = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { if (mounted) {
setState(() {}); setState(() {});
} }
_isStatusUpdatePending = false;
});
} }
/// Set library status optimistically and return previous value for rollback /// 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(); super.initState();
// Load hint settings // Load hint settings
_loadHintSettings(); SettingsService.getShowHints().then((value) {
if (mounted) setState(() => _showHints = value);
});
_revealController = AnimationController( _revealController = AnimationController(
vsync: this, vsync: this,
@@ -106,21 +108,15 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
_revealController.forward(); _revealController.forward();
// Auto-refresh player data // Auto-refresh player data
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) async { _refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) {
if (mounted) { if (mounted) {
final provider = context.read<MusicAssistantProvider>(); final provider = context.read<MusicAssistantProvider>();
provider.refreshPlayers(); provider.refreshPlayers();
await provider.preloadAllPlayerTracks(); provider.preloadAllPlayerTracks().then((_) => _preloadColorsForPlayers());
_preloadColorsForPlayers();
} }
}); });
} }
Future<void> _loadHintSettings() async {
final value = await SettingsService.getShowHints();
if (mounted) setState(() => _showHints = value);
}
/// Extract accent colors from album art for each player /// Extract accent colors from album art for each player
Future<void> _preloadColorsForPlayers() async { Future<void> _preloadColorsForPlayers() async {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
@@ -168,7 +164,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
} }
/// Animate dismissal and call callback when done /// Animate dismissal and call callback when done
Future<void> dismiss() async { void dismiss() {
// Trigger single bounce on mini player when cards are ~70% collapsed // Trigger single bounce on mini player when cards are ~70% collapsed
// This creates the effect of cards "landing" on the mini player // This creates the effect of cards "landing" on the mini player
bool bounceFired = false; bool bounceFired = false;
@@ -192,9 +188,10 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
_revealController.duration = const Duration(milliseconds: 150); _revealController.duration = const Duration(milliseconds: 150);
_revealController.addListener(checkBounce); _revealController.addListener(checkBounce);
await _revealController.reverse(); _revealController.reverse().then((_) {
_revealController.removeListener(checkBounce); _revealController.removeListener(checkBounce);
widget.onDismiss(); widget.onDismiss();
});
} }
/// Check if scroll is at top (or not scrollable) /// 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(); _dropdownController.forward();
} }
void _closeTransferDropdown() async { void _closeTransferDropdown() {
if (!_showingTransferDropdown) return; if (!_showingTransferDropdown) return;
await _dropdownController.reverse(); _dropdownController.reverse().then((_) {
if (mounted) setState(() => _showingTransferDropdown = false); if (mounted) setState(() => _showingTransferDropdown = false);
});
} }
void _handleTransferQueue(BuildContext context) { void _handleTransferQueue(BuildContext context) {
+2 -2
View File
@@ -255,12 +255,12 @@ class _PlaylistCardState extends State<PlaylistCard> with LibraryStatusMixin {
initialImageUrl: imageUrl, initialImageUrl: imageUrl,
), ),
), ),
); ).then((_) {
// Reset after navigation debounce delay // Reset after navigation debounce delay
Future.delayed(Timings.navigationDebounce, () { Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isNavigating = false; if (mounted) _isNavigating = false;
}); });
});
}, },
onLongPressStart: (details) { onLongPressStart: (details) {
MediaContextMenu.show( 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) // Card layout: square artwork + text below (same as AlbumRow)
// Text area: 8px gap + ~18px title + ~18px owner = ~44px // Text area: 8px gap + ~18px title + ~18px owner = ~44px
const textAreaHeight = 44.0; const textAreaHeight = 52.0;
final artworkSize = contentHeight - textAreaHeight; final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square) final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins 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) // Total row height includes title + content (same as AlbumRow)
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 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 contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary( final result = RepaintBoundary(
+2 -2
View File
@@ -261,12 +261,12 @@ class _PodcastCardState extends State<PodcastCard> with LibraryStatusMixin {
initialImageUrl: imageUrl, initialImageUrl: imageUrl,
), ),
), ),
); ).then((_) {
// Reset after navigation debounce delay // Reset after navigation debounce delay
Future.delayed(Timings.navigationDebounce, () { Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isNavigating = false; if (mounted) _isNavigating = false;
}); });
});
}, },
onLongPressStart: (details) { onLongPressStart: (details) {
MediaContextMenu.show( 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) // Card layout: square artwork + text below (same as AlbumRow)
// Text area: 8px gap + ~18px title + ~18px author = ~44px // Text area: 8px gap + ~18px title + ~18px author = ~44px
const textAreaHeight = 44.0; const textAreaHeight = 52.0;
final artworkSize = contentHeight - textAreaHeight; final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square) final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins 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) // Total row height includes title + content (same as AlbumRow)
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 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 contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary( 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) // Card layout: circle image + name below (same as ArtistRow)
// Text area: 8px gap + ~36px for 2-line name = ~44px // Text area: 8px gap + ~36px for 2-line name = ~44px
const textAreaHeight = 44.0; const textAreaHeight = 52.0;
final imageSize = contentHeight - textAreaHeight; final imageSize = contentHeight - textAreaHeight;
final cardWidth = imageSize; // Card width = image width (circle) final cardWidth = imageSize; // Card width = image width (circle)
final itemExtent = cardWidth + 16; // width + horizontal margins 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) // Total row height includes title + content (same as ArtistRow)
final totalHeight = widget.rowHeight ?? 207.0; // Default: 44 title + 163 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 contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary( 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 artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; final cardWidth = artworkSize;
final itemExtent = cardWidth + 12; final itemExtent = cardWidth + 12;
@@ -231,7 +231,7 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final totalHeight = widget.rowHeight ?? 237.0; final totalHeight = widget.rowHeight ?? 237.0;
const titleHeight = 44.0; const titleHeight = 52.0;
final contentHeight = totalHeight - titleHeight; final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary( final result = RepaintBoundary(
+2 -2
View File
@@ -109,7 +109,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
// Card layout: square artwork + text below // Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px // Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0; const textAreaHeight = 52.0;
final artworkSize = contentHeight - textAreaHeight; final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square) final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; final itemExtent = cardWidth + 12;
@@ -150,7 +150,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
// Total row height includes title + content // Total row height includes title + content
final totalHeight = widget.rowHeight ?? 224.0; // Default: 44 title + 180 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 contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary( final result = RepaintBoundary(
+1 -33
View File
@@ -1106,30 +1106,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" 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: url_launcher_linux:
dependency: transitive dependency: transitive
description: description:
@@ -1138,14 +1114,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.1" 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: url_launcher_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -1284,4 +1252,4 @@ packages:
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.9.0 <4.0.0" dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.0" flutter: ">=3.24.0"