Compare commits

..
2 Commits
160 changed files with 7823 additions and 26732 deletions
-3
View File
@@ -1,3 +0,0 @@
# These are the funding models supported by Ensemble
github: CollotsSpot
custom: https://ko-fi.com/collotsspot
+17 -4
View File
@@ -31,8 +31,8 @@ jobs:
- name: Setup Flutter - name: Setup Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
flutter-version: '3.38.0' flutter-version: '3.24.0'
channel: 'master' channel: 'stable'
cache: true cache: true
- name: Cache Gradle dependencies - name: Cache Gradle dependencies
@@ -63,7 +63,10 @@ jobs:
- name: Setup signing keystore - name: Setup signing keystore
run: | run: |
# Decode keystore from secret (persistent key for consistent signatures)
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/upload-keystore.jks echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/upload-keystore.jks
# Create key.properties file
cat > android/key.properties << EOF cat > android/key.properties << EOF
storePassword=${{ secrets.KEYSTORE_PASSWORD }} storePassword=${{ secrets.KEYSTORE_PASSWORD }}
keyPassword=${{ secrets.KEY_PASSWORD }} keyPassword=${{ secrets.KEY_PASSWORD }}
@@ -72,7 +75,12 @@ jobs:
EOF EOF
- name: Build APK - name: Build APK
run: flutter build apk --release run: |
# Use init.gradle to force Maven mirrors (workaround for Maven Central 403)
mkdir -p ~/.gradle
cp android/init.gradle ~/.gradle/init.gradle
flutter build apk --release
- name: Upload APK artifact - name: Upload APK artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -83,5 +91,10 @@ jobs:
- name: Upload APK summary - name: Upload APK summary
run: | run: |
echo "## APK Build Successful!" >> $GITHUB_STEP_SUMMARY echo "## APK Build Successful! 🎉" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "📱 **APK Location:** \`build/app/outputs/flutter-apk/app-release.apk\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "📦 **Download:** Check the artifacts section below" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
ls -lh build/app/outputs/flutter-apk/app-release.apk >> $GITHUB_STEP_SUMMARY ls -lh build/app/outputs/flutter-apk/app-release.apk >> $GITHUB_STEP_SUMMARY
-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
+26 -28
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>
@@ -22,12 +17,6 @@ This application was built with AI-assisted development using **Claude Code** an
--- ---
## Also See
**[Ensemble TV](https://github.com/CollotsSpot/ensemble-tv)** - A simple Android TV client for Music Assistant that displays what's currently playing on your TV with remote control support.
---
## Features ## Features
### Local Playback ### Local Playback
@@ -47,8 +36,7 @@ This application was built with AI-assisted development using **Claude Code** an
### Queue Management ### Queue Management
- **View & Manage Queue** - See upcoming tracks in the playback queue - **View & Manage Queue** - See upcoming tracks in the playback queue
- **Drag to Reorder** - Instant drag handles for reordering tracks - **Drag to Reorder** - Instant drag handles for reordering tracks
- **Swipe left to Delete** - Remove tracks with a simple swipe gesture - **Swipe to Delete** - Remove tracks with a simple swipe gesture
- **Swipe right to play next** - Move tracks with a simple swipe gesture
### Home Screen ### Home Screen
- **Customizable Rows** - Toggle and reorder: Recently Played, Discover Artists, Discover Albums - **Customizable Rows** - Toggle and reorder: Recently Played, Discover Artists, Discover Albums
@@ -62,9 +50,7 @@ This application was built with AI-assisted development using **Claude Code** an
- **Podcasts** - Browse podcasts, view episodes with descriptions and publish dates - **Podcasts** - Browse podcasts, view episodes with descriptions and publish dates
- **Audiobooks** - Browse by title, series, or author with progress tracking - **Audiobooks** - Browse by title, series, or author with progress tracking
- **Favorites Filter** - Toggle to show only your favorite items - **Favorites Filter** - Toggle to show only your favorite items
- **Providers Filter** - Toggle to show only specific provider items
- **Letter Scrollbar** - Fast navigation through long lists - **Letter Scrollbar** - Fast navigation through long lists
- **Provider Icons** - Provider icons on library covers
### Search ### Search
- **Universal Search** - Find music, podcasts, radio stations, playlists, and audiobooks - **Universal Search** - Find music, podcasts, radio stations, playlists, and audiobooks
@@ -100,18 +86,16 @@ This application was built with AI-assisted development using **Claude Code** an
## Screenshots ## Screenshots
<div align="center"> <div align="center">
<img src="assets/screenshots/1.png?v=4" alt="Screenshot 1" width="150"> <img src="assets/screenshots/1.png?v=3" alt="Screenshot 1" width="150">
<img src="assets/screenshots/2.png?v=4" alt="Screenshot 2" width="150"> <img src="assets/screenshots/2.png?v=3" alt="Screenshot 2" width="150">
<img src="assets/screenshots/3.png?v=4" alt="Screenshot 3" width="150"> <img src="assets/screenshots/3.png?v=3" alt="Screenshot 3" width="150">
<img src="assets/screenshots/4.png?v=4" alt="Screenshot 4" width="150"> <img src="assets/screenshots/4.png?v=3" alt="Screenshot 4" width="150">
<img src="assets/screenshots/5.png?v=4" alt="Screenshot 5" width="150"> <img src="assets/screenshots/5.png?v=3" alt="Screenshot 5" width="150">
<img src="assets/screenshots/6.png?v=4" alt="Screenshot 6" width="150"> <img src="assets/screenshots/6.png?v=3" alt="Screenshot 6" width="150">
<img src="assets/screenshots/7.png?v=4" alt="Screenshot 7" width="150"> <img src="assets/screenshots/7.png?v=3" alt="Screenshot 7" width="150">
<img src="assets/screenshots/8.png?v=4" alt="Screenshot 8" width="150"> <img src="assets/screenshots/8.png?v=3" alt="Screenshot 8" width="150">
<img src="assets/screenshots/9.png?v=4" alt="Screenshot 9" width="150"> <img src="assets/screenshots/9.png?v=3" alt="Screenshot 9" width="150">
<img src="assets/screenshots/10.png?v=4" alt="Screenshot 10" width="150"> <img src="assets/screenshots/10.png?v=3" alt="Screenshot 10" width="150">
<img src="assets/screenshots/11.png?v=4" alt="Screenshot 11" width="150">
<img src="assets/screenshots/12.png?v=4" alt="Screenshot 12" width="150">
</div> </div>
## Download ## Download
@@ -138,11 +122,25 @@ To find the correct URL:
If you run Music Assistant as a Home Assistant add-on: If you run Music Assistant as a Home Assistant add-on:
- Use the IP address of your Home Assistant server - Use the IP address of your Home Assistant server
- Enter `8095` in the port field
- Do **not** use your Home Assistant URL or ingress URL - Do **not** use your Home Assistant URL or ingress URL
### Remote Access ### Remote Access
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Traefik, Nginx Proxy Manager, Cloudflare Tunnel). For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Nginx Proxy Manager, Cloudflare Tunnel).
## Authentication
Ensemble supports multiple authentication methods:
| Method | Status |
|--------|--------|
| Music Assistant native auth | Tested |
| No authentication | Tested |
| Authelia | Implemented, not recently tested |
| HTTP Basic Auth | Implemented, not recently tested |
**Note:** Development and testing is done against Music Assistant beta with native authentication enabled.
## Requirements ## Requirements
-78
View File
@@ -1,78 +0,0 @@
# Release Notes Draft
## v2.9.2-beta (TBD)
### New Features
#### Context Menus
- **Long-press context menus on all media items**: Albums, artists, tracks, playlists, podcasts, and radio stations now all support long-press to show a context menu with Play, Add to Library, and Favorite options.
#### Podcasts
- **Podcast episode detail screen**: Tap "View Details" from episode context menu to see full scrollable description, publish date, duration, with Play, Play On, and Add to Queue actions.
- **Episode sorting**: Sort podcast episodes by newest first, oldest first, alphabetical, or duration.
#### Audio Quality
- **Hi-res audio badge**: Track listings now show a hi-res badge when high quality audio is available.
### Improvements
#### UI/UX
- **New library icon**: Replaced confusing library icon with clearer book icon (book_2) across bottom nav, search filter, detail screens, and context menus. Filled when in library, outlined when not.
- **Player selector**: Replaced popup dialog with player select overlay for queue actions.
- **Search library filter**: Filled icon indicates active filter state without background highlight.
- **Context menu polish**: Slightly larger buttons (46px), wider menu (185px), consistent text colors that don't pick up adaptive theming.
- **Navigation transitions**: Smoother fade/slide animations between screens.
- **Hero animations**: Podcast episode images animate smoothly to detail screen.
#### Performance
- **Library list optimization**: Standardized list item heights with itemExtent, reduced image cache sizes for smoother scrolling.
- **Palette extraction**: Moved to isolate for better UI responsiveness during color extraction.
- **Provider selects**: Optimized provider state watching to reduce unnecessary rebuilds.
### Bug Fixes
#### Audiobooks
- **Fixed audiobooks not loading after provider re-add**: When removing and re-adding an AudiobookShelf provider, the app would show 0 audiobooks because saved library paths referenced the old provider instance ID. Now automatically clears stale settings and falls back to the library_items API.
- **Fixed duplicate provider filter options**: After re-adding a provider, two entries for the same provider could appear in the library filter menu. Now deduplicates providers by name, keeping the one with more items.
- **Fixed multiple accounts of same service not showing**: When you have multiple accounts of the same service (e.g., two Spotify accounts for different family members), only one would appear in the library menu provider toggles. Now all accounts with distinct names are shown.
- **Fixed audiobook year sorting**: Sort by year now works correctly for audiobooks. Fixed two issues:
- Year data is now properly extracted from ABS's `metadata.release_date` field (handles both "2024" and "2024-01-15" formats)
- Sort handler now properly supports ascending and descending year sorts
#### Library Sorting
- **Fixed letter scrollbar showing irrelevant letters**: When sorting by date or play count, the scrollbar popup now hides instead of showing meaningless letters. Shows contextual info based on sort type (letters for alphabetical, years for year sort, hidden for date/count sorts).
- **Fixed sorting not working for date added, last played, play count**: These sort options now correctly preserve server-side sorting instead of re-sorting alphabetically on the client. Affected albums, artists, and tracks tabs.
- **Fixed playlists sorting**: Modified, last played, and play count sorting now works for playlists by properly requesting sorted data from the server.
- **Fixed ascending/descending not working**: Sort direction now properly applies for all sort types across albums, artists, playlists, and audiobooks.
- **Fixed list not updating when sort order changes**: Added sort order to ListView/GridView keys to force UI rebuilds when changing sort direction.
#### Provider Management
- **Fixed stale provider IDs persisting in settings**: When toggling providers, stale provider instance IDs (from removed providers) are now automatically cleaned up from saved settings.
#### Playback
- **Fixed Spotify external source detection** (issue #27): Properly detects when Spotify is the music provider.
- **Fixed foreground service not stopping** (issue #51): Auto-stops after 30 minutes idle.
- **Fixed Sendspin proxy auth**: Now works correctly for local network connections.
#### UI Fixes
- **Fixed nav bar hidden when no player selected**: Navigation bar now always visible.
- **Fixed Flutter 3.38 slider grey rectangles**: Volume and seek sliders render correctly.
- **Fixed library tab scroll position**: Maintains position when switching tabs.
### Technical Changes
- Flutter 3.38 compatibility
- Simplified authentication using MA built-in auth
- Added `material_symbols_icons` package for book_2 icon
- Added `ScrollbarDisplayMode` enum (`letter`, `year`, `count`, `none`) for contextual scrollbar popups
- Added `updateCachedAlbums()`, `updateCachedArtists()`, `updateCachedPlaylists()` methods to SyncService for proper cache synchronization with sorted data
- Provider filter now validates against available providers when toggling, not just on initial load
- Removed Selector caching from albums and artists tabs to ensure proper rebuilds when source tracking updates
- Options menu provider toggles now use Consumer to watch Provider state directly
- Centralized player selector logic in GlobalPlayerOverlay.showPlayerSelectorForAction()
+4 -9
View File
@@ -29,8 +29,8 @@ if (keystorePropertiesFile.exists()) {
} }
android { android {
namespace "com.collotsspot.ensemble" namespace "com.musicassistant.music_assistant"
compileSdk 36 compileSdk 35
ndkVersion flutter.ndkVersion ndkVersion flutter.ndkVersion
compileOptions { compileOptions {
@@ -58,8 +58,8 @@ android {
} }
defaultConfig { defaultConfig {
applicationId "com.collotsspot.ensemble" applicationId "com.musicassistant.music_assistant"
minSdkVersion flutter.minSdkVersion minSdk 21
targetSdk 35 targetSdk 35
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
@@ -70,11 +70,6 @@ android {
signingConfig keystorePropertiesFile.exists() ? signingConfigs.release : signingConfigs.debug signingConfig keystorePropertiesFile.exists() ? signingConfigs.release : signingConfigs.debug
} }
} }
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
} }
flutter { flutter {
-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,4 +1,4 @@
package com.collotsspot.ensemble package com.musicassistant.music_assistant
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
@@ -10,7 +10,7 @@ import io.flutter.plugin.common.MethodChannel
// while also intercepting volume button events // while also intercepting volume button events
class MainActivity: AudioServiceActivity() { class MainActivity: AudioServiceActivity() {
private val TAG = "EnsembleVolume" private val TAG = "EnsembleVolume"
private val CHANNEL = "com.collotsspot.ensemble/volume_buttons" private val CHANNEL = "com.musicassistant.music_assistant/volume_buttons"
private var methodChannel: MethodChannel? = null private var methodChannel: MethodChannel? = null
private var isListening = false private var isListening = false
+2 -2
View File
@@ -1,5 +1,5 @@
buildscript { buildscript {
ext.kotlin_version = '2.1.0' ext.kotlin_version = '1.9.10'
repositories { repositories {
google() google()
// Use Maven Central mirrors as fallback for 403 errors // Use Maven Central mirrors as fallback for 403 errors
@@ -9,7 +9,7 @@ buildscript {
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.9.1' classpath 'com.android.tools.build:gradle:8.1.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.0-all.zip
+2 -2
View File
@@ -22,8 +22,8 @@ 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.1.0" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false id "org.jetbrains.kotlin.android" version "1.9.10" apply false
} }
include ":app" include ":app"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 649 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 KiB

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 909 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

-336
View File
@@ -1,336 +0,0 @@
# Per-Provider Sync Implementation
## Date: January 17, 2026
## Overview
This document details the implementation of per-provider sync with source tracking, enabling instant client-side filtering that properly differentiates between multiple accounts of the same provider type (e.g., two different Spotify accounts).
## Problem Statement
### The Core Issue
When a user has multiple accounts of the same provider type (e.g., two Spotify accounts: "Chris Laycock" and "Bababagoly"), the existing provider filter couldn't differentiate between them.
**Why?** Music Assistant's `provider_mappings` field on media items shows which providers **CAN PLAY** an item, not which provider **ADDED** it. When both Spotify accounts can play the same artist, both appear in `provider_mappings`, making client-side filtering impossible.
### Previous Approach (Failed)
The initial implementation tried to filter client-side using `provider_mappings`:
```dart
bool isItemAllowedByProviderFilter(MediaItem item) {
final mappings = item.providerMappings;
for (final mapping in mappings) {
if (_providerFilter.contains(mapping.providerInstance)) {
return true;
}
}
return false;
}
```
**Result:** Both Spotify accounts showed the same items because `provider_mappings` contained both account IDs for shared content.
### Race Condition Issue
Additionally, when rapidly toggling providers, a race condition occurred:
1. User toggles Provider A off
2. Sync starts for remaining providers
3. User toggles Provider B off before sync completes
4. First sync completes with wrong filter state
5. UI shows incorrect results
## Solution: Per-Provider Sync with Source Tracking
### Concept
Instead of relying on `provider_mappings`, we:
1. **Sync each provider separately** using the MA API's `provider` parameter
2. **Tag each item** with which provider instance(s) returned it
3. **Store source tracking** in the local database
4. **Filter client-side** using the source tracking data (instant)
5. **Background sync** keeps data fresh without blocking UI
### Why This Works
When we call the MA API with `provider: ["spotify--RKnr3jKx"]`, the server returns **only items from that specific account's library**. By syncing each provider separately and remembering which provider returned each item, we can accurately filter.
## Implementation Details
### 1. Database Schema Update
**File:** `lib/database/database.dart`
Added `sourceProviders` column to `LibraryCache` table:
```dart
class LibraryCache extends Table {
// ... existing columns ...
/// Provider instance IDs that provided this item (JSON array)
/// Used for client-side filtering by source provider
TextColumn get sourceProviders => text().withDefault(const Constant('[]'))();
}
```
**Schema version:** 5 → 6
**Migration:**
```dart
if (from < 6) {
await customStatement(
"ALTER TABLE library_cache ADD COLUMN source_providers TEXT NOT NULL DEFAULT '[]'"
);
}
```
### 2. Database Service Updates
**File:** `lib/services/database_service.dart`
Added `sourceProvider` parameter to `cacheItem()`:
```dart
Future<void> cacheItem<T>({
required String itemType,
required String itemId,
required Map<String, dynamic> data,
String? sourceProvider, // NEW: tracks which provider this item came from
})
```
Added new method to retrieve items with source tracking:
```dart
Future<List<(Map<String, dynamic>, List<String>)>> getCachedItemsWithProviders(String itemType)
```
Returns tuples of `(itemData, sourceProviderIds)`.
### 3. SyncService Rewrite
**File:** `lib/services/sync_service.dart`
#### New Fields
```dart
// Source provider tracking for client-side filtering
Map<String, List<String>> _albumSourceProviders = {};
Map<String, List<String>> _artistSourceProviders = {};
Map<String, List<String>> _audiobookSourceProviders = {};
Map<String, List<String>> _playlistSourceProviders = {};
```
#### Per-Provider Sync Logic
```dart
Future<void> syncFromApi(MusicAssistantAPI api, {
bool force = false,
List<String>? providerInstanceIds,
}) async {
// If specific providers are requested, sync each separately
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
for (final providerId in providerInstanceIds) {
// Fetch from this specific provider
final results = await Future.wait([
api.getAlbums(limit: 1000, providerInstanceIds: [providerId]),
api.getArtists(limit: 1000, providerInstanceIds: [providerId]),
// ... etc
]);
// Track source provider for each item
for (final album in albums) {
albumMap[album.itemId] = album;
_albumSourceProviders.putIfAbsent(album.itemId, () => []);
if (!_albumSourceProviders[album.itemId]!.contains(providerId)) {
_albumSourceProviders[album.itemId]!.add(providerId);
}
}
}
}
}
```
#### Client-Side Filtering Methods
```dart
/// Filter albums by source provider (instant, no network)
List<Album> getAlbumsFilteredByProviders(Set<String> enabledProviderIds) {
if (enabledProviderIds.isEmpty || _albumSourceProviders.isEmpty) {
return _cachedAlbums;
}
return _cachedAlbums.where((album) {
final sources = _albumSourceProviders[album.itemId];
if (sources == null || sources.isEmpty) return true; // No tracking = show
return sources.any((s) => enabledProviderIds.contains(s));
}).toList();
}
// Similar methods for artists, audiobooks, playlists
```
### 4. Library Screen Updates
**File:** `lib/screens/new_library_screen.dart`
#### Hybrid Provider Toggle Handler
```dart
void _handleProviderToggle(String providerId, bool enabled) {
final maProvider = context.read<MusicAssistantProvider>();
// Instant UI update via global provider toggle
maProvider.toggleProviderEnabled(providerId, enabled);
setState(() {}); // Rebuild with client-side filtering
// Background sync after 1.5s debounce
_providerFilterDebounce?.cancel();
_providerFilterDebounce = Timer(const Duration(milliseconds: 1500), () async {
if (mounted) {
await maProvider.forceLibrarySync();
if (mounted) setState(() {});
}
});
}
```
#### Updated Tab Builders
Artists tab now uses source tracking:
```dart
Widget _buildArtistsTab(BuildContext context, S l10n) {
return Selector<MusicAssistantProvider, (List<Artist>, bool, Set<String>)>(
selector: (_, provider) => (
provider.artists,
provider.isLoading,
provider.enabledProviderIds.toSet()
),
builder: (context, data, _) {
final (allArtists, isLoading, enabledProviders) = data;
// Client-side filtering using SyncService source tracking
final syncService = SyncService.instance;
final filteredArtists = syncService.hasSourceTracking && enabledProviders.isNotEmpty
? syncService.getArtistsFilteredByProviders(enabledProviders)
: allArtists;
// ... rest of build
},
);
}
```
Similar updates for albums, playlists, and audiobooks tabs.
### 5. Provider Instance Model
**File:** `lib/models/provider_instance.dart` (new file)
```dart
class ProviderInstance {
final String instanceId; // e.g., "spotify--abc123"
final String domain; // e.g., "spotify"
final String name; // e.g., "Spotify (John's Account)"
final bool available;
/// Known music provider domains
static const Set<String> musicProviderDomains = {
'spotify', 'tidal', 'qobuz', 'deezer', 'ytmusic', 'soundcloud',
'apple_music', 'amazon_music', 'plex', 'subsonic', 'opensubsonic',
'jellyfin', 'emby', 'navidrome', 'audiobookshelf', 'filesystem',
'tunein', 'radiobrowser', 'snapcast', 'fully_kiosk',
};
bool get isMusicProvider => musicProviderDomains.contains(domain);
}
```
## Data Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ User Toggles Provider │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 1. MusicAssistantProvider.toggleProviderEnabled() │
│ - Updates enabledProviderIds │
│ - Triggers setState() for instant UI update │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 2. UI Rebuilds Immediately │
│ - Selector detects enabledProviderIds change │
│ - Calls SyncService.getArtistsFilteredByProviders() │
│ - Filters using source tracking (instant, no network) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 3. Background Sync (after 1.5s debounce) │
│ - forceLibrarySync() called │
│ - Syncs each enabled provider separately │
│ - Updates source tracking in database │
│ - UI refreshes with fresh data │
└─────────────────────────────────────────────────────────────────┘
```
## Files Changed
| File | Changes |
|------|---------|
| `lib/database/database.dart` | Added `sourceProviders` column, schema v6, migration |
| `lib/database/database.g.dart` | Auto-generated from schema changes |
| `lib/services/database_service.dart` | Added `sourceProvider` param, `getCachedItemsWithProviders()` |
| `lib/services/sync_service.dart` | Per-provider sync, source tracking maps, filtering methods |
| `lib/screens/new_library_screen.dart` | Hybrid toggle handler, updated tab builders |
| `lib/models/provider_instance.dart` | New model for provider instances |
## Testing Checklist
- [ ] Fresh install: First sync should populate source tracking
- [ ] Toggle single provider: UI updates instantly
- [ ] Toggle multiple providers rapidly: Debounce prevents race conditions
- [ ] Two Spotify accounts: Toggling one only shows that account's items
- [ ] Mixed providers: Toggling Spotify doesn't affect Audiobookshelf items
- [ ] Legacy cache: Items without source tracking show regardless of filter
- [ ] Pull to refresh: Refreshes source tracking data
## Known Limitations
1. **First sync required:** Source tracking only works after at least one sync with providers enabled
2. **Network cost:** Per-provider sync makes N API calls (one per provider) vs. 1 call for all providers
3. **Items in multiple accounts:** If the same item exists in both Spotify accounts, it will show when either is enabled
## Future Improvements
1. **Incremental sync:** Only sync changed items instead of full library
2. **Provider-specific cache age:** Different sync intervals per provider
3. **Offline source tracking:** Persist source tracking independently of full cache
## Branch
All changes are on the `feature/per-provider-sync` branch.
```bash
git checkout feature/per-provider-sync
```
## Commit
```
feat: Add per-provider sync with source tracking for instant filtering
- Add sourceProviders column to database schema (v6) for tracking which
provider instance each item came from
- Update SyncService to sync each enabled provider separately and tag
items with their source provider
- Add client-side filtering methods that use source tracking to properly
differentiate between multiple accounts of the same provider type
- Update library UI to use instant client-side filtering with background
sync for data freshness
```
-217
View File
@@ -1,217 +0,0 @@
# Provider Filter Implementation Notes
## Overview
This document captures the context and learnings from an attempted implementation of provider filtering in the Ensemble app. The goal was to allow users to filter their library to show only items from a specific music provider (e.g., only one Spotify account when multiple are configured).
## The Problem
Users with multiple music provider accounts (e.g., two Spotify accounts: one personal, one family) want to filter their library view to show only items from one specific account. The Music Assistant (MA) web UI successfully implements this feature.
## What Works vs What Failed
### Works: Different Provider Types
Filtering between different provider types (e.g., Spotify vs Tidal vs Plex) works correctly. Each provider type has distinct `provider_mappings` entries.
### Failed: Multiple Accounts of Same Provider
Filtering between multiple Spotify accounts (e.g., `spotify--abc123` vs `spotify--xyz789`) does NOT work correctly. The same library items appear regardless of which Spotify account is selected.
## Root Cause Analysis
### The provider_mappings Problem
MA's `provider_mappings` field on media items does NOT indicate "which account ADDED this item to the library." Instead, it indicates "which providers CAN PLAY this item."
Example: An artist "Ari Mason" that exists ONLY in User A's Spotify library will show provider_mappings containing BOTH Spotify accounts:
```json
{
"provider_mappings": [
{"provider_instance": "spotify--userA_id", "item_id": "..."},
{"provider_instance": "spotify--userB_id", "item_id": "..."}
]
}
```
This happens because:
1. MA's internal database stores that the artist exists
2. MA knows both Spotify accounts CAN play this artist (Spotify has the content)
3. MA doesn't track WHICH account originally added the item to the library
### MA Web UI Behavior
The MA web UI's provider filter works differently - it appears to use server-side filtering via the `provider` parameter on library_items API calls. When you select a single provider in MA settings, the API returns only items from that provider's library.
## API Investigation
### Server-Side Filtering Parameter
The MA server's `library_items` method accepts a `provider` parameter:
- Parameter name: `provider` (string or list of strings)
- Endpoint: `music/{type}/library_items` (e.g., `music/artists/library_items`)
- Expected behavior: Filter to only return items from the specified provider's library
### What We Tried
1. **Client-side filtering based on provider_mappings**
- Result: Failed - all Spotify accounts appear in mappings regardless of which added the item
2. **Server-side filtering with `provider` parameter**
- Result: Partially implemented but behavior was inconsistent
- The API accepts the parameter but may not filter correctly for same-provider-type accounts
3. **Using `provider_instance_id_or_domain` parameter**
- Result: This parameter is for getting specific items by ID, not for filtering library_items
## Code Locations
### Files Modified (in stash)
The implementation attempt is preserved in git stash:
```bash
git stash list # Look for "provider-filter-and-dropdown-work"
git stash show -p stash@{0} # View the changes
```
Key files that were modified:
1. **lib/providers/music_assistant_provider.dart**
- `_disabledProviders`: List of disabled provider instance IDs (local app setting)
- `selectedProviderForFilter`: Returns single enabled provider for server-side filtering
- `toggleProviderEnabled()`: Toggle provider on/off and trigger re-sync
- `loadDisabledProviders()`: Load from local settings
- `filterByProvider()`: Client-side filtering (enhanced version)
- `isItemAllowedByProviderFilter()`: Check if item should be visible
2. **lib/services/music_assistant_api.dart**
- Added `provider` parameter to: `getArtists`, `getAlbums`, `getTracks`, `getRandomArtists`, `getRandomAlbums`
- Added `MusicProvider` model parsing from `/providers` endpoint
- `musicProviders` getter: List of available music providers
3. **lib/services/sync_service.dart**
- Added `provider` parameter to `syncFromApi()` and `forceSync()`
- Pass provider filter to API calls during sync
4. **lib/services/settings_service.dart**
- `getDisabledMusicProviders()`: Get list of disabled provider IDs
- `setDisabledMusicProviders()`: Save disabled providers
- `toggleProviderEnabled()`: Toggle and persist
5. **lib/screens/settings_screen.dart**
- UI for toggling individual providers on/off
- Display provider name and instance ID
6. **lib/screens/new_library_screen.dart**
- Integration with provider filtering
7. **lib/models/music_provider.dart** (new file)
- Model for MA provider instances
- Fields: `instanceId`, `domain`, `name`, `available`
## Race Condition Issue
A race condition was identified where:
1. Initial app sync starts WITHOUT provider filter
2. User toggles provider filter
3. `clearCache()` is called
4. New sync with provider filter tries to start
5. "Sync already in progress" - new sync is skipped
6. Result: Old unfiltered data remains
Fix attempted: Reset `_isSyncing = false` in `clearCache()` to allow fresh sync.
## Potential Solutions to Explore
### 1. Investigate MA Server-Side Filtering
The MA server should support filtering by provider. Need to verify:
- Does the `provider` parameter work for filtering between same-type providers?
- Is there a different parameter or API endpoint?
- Check MA source code: https://github.com/music-assistant/server
### 2. Library Sync Per Provider
Instead of syncing all items and filtering, sync each provider's library separately:
```dart
for (final provider in enabledProviders) {
final items = await api.getAlbums(provider: provider.instanceId);
// Store with provider association
}
```
### 3. Track Item Origin
Store which provider originally added each item:
- Modify database schema to track `source_provider`
- When syncing, record which provider the item came from
- Filter based on source, not playability
### 4. Use MA's Internal Library State
MA might have internal state about which items are in which provider's library. Investigate:
- `/music/library/items` endpoint
- Provider-specific library endpoints
- MA's database schema for library associations
### 5. Check MA User Settings
The MA web UI's provider filter might be stored in user settings:
```dart
final userInfo = await api.getCurrentUserInfo();
// Check for provider_filter or similar field
```
## Testing Notes
### Log Messages to Watch
```
🔌 Selected provider for filter: spotify--xxxxx
🔍 API getArtists args: {limit: 1000, provider: spotify--xxxxx}
🔍 API getArtists returned XXX items
🔌 filterByProvider: XXX → YYY (disabled=[...])
```
### Test Scenario
1. Configure two Spotify accounts in MA
2. Add different artists to each account's library
3. In Ensemble, select only one Spotify account
4. Verify library shows ONLY that account's items
5. Switch to other account, verify different items
### Expected vs Actual
- **Expected**: Artist "Ari Mason" (only in Account A) should NOT appear when Account B is selected
- **Actual**: Artist appears regardless of which account is selected
## MA API Reference
### Get Providers
```
Command: providers
Returns: List of provider instances with type, domain, instanceId, name, available
```
### Library Items with Filter
```
Command: music/artists/library_items
Args: {
limit: int,
offset: int,
provider: string | list<string>, // Filter to specific provider(s)
album_artists_only: bool,
order_by: string
}
```
### Relevant MA Source Files
- `music_assistant/server/controllers/music.py` - Library item retrieval
- `music_assistant/server/models/provider.py` - Provider model
- `music_assistant/server/providers/` - Individual provider implementations
## Conclusion
The core challenge is that MA's `provider_mappings` indicates playability, not ownership. Server-side filtering via the `provider` parameter should work but needs further investigation to understand why it doesn't differentiate between multiple accounts of the same provider type.
The implementation is preserved in the git stash for future reference. The sorting/dropdown improvements were extracted and committed separately.
@@ -1,271 +0,0 @@
# Audiobook Provider Filtering Fix
## Date: January 17, 2026 (Continuation)
## Overview
This session focused on fixing provider filtering issues discovered after the initial provider filtering improvements:
1. Audiobooks were not responding to provider filter changes (browse API ignoring `providerInstanceIds`)
2. UI not updating instantly when toggling providers (missing `notifyListeners()` call)
3. Audiobook tabs not rebuilding on provider changes (using `context.read` instead of `Selector`)
---
## Problem Statement
After implementing capability-based provider filtering for the music library, two issues remained:
### Issue 1: Audiobooks Not Filtering During Sync
The bug report showed:
```
📊 Source tracking: 252 albums, 87 artists have provider info
📚 Total audiobooks from browse: 186
```
- Albums and artists had provider source tracking
- Audiobooks had NO provider source tracking (not listed)
- All 186 audiobooks were returned regardless of provider selection
### Issue 2: UI Not Updating Instantly
When toggling providers in the library menu:
- Music tabs weren't updating immediately
- Audiobooks required manual pull-to-refresh
- Changes only appeared after ~600ms (debounce timer)
---
## Root Cause Analysis
### Cause 1: Browse API Ignoring Provider Filter
In `lib/services/music_assistant_api.dart`, when ABS library filtering is enabled, the `getAudiobooks()` function uses the browse API but was **ignoring** the `providerInstanceIds` parameter:
```dart
// BEFORE: providerInstanceIds was ignored when using browse API
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly);
}
```
### Cause 2: `notifyListeners()` Called Too Late
In `lib/providers/music_assistant_provider.dart`, the `toggleProviderEnabled()` function only called `notifyListeners()` **inside** the 600ms debounce timer:
```dart
// BEFORE: notifyListeners only called after 600ms debounce
_providerFilterDebounceTimer = Timer(const Duration(milliseconds: 600), () {
forceLibrarySync();
notifyListeners(); // Too late! UI should update immediately
});
```
### Cause 3: Audiobook Tabs Not Listening for Changes
The audiobook tabs used `context.read<MusicAssistantProvider>()` which doesn't trigger rebuilds when the provider changes:
```dart
// BEFORE: context.read doesn't listen for changes
Widget _buildBooksAuthorsTab(BuildContext context, S l10n) {
final maProvider = context.read<MusicAssistantProvider>();
final enabledProviders = maProvider.enabledProviderIds.toSet();
// ... widget doesn't rebuild when enabledProviders changes
}
```
---
## Solutions
### Fix 1: Browse API Provider Filtering
**File:** `lib/services/music_assistant_api.dart` (lines 760-785)
Added logic to filter library paths by provider ID before browsing:
```dart
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
_logger.log('📚 getAudiobooks: enabledLibraries=$enabledLibraries, providerInstanceIds=$providerInstanceIds');
// If specific providers are requested, filter libraries to only matching providers
// Library paths start with provider ID (e.g., "audiobookshelf--abc123://lb...")
var librariesToBrowse = enabledLibraries;
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
librariesToBrowse = enabledLibraries.where((libPath) {
// Extract provider ID from library path (everything before "://")
final providerEnd = libPath.indexOf('://');
if (providerEnd == -1) return false;
final providerId = libPath.substring(0, providerEnd);
return providerInstanceIds.contains(providerId);
}).toList();
// If no libraries match the requested providers, return empty
if (librariesToBrowse.isEmpty) {
return [];
}
}
return await _getAudiobooksFromBrowse(librariesToBrowse, favoriteOnly: favoriteOnly);
}
```
### Fix 2: Immediate `notifyListeners()` Call
**File:** `lib/providers/music_assistant_provider.dart` (line 1149)
Moved `notifyListeners()` to fire immediately after `_enabledProviderIds` changes:
```dart
// Reload the enabled providers
final savedEnabled = await SettingsService.getEnabledMusicProviders();
_enabledProviderIds = savedEnabled ?? [];
// Notify listeners IMMEDIATELY so UI rebuilds with client-side filtering
// This enables instant UI updates using cached data with source tracking
notifyListeners();
// Cancel any pending debounce timer
_providerFilterDebounceTimer?.cancel();
// Start debounced sync - waits 600ms for user to finish toggling
// This refreshes the cache with new data from enabled providers
_providerFilterDebounceTimer = Timer(const Duration(milliseconds: 600), () {
_logger.log('🔄 Debounce complete, starting library sync...');
forceLibrarySync();
});
```
### Fix 3: Audiobook Tabs Use Selector
**File:** `lib/screens/new_library_screen.dart`
Wrapped `_buildBooksAuthorsTab` and `_buildAllBooksTab` in `Selector` to rebuild when `enabledProviderIds` changes:
```dart
Widget _buildBooksAuthorsTab(BuildContext context, S l10n) {
// Use Selector to rebuild when enabledProviderIds changes
return Selector<MusicAssistantProvider, Set<String>>(
selector: (_, provider) => provider.enabledProviderIds.toSet(),
builder: (context, enabledProviders, _) {
final syncService = SyncService.instance;
// Client-side filtering using SyncService source tracking for instant updates
var audiobooks = syncService.hasSourceTracking && enabledProviders.isNotEmpty
? syncService.getAudiobooksFilteredByProviders(enabledProviders)
: List<Audiobook>.from(_audiobooks);
// ... rest of widget
},
);
}
```
### Fix 4: Enhanced Sync Logging
**File:** `lib/services/sync_service.dart`
Added audiobook counts to logging:
```dart
// Per-provider log (line 237)
_logger.log(' 📥 Got ${albums.length} albums, ${artists.length} artists, ${audiobooks.length} audiobooks from $providerId');
// Source tracking summary (line 323)
_logger.log('📊 Source tracking: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists, ${_audiobookSourceProviders.length} audiobooks have provider info');
```
---
## Data Flow After Fixes
```
┌─────────────────────────────────────────────────────────────────┐
│ User Toggles Provider │
│ │
│ 1. toggleProviderEnabled() called │
│ 2. _enabledProviderIds updated │
│ 3. notifyListeners() called IMMEDIATELY │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Selectors Detect Change │
│ │
│ Music tabs: Selector<..., (List, bool, Set<String>)> │
│ Audiobook tabs: Selector<..., Set<String>> │
│ │
│ → All tabs rebuild immediately │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Client-Side Filtering (Instant) │
│ │
│ syncService.getAlbumsFilteredByProviders(enabledProviders) │
│ syncService.getArtistsFilteredByProviders(enabledProviders) │
│ syncService.getAudiobooksFilteredByProviders(enabledProviders)│
│ │
│ → Uses EXISTING cache with source tracking │
│ → Instant UI update, no network required │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Background Sync (After 600ms Debounce) │
│ │
│ - Refreshes cache with new data from enabled providers │
│ - Updates source tracking │
│ - Only needed when ENABLING providers (to fetch new data) │
└─────────────────────────────────────────────────────────────────┘
```
---
## Files Changed
| File | Changes |
|------|---------|
| `lib/services/music_assistant_api.dart` | Added provider filtering to browse API path in `getAudiobooks()`, added debug logging |
| `lib/services/sync_service.dart` | Added audiobook counts to per-provider and summary logs |
| `lib/providers/music_assistant_provider.dart` | Moved `notifyListeners()` to fire immediately on provider toggle |
| `lib/screens/new_library_screen.dart` | Wrapped audiobook tabs in `Selector` to rebuild on provider changes |
---
## Expected Behavior After Fixes
1. **Instant UI Updates:** When you toggle a provider, the library immediately updates using client-side filtering
2. **Audiobook Source Tracking:** Audiobooks now have provider source tracking (visible in logs)
3. **Audiobook Tab Rebuilds:** Both audiobook tabs (Authors and All Books) rebuild instantly on provider toggle
4. **Music Tab Consistency:** Music tabs continue to work as before, now with immediate rebuilds
---
## Testing Checklist
- [ ] Toggle a music provider, verify instant UI update (no 600ms delay)
- [ ] Toggle between different Spotify accounts, verify correct filtering
- [ ] Sync with ABS library filtering enabled
- [ ] Verify audiobooks appear in source tracking log
- [ ] Toggle ABS provider off in library menu
- [ ] Verify audiobooks list becomes empty immediately (no pull-to-refresh needed)
- [ ] Toggle ABS provider back on
- [ ] Verify audiobooks reappear after sync completes
- [ ] Test with multiple providers (Spotify audiobooks + ABS)
---
## Related Documentation
- `docs/SESSION_2026_01_17_PROVIDER_FILTERING.md` - Initial provider filtering implementation
- `docs/PER_PROVIDER_SYNC_IMPLEMENTATION.md` - Per-provider sync architecture
---
## Known Limitations
1. **Series Tab:** The Series tab still doesn't filter by provider. Series are fetched as `AudiobookSeries` objects without per-provider tracking.
2. **Mixed Provider Scenario:** If user has both Spotify audiobooks and ABS audiobooks, and ABS library filtering is enabled, Spotify audiobooks may not be fetched because the browse API path is taken. This is a pre-existing architectural limitation.
3. **Enabling Providers:** When ENABLING a previously disabled provider, the UI updates immediately but shows empty until the background sync completes (because the cache doesn't have that provider's data yet). This is expected behavior.
@@ -1,217 +0,0 @@
# Session 2026-01-17: Optimistic Updates & Podcast Provider Filtering
## Overview
This session focused on fixing provider filtering for podcasts, resolving iTunes podcast provider issues, and implementing optimistic updates for library buttons across detail screens.
## Issues Addressed
### 1. Podcast Provider Counts Showing Incorrectly
**Problem**: All Spotify accounts showed the same podcast count instead of only counting podcasts owned by each account.
**Root Cause**: The podcast counting logic wasn't checking the `inLibrary` field on provider mappings.
**Solution**:
- Added `inLibrary` field to `ProviderMapping` model
- Updated `getProvidersWithPodcasts()` to only count mappings where `inLibrary == true`
- Updated podcast filtering in library screen to check `inLibrary` field
**Files Modified**:
- `lib/models/media_item.dart` - Added `inLibrary` field
- `lib/providers/music_assistant_provider.dart` - Updated counting logic
- `lib/screens/new_library_screen.dart` - Updated filtering logic
### 2. iTunes Podcasts Not Appearing in Library
**Problem**: Podcasts added from iTunes search weren't showing in library and iTunes provider filter showed 0 count.
**Investigation**:
- Added `itunes_podcasts` to provider capabilities and music provider domains
- Added synthetic provider instance creation for search-only providers
- Added debug logging to understand podcast loading
**Resolution**: The issue was with Music Assistant's iTunes podcast provider itself - podcasts added via iTunes weren't being properly stored in the MA library. The user removed the iTunes provider from MA and re-added podcasts via Spotify search as a workaround.
**Files Modified**:
- `lib/models/provider_instance.dart` - Added `itunes_podcasts` to capabilities
### 3. Episode Loading Failing for Non-Library Podcasts
**Problem**: When a podcast was removed from library, the old library ID became invalid and episodes wouldn't load.
**Solution**: Added fallback logic to try loading episodes via provider mappings when the primary ID fails.
**Files Modified**:
- `lib/screens/podcast_detail_screen.dart` - Added fallback episode loading
### 4. Search Filtering Rework
**Problem**: Server-side provider filtering was being applied to search results when it should only apply when the "in library" toggle is enabled.
**Solution**:
- Removed `filterSearchResults()` calls from search methods
- Added client-side `_filterByEnabledProviders()` helper
- Applied filtering only when `_libraryOnly` toggle is ON
**Files Modified**:
- `lib/providers/music_assistant_provider.dart` - Removed filterSearchResults calls
- `lib/screens/search_screen.dart` - Added client-side filtering
### 5. Library Buttons Not Updating Immediately
**Problem**: Add/remove library buttons on detail screens waited for API response before updating, causing perceived lag.
**Solution**: Implemented optimistic updates - update UI immediately, revert on failure.
**Files Modified**:
- `lib/screens/podcast_detail_screen.dart`
- `lib/screens/album_details_screen.dart`
- `lib/screens/artist_details_screen.dart`
## Code Changes
### ProviderMapping.inLibrary Field
```dart
// lib/models/media_item.dart
class ProviderMapping {
final String itemId;
final String providerDomain;
final String providerInstance;
final bool available;
final Map<String, dynamic>? audioFormat;
final bool inLibrary; // NEW: indicates ownership
factory ProviderMapping.fromJson(Map<String, dynamic> json) {
return ProviderMapping(
// ... other fields ...
inLibrary: json['in_library'] == true || json['in_library'] == 1,
);
}
}
```
### Podcast Provider Counting
```dart
// lib/providers/music_assistant_provider.dart
Map<String, int> getProvidersWithPodcasts() {
final counts = <String, int>{};
for (final item in _podcasts) {
final mappings = item.providerMappings;
if (mappings != null) {
for (final mapping in mappings) {
// Only count if this provider "owns" the item
if (mapping.inLibrary) {
final instanceId = mapping.providerInstance;
if (instanceId.isNotEmpty) {
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
}
}
}
}
}
return counts;
}
```
### Optimistic Update Pattern
```dart
// Example from podcast_detail_screen.dart
Future<void> _toggleLibrary() async {
final newState = !_isInLibrary;
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.addedToLibrary)),
);
}
// Fire and forget - API call happens in background
maProvider.addToLibrary(...).catchError((e) {
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
}
```
### Client-Side Search Filtering
```dart
// lib/screens/search_screen.dart
Map<String, List<MediaItem>> _filterByEnabledProviders(
Map<String, List<MediaItem>> results,
Set<String> enabledProviders,
) {
if (enabledProviders.isEmpty) return results;
bool isItemAllowed(MediaItem item) {
final mappings = item.providerMappings;
if (mappings == null || mappings.isEmpty) return true;
return mappings.any((m) => m.inLibrary && enabledProviders.contains(m.providerInstance));
}
return {
for (final entry in results.entries)
entry.key: entry.value.where(isItemAllowed).toList(),
};
}
// Applied when setting results:
if (_libraryOnly) {
final enabledProviders = provider.enabledProviderIds.toSet();
combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
}
```
## Provider Capabilities Update
```dart
// lib/models/provider_instance.dart
static const Map<String, Set<String>> providerCapabilities = {
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'ytmusic': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
'apple_music': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'audiobookshelf': {'audiobooks', 'podcasts'},
'itunes_podcasts': {'podcasts'}, // NEW
'tunein': {'radio', 'podcasts'},
// ... other providers
};
```
## Summary of All Modified Files
| File | Changes |
|------|---------|
| `lib/models/media_item.dart` | Added `inLibrary` field to ProviderMapping |
| `lib/models/provider_instance.dart` | Added `itunes_podcasts`, podcasts to capabilities |
| `lib/providers/music_assistant_provider.dart` | Updated podcast counting, removed filterSearchResults |
| `lib/screens/new_library_screen.dart` | Updated podcast filtering with inLibrary check |
| `lib/screens/search_screen.dart` | Added client-side filtering for in-library mode |
| `lib/screens/podcast_detail_screen.dart` | Optimistic updates, fallback episode loading |
| `lib/screens/album_details_screen.dart` | Optimistic updates for library button |
| `lib/screens/artist_details_screen.dart` | Optimistic updates for library button |
## Key Learnings
1. **inLibrary field**: Music Assistant's provider mappings include an `in_library` field that indicates whether the user added the item from that specific provider account. This is essential for accurate per-provider filtering.
2. **iTunes podcast provider**: The MA iTunes podcast provider is for search only - it doesn't properly store items in the library. Users should add podcasts from streaming providers like Spotify instead.
3. **Optimistic updates**: For better UX, library toggle buttons should update immediately and only revert if the API call fails, rather than waiting for API response.
4. **Search vs Library filtering**: Server-side provider filtering should only apply to library views, not search results (unless the "in library" toggle is enabled).
@@ -1,263 +0,0 @@
# Provider Filtering Improvements
## Date: January 17, 2026
## Overview
This session focused on improving the provider filtering system across the app, building on the per-provider sync implementation. Key improvements include a cleaner capability-based architecture, fixing filter visibility bugs, and extending filtering to the audiobooks library.
---
## Changes Made
### 1. Provider Capability Mapping
**File:** `lib/models/provider_instance.dart`
Replaced the clunky item-count-based filtering with a static capability map that defines what content types each provider domain supports.
```dart
static const Map<String, Set<String>> providerCapabilities = {
// Music streaming services
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
'tidal': {'artists', 'albums', 'tracks', 'playlists'},
'qobuz': {'artists', 'albums', 'tracks', 'playlists'},
'deezer': {'artists', 'albums', 'tracks', 'playlists'},
'ytmusic': {'artists', 'albums', 'tracks', 'playlists'},
'soundcloud': {'artists', 'albums', 'tracks', 'playlists'},
'apple_music': {'artists', 'albums', 'tracks', 'playlists'},
'amazon_music': {'artists', 'albums', 'tracks', 'playlists'},
// Self-hosted media servers (can have music and audiobooks)
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
// Music-only servers
'subsonic': {'artists', 'albums', 'tracks', 'playlists'},
'opensubsonic': {'artists', 'albums', 'tracks', 'playlists'},
'navidrome': {'artists', 'albums', 'tracks', 'playlists'},
'filesystem': {'artists', 'albums', 'tracks', 'playlists'},
// Audiobook-only providers
'audiobookshelf': {'audiobooks'},
// Radio providers
'tunein': {'radio'},
'radiobrowser': {'radio'},
// Player/system providers (no library content)
'snapcast': <String>{},
'fully_kiosk': <String>{},
};
```
**Added helper methods:**
```dart
Set<String> get supportedContentTypes => providerCapabilities[domain] ?? <String>{};
bool supportsContentType(String category) => supportedContentTypes.contains(category);
```
**Benefits:**
- Radio providers never show in Artists tab (by design, not by count)
- Works immediately, even with empty libraries
- Disabled providers still show in menu (if they support the content type)
- Clean separation of concerns
---
### 2. Updated Provider Filtering in MusicAssistantProvider
**File:** `lib/providers/music_assistant_provider.dart`
Updated `getRelevantProvidersForCategory()` to filter by capability:
```dart
List<(ProviderInstance, int)> getRelevantProvidersForCategory(String category) {
// ... get counts ...
final result = <(ProviderInstance, int)>[];
for (final provider in _availableMusicProviders) {
// Only include providers that support this content type
if (provider.supportsContentType(category)) {
final count = counts[provider.instanceId] ?? 0;
result.add((provider, count));
}
}
// ...
}
```
---
### 3. Simplified Library Menu Provider Filter
**File:** `lib/screens/new_library_screen.dart`
Simplified the options menu overlay to show all providers that support the current category:
**Before (clunky):**
```dart
if (widget.relevantProviders.where((p) {
final hasItems = p.$2 > 0;
final isDisabledByUser = widget.enabledProviderIds.isNotEmpty &&
!widget.enabledProviderIds.contains(p.$1.instanceId);
return hasItems || isDisabledByUser;
}).length > 1) ...[
```
**After (clean):**
```dart
// relevantProviders is pre-filtered by capability
if (widget.relevantProviders.length > 1) ...[
// Show all providers that support this category
...widget.relevantProviders.map((providerData) {
```
---
### 4. Removed Provider Toggles from Settings Screen
**File:** `lib/screens/settings_screen.dart`
Removed the "Music Providers" section entirely since provider filtering is now handled in the library menu:
- Removed `_providerEnabled` state variable
- Removed provider loading code from `_loadSettings()`
- Removed the entire Music Providers UI section (~100 lines)
**Rationale:** Single source of truth - provider toggles now only appear in the library menu where they're contextually relevant.
---
### 5. Fixed Audiobooks Library Provider Filtering
**File:** `lib/screens/new_library_screen.dart`
The audiobooks tabs were not filtering by provider - they loaded data once and displayed it without responding to provider changes.
**Fixed `_buildBooksAuthorsTab()`:**
```dart
final syncService = SyncService.instance;
final enabledProviders = maProvider.enabledProviderIds.toSet();
// Client-side filtering using SyncService source tracking
var audiobooks = syncService.hasSourceTracking && enabledProviders.isNotEmpty
? syncService.getAudiobooksFilteredByProviders(enabledProviders)
: List<Audiobook>.from(_audiobooks);
// Filter by favorites if enabled
if (_showFavoritesOnly) {
audiobooks = audiobooks.where((a) => a.favorite == true).toList();
}
// Group filtered audiobooks by author (dynamic, not cached)
final groupedByAuthor = <String, List<Audiobook>>{};
for (final book in audiobooks) {
final authorName = book.authorsString;
groupedByAuthor.putIfAbsent(authorName, () => []).add(book);
}
```
**Fixed `_buildAllBooksTab()`:**
- Same provider filtering pattern
- Dynamic sorting after filtering
- Updated PageStorageKey to include provider count for proper rebuild
---
## Bug Fixes
### Provider Filter Menu Disappearing
**Problem:** When providers were deselected, the background sync would clear the cache and only fetch data for enabled providers. Disabled providers then had 0 items, and the menu required `> 1 providers WITH items` to show. The entire Providers section would disappear.
**Solution:** Changed to capability-based filtering. The menu now shows all providers that support the category, regardless of item count.
### Audiobooks Not Filtering by Provider
**Problem:** Audiobooks tabs loaded data once into `_audiobooks` and displayed it without provider filtering. Toggling providers in the menu had no effect.
**Solution:** Added inline provider filtering during build (same pattern as music tabs).
---
## Files Changed
| File | Changes |
|------|---------|
| `lib/models/provider_instance.dart` | Added `providerCapabilities` map and helper methods |
| `lib/providers/music_assistant_provider.dart` | Updated `getRelevantProvidersForCategory()` to filter by capability |
| `lib/screens/new_library_screen.dart` | Simplified menu filter logic, added audiobook provider filtering |
| `lib/screens/settings_screen.dart` | Removed Music Providers section |
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Provider Capability Map │
│ (ProviderInstance.providerCapabilities) │
│ │
│ spotify → {artists, albums, tracks, playlists, audiobooks} │
│ audiobookshelf → {audiobooks} │
│ tunein → {radio} │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ getRelevantProvidersForCategory(category) │
│ │
│ - Filters _availableMusicProviders by capability │
│ - Returns only providers that support the category │
│ - Includes item counts for display │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Library Menu (Options) │
│ │
│ - Shows providers section if > 1 providers support category │
│ - All relevant providers shown (can toggle on/off) │
│ - Instant UI update via setState() │
│ - Background sync after 1.5s debounce │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Tab Build Methods │
│ │
│ - Get enabledProviderIds from MusicAssistantProvider │
│ - Filter data using SyncService source tracking │
│ - Display filtered results │
└─────────────────────────────────────────────────────────────────┘
```
---
## Known Limitations
1. **Series Tab:** The Series tab in audiobooks doesn't filter by provider yet. Series are fetched separately as `AudiobookSeries` objects and don't have the same source tracking as individual audiobooks.
2. **First Sync Required:** Source tracking only works after at least one sync with providers enabled.
---
## Testing Checklist
- [x] Music tabs filter by provider correctly
- [x] Provider menu shows only relevant providers per category
- [x] Radio providers don't appear in Artists/Albums tabs
- [x] Spotify appears in Audiobooks tab (has audiobooks)
- [x] Audiobooks Authors tab filters by provider
- [x] Audiobooks All Books tab filters by provider
- [ ] Audiobooks Series tab filters by provider (not implemented)
- [x] Disabled providers can be re-enabled from menu
- [x] Settings screen no longer has provider toggles
---
## Related Documentation
- `docs/PER_PROVIDER_SYNC_IMPLEMENTATION.md` - Original per-provider sync implementation
-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
-221
View File
@@ -1,221 +0,0 @@
# Feature: Replace "Play On" Popups with Player Selector
## Overview
Replace the `PlayerPickerSheet` popup modal with the existing Player Selector (reveal overlay) when users tap "Play on" buttons throughout the app. This provides a consistent, richer player selection experience.
## Current State
### PlayerPickerSheet Popup
Used in 7+ places:
- `album_details_screen.dart` - Play Album, Play From Here, Play Radio
- `playlist_details_screen.dart` - Play Playlist, Play Radio
- `search_screen.dart` - Play album/playlist/audiobook/radio from search
- `podcast_detail_screen.dart` - Play Episode
- `audiobook_detail_screen.dart` - Play Audiobook
- `queue_screen.dart` - Transfer Queue (special case - keep popup)
### Player Selector (Reveal Overlay)
- Location: `lib/widgets/player/player_reveal_overlay.dart`
- Shows vertical list of all players with artwork, current track, sync status
- Has hints above player list: "Long-press to sync" & "Swipe to adjust volume"
- Triggered by pull-down gesture or device button tap
## Proposed Changes
### 1. Modify PlayerRevealOverlay
**File:** `lib/widgets/player/player_reveal_overlay.dart`
Add new optional parameters:
```dart
class PlayerRevealOverlay extends StatefulWidget {
final VoidCallback onDismiss;
final double miniPlayerBottom;
final double miniPlayerHeight;
final bool showOnboardingHints;
// NEW: Callback when player is selected for a pending action
final void Function(Player player)? onPlayerSelected;
// NEW: Context hint to display instead of default hints
// e.g., "Select player to play album"
final String? contextHint;
// NEW: Icon for context hint (default: speaker icon)
final IconData? contextHintIcon;
```
### 2. Modify Hint Display
**File:** `lib/widgets/player/player_reveal_overlay.dart` (around line 386-394)
```dart
// Current:
: Column(
mainAxisSize: MainAxisSize.min,
children: [
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
const SizedBox(height: 4),
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
],
),
// New:
: widget.contextHint != null
? buildHintRow(
widget.contextHintIcon ?? Icons.play_circle_outline,
widget.contextHint!,
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
const SizedBox(height: 4),
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
],
),
```
### 3. Modify Player Card onTap
**File:** `lib/widgets/player/player_reveal_overlay.dart` (around line 437-441 and 490-494)
```dart
// Current:
onTap: () {
HapticFeedback.mediumImpact();
maProvider.selectPlayer(data.player);
dismiss();
},
// New:
onTap: () {
HapticFeedback.mediumImpact();
if (widget.onPlayerSelected != null) {
// Execute the pending action with selected player
widget.onPlayerSelected!(data.player);
} else {
// Default behavior: just select the player
maProvider.selectPlayer(data.player);
}
dismiss();
},
```
### 4. Add Helper Method to GlobalPlayerOverlay
**File:** `lib/widgets/global_player_overlay.dart`
Add a static method to show player selector with pending action:
```dart
/// Show player selector for a "Play on" action
///
/// [contextHint] - Message shown instead of default hints
/// [onPlayerSelected] - Called when user taps a player
///
/// Example:
/// ```dart
/// GlobalPlayerOverlay.showPlayerSelectorForAction(
/// contextHint: "Select player to play album",
/// onPlayerSelected: (player) {
/// provider.playAlbum(albumId, playerId: player.playerId);
/// },
/// );
/// ```
static void showPlayerSelectorForAction({
required String contextHint,
required void Function(Player player) onPlayerSelected,
IconData? hintIcon,
}) {
// Implementation: trigger the player reveal overlay with these parameters
// May need to store pending action in state and pass to overlay
}
```
### 5. Update Calling Sites
Replace `showPlayerPickerSheet()` calls with new method:
**Example - album_details_screen.dart:**
```dart
// Current (around line 1305-1316):
showPlayerPickerSheet(
context: context,
title: 'Play Album',
onPlayerSelected: (player) async {
GlobalPlayerOverlay.hidePlayer();
await maProvider.playAlbum(albumId, playerId: player.playerId);
GlobalPlayerOverlay.showPlayer();
},
);
// New:
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlayAlbum, // Add localization
onPlayerSelected: (player) async {
await maProvider.playAlbum(albumId, playerId: player.playerId);
},
);
```
### 6. Add Localizations
**File:** `lib/l10n/app_en.arb` (and other language files)
```json
"selectPlayerToPlay": "Select player to play",
"selectPlayerToPlayAlbum": "Select player to play album",
"selectPlayerToPlayPlaylist": "Select player to play playlist",
"selectPlayerToPlayRadio": "Select player to start radio",
"selectPlayerToPlayEpisode": "Select player to play episode",
"selectPlayerToPlayAudiobook": "Select player to play audiobook"
```
## Files to Modify
1. `lib/widgets/player/player_reveal_overlay.dart` - Add parameters, modify hints & onTap
2. `lib/widgets/global_player_overlay.dart` - Add helper method
3. `lib/screens/album_details_screen.dart` - Replace 3 popup calls
4. `lib/screens/playlist_details_screen.dart` - Replace 2 popup calls
5. `lib/screens/search_screen.dart` - Replace ~5 popup calls
6. `lib/screens/podcast_detail_screen.dart` - Replace 1 popup call
7. `lib/screens/audiobook_detail_screen.dart` - Replace 1 popup call
8. `lib/l10n/app_en.arb` - Add new strings
9. `lib/l10n/app_de.arb`, `app_fr.arb`, `app_es.arb` - Translations
## Exception: Queue Transfer
**Keep the popup for `queue_screen.dart`** because:
- Needs to filter out current player (can't transfer to self)
- Needs to filter out certain player types
- Different UX intent (transfer vs play)
## Implementation Order
1. Modify `PlayerRevealOverlay` to accept new parameters
2. Add helper method to `GlobalPlayerOverlay`
3. Test with one screen (album_details_screen.dart)
4. Add localizations
5. Migrate remaining screens
6. Clean up unused code from PlayerPickerSheet if no longer needed elsewhere
## Testing Checklist
- [ ] Album details: Play Album button
- [ ] Album details: Play From Here (track context menu)
- [ ] Album details: Play Radio (track context menu)
- [ ] Playlist details: Play Playlist button
- [ ] Playlist details: Play Radio (track context menu)
- [ ] Search: Play album from results
- [ ] Search: Play playlist from results
- [ ] Search: Play radio from results
- [ ] Podcast detail: Play episode
- [ ] Audiobook detail: Play audiobook
- [ ] Queue transfer still works with popup
- [ ] Long-press to sync still works in player selector
- [ ] Dismiss by tapping backdrop works
- [ ] Dismiss by swiping down works
@@ -1,328 +0,0 @@
# Remote Connection - Future Implementation Guide
## Overview
This document provides guidance for implementing remote connection to Music Assistant servers via WebRTC. Remote connection allows the Ensemble app to connect to MA servers outside the local network using Nabu Casa's WebRTC signaling infrastructure.
## Current Status: On Hold
Remote connection was partially implemented but put on hold due to a critical bug where the `ma-api` data channel becomes one-way after 1-3 minutes. See `MA_WEBRTC_BUG_REPORT.md` for details.
---
## Architecture
### How It Works
```
┌─────────────────┐ WebSocket ┌──────────────────────────┐
│ Ensemble App │◄──────────────────►│ MA Server (local WS) │
└─────────────────┘ └──────────────────────────┘
│ │
│ (when local unavailable) │
▼ ▼
┌─────────────────┐ ┌──────────────────────────┐
│ SignalingClient│◄──── WebSocket ───►│ signaling.music-assistant│
└─────────────────┘ │ .io/ws │
│ └──────────────────────────┘
│ ICE candidates, SDP │
▼ ▼
┌─────────────────┐ ┌──────────────────────────┐
│ WebRTC Peer │◄═══ Data Channel ══►│ MA Server WebRTC │
│ Connection │ (ma-api) │ Endpoint │
└─────────────────┘ └──────────────────────────┘
```
### Components
1. **SignalingClient** - WebSocket connection to `wss://signaling.music-assistant.io/ws`
2. **WebRTCConnection** - Manages RTCPeerConnection and data channels
3. **RemoteBridge** - Bridges local WebSocket API to remote WebRTC connection
### Data Channels
| Channel | Purpose | Traffic Pattern |
|---------|---------|-----------------|
| `ma-api` | JSON API requests/responses | High volume, large payloads |
| `sendspin` | Audio streaming + control | Low JSON, binary audio data |
---
## Key Files to Create/Modify
### New Files
| File | Purpose |
|------|---------|
| `lib/services/remote/signaling_client.dart` | WebSocket client for signaling server |
| `lib/services/remote/webrtc_connection.dart` | WebRTC peer connection management |
| `lib/services/remote/remote_bridge.dart` | Bridges API calls to WebRTC |
### Files to Modify
| File | Changes |
|------|---------|
| `lib/services/music_assistant_api.dart` | Add remote connection fallback |
| `lib/providers/music_assistant_provider.dart` | Expose remote connection state |
| `pubspec.yaml` | Add `flutter_webrtc` dependency |
---
## Implementation Steps
### Step 1: Add Dependencies
```yaml
# pubspec.yaml
dependencies:
flutter_webrtc: ^0.12.0 # or latest
```
### Step 2: Implement SignalingClient
The signaling client connects to Music Assistant's signaling server and exchanges WebRTC offers/answers.
```dart
class SignalingClient {
WebSocket? _socket;
Future<void> connect(String serverId) async {
_socket = await WebSocket.connect('wss://signaling.music-assistant.io/ws');
// Send connect request
_send({
'type': 'connect-request',
'server_id': serverId,
});
// Listen for messages
_socket!.listen(_handleMessage);
}
void _handleMessage(dynamic data) {
final message = jsonDecode(data);
switch (message['type']) {
case 'answer':
onAnswer?.call(message['sdp']);
break;
case 'ice-candidate':
onIceCandidate?.call(message['candidate']);
break;
}
}
}
```
### Step 3: Implement WebRTCConnection
```dart
class WebRTCConnection {
RTCPeerConnection? _peerConnection;
RTCDataChannel? _apiChannel;
RTCDataChannel? _sendspinChannel;
Future<void> connect(SignalingClient signaling) async {
// Create peer connection with STUN/TURN servers
_peerConnection = await createPeerConnection({
'iceServers': [
{'urls': 'stun:stun.l.google.com:19302'},
// TURN servers provided by signaling
],
});
// Create data channels BEFORE creating offer
_apiChannel = await _peerConnection!.createDataChannel(
'ma-api',
RTCDataChannelInit()..ordered = true,
);
_sendspinChannel = await _peerConnection!.createDataChannel(
'sendspin',
RTCDataChannelInit()..ordered = true,
);
// Create and send offer
final offer = await _peerConnection!.createOffer();
await _peerConnection!.setLocalDescription(offer);
signaling.sendOffer(offer.sdp!);
// Handle answer from server
signaling.onAnswer = (sdp) async {
await _peerConnection!.setRemoteDescription(
RTCSessionDescription(sdp, 'answer'),
);
};
}
}
```
### Step 4: Implement RemoteBridge
The bridge translates between the local WebSocket API format and WebRTC data channel messages.
```dart
class RemoteBridge {
final WebRTCConnection _webrtc;
final StreamController<String> _messageController;
void sendCommand(String json) {
_webrtc.sendToApiChannel(json);
}
Stream<String> get messages => _messageController.stream;
}
```
### Step 5: Integrate with MusicAssistantAPI
```dart
class MusicAssistantAPI {
WebSocket? _localSocket;
RemoteBridge? _remoteBridge;
Future<void> connect(String host, {String? serverId}) async {
try {
// Try local connection first
_localSocket = await WebSocket.connect('ws://$host/ws');
} catch (e) {
// Fall back to remote if serverId provided
if (serverId != null) {
_remoteBridge = RemoteBridge();
await _remoteBridge!.connect(serverId);
}
}
}
}
```
---
## Critical Implementation Details
### 1. Channel Creation Order
The client MUST create data channels BEFORE sending the WebRTC offer. The server expects the client to create:
- `ma-api` - for API traffic
- `sendspin` - for audio streaming (optional)
### 2. Health Monitoring
Implement aggressive health checks because data channels can silently die:
```dart
// Track message counts
int _sentCount = 0;
int _recvCount = 0;
DateTime _lastRecvTime = DateTime.now();
// Health check every 10 seconds
void _healthCheck() {
final timeSinceRecv = DateTime.now().difference(_lastRecvTime);
if (timeSinceRecv.inSeconds > 30) {
// Channel is dead, trigger reconnection
_reconnect();
}
}
```
### 3. Keepalive Messages
Send periodic keepalive on the data channel itself (not just signaling):
```dart
void _sendKeepalive() {
_apiChannel?.send(RTCDataChannelMessage(
jsonEncode({'type': 'ping', 'timestamp': DateTime.now().toIso8601String()}),
));
}
```
### 4. Flow Control
Monitor buffer to prevent overflow:
```dart
void sendMessage(String json) {
if (_apiChannel!.bufferedAmount > 1048576) { // 1MB
// Queue message, don't send
_sendQueue.add(json);
return;
}
_apiChannel!.send(RTCDataChannelMessage(json));
}
```
---
## Known Issues & Pitfalls
### 1. One-Way Channel Death (CRITICAL - UNSOLVED)
The `ma-api` channel stops receiving after 1-3 minutes while `sendspin` continues working. This occurs in both flutter_webrtc and WebView implementations.
**Workaround options:**
- Detect via health check and force reconnection
- Try single-channel multiplexing (see `PLAN_B_SINGLE_CHANNEL.md`)
- Report to MA team and wait for server-side fix
### 2. flutter_webrtc Quirks
- `RTCDataChannel.state` may show "open" when actually dead
- `bufferedAmount` accuracy varies by platform
- `onBufferedAmountLow` callback support inconsistent
### 3. Android Background Throttling
Android aggressively throttles background WebRTC. Consider:
- Foreground service for sustained connections
- Reconnection on app resume
### 4. ICE/TURN Issues
- aiortc (server-side) has known TURN + DataChannel bugs
- May need ICE restart on network changes
---
## Testing Checklist
- [ ] Connect via remote when local unavailable
- [ ] Verify API commands work (players/all, library queries)
- [ ] Test sustained connection (30+ minutes)
- [ ] Test app backgrounding and resume
- [ ] Test network transitions (WiFi ↔ cellular)
- [ ] Verify reconnection after channel death
- [ ] Test sendspin audio streaming (if implemented)
---
## Useful Resources
- [Music Assistant Remote Access Docs](https://music-assistant.io/docs/remote-access/)
- [flutter_webrtc Package](https://pub.dev/packages/flutter_webrtc)
- [WebRTC Data Channel MDN](https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel)
- Signaling server: `wss://signaling.music-assistant.io/ws`
---
## Files in This Directory
| File | Description |
|------|-------------|
| `FUTURE_IMPLEMENTATION_GUIDE.md` | This file - implementation guide |
| `MA_WEBRTC_BUG_REPORT.md` | Bug report for MA team |
| `WEBRTC_DISCONNECT_FIXES.md` | All fixes attempted |
| `WEBRTC_PROGRESS.md` | Progress log |
| `PLAN_B_SINGLE_CHANNEL.md` | Alternative single-channel approach |
| `webrtc-failure-logs.txt` | Raw failure logs |
| `console-export-*.log` | Browser console exports |
---
## Quick Start for Future Implementation
1. Read this guide fully
2. Review `WEBRTC_DISCONNECT_FIXES.md` for all attempted fixes
3. Check if MA team has fixed server-side issues
4. Start with basic implementation, add health monitoring early
5. Test extensively before shipping - the failure mode is subtle
@@ -1,127 +0,0 @@
# WebRTC Data Channel Failure on Android - Bug Report
## Summary
When connecting to Music Assistant via WebRTC remote access (Nabu Casa), the `ma-api` data channel becomes **one-way** (can send, cannot receive) after 1-3 minutes of use, while the `sendspin` channel continues working normally.
## Environment
- **MA Server**: 2.8.0b7 (Home Assistant add-on)
- **Client**: Android app (Flutter) connecting via Nabu Casa remote access
- **Remote Access**: WebRTC via `wss://signaling.music-assistant.io/ws`
## Reproduction Steps
1. Connect to MA server via WebRTC remote access (not local network)
2. Use the app normally - browse library, view players
3. After 1-3 minutes, the connection degrades
4. Commands time out, but signaling ping/pong continues working
## Observed Behavior
### Timeline from logs:
```
07:14:20 - Working normally
Health: API sent:51 recv:54 (balanced)
07:15:57 - Receive stops
Health: API sent:97 recv:97, lastRecv:12s ago
07:16:02 - Timeouts begin
"TimeoutException: Command timeout: players/all"
07:16:17 - Sendspin still works!
"Sendspin: Received server/time response"
Health: API sent:114 recv:97, lastRecv:27s ago
07:17:30 - Forced reconnect
Health: API sent:137 recv:97, lastRecv:42s ago
"Health check detected stale connection"
```
### Key Observation
| Channel | Traffic Pattern | Status |
|---------|-----------------|--------|
| `ma-api` | High (10+ msg/s, large payloads) | **FAILS** - stops receiving |
| `sendspin` | Low (~1 msg/5s, small payloads) | **SURVIVES** - continues working |
| Signaling | Ping/pong every 15s | **SURVIVES** - continues working |
The WebRTC peer connection remains in "connected" state. ICE connection shows "completed". Only the ma-api data channel stops receiving.
## Testing Performed
### Test 1: flutter_webrtc (Native WebRTC)
- **Result**: ma-api channel dies after 1-3 minutes
- **Sendspin**: Continues working
### Test 2: WebView-based WebRTC (Browser engine)
- **Hypothesis**: Use Android WebView's browser-native WebRTC, since MA Web UI works
- **Implementation**: Hidden InAppWebView running JavaScript WebRTC engine
- **Result**: **Same failure mode** - ma-api dies, sendspin survives
- **Conclusion**: Not a flutter_webrtc bug
### Test 3: Various Data Channel Settings
- Tried `ordered: false` with `maxRetransmits: 3`
- Tried letting server create channels (failed - server expects client to create)
- No improvement
## Analysis
The identical failure in both native and WebView WebRTC implementations suggests the issue is:
1. **Server-side** - Different handling of ma-api vs sendspin channels
2. **TURN relay** - High-traffic channel may hit limits
3. **Not client-specific** - Both implementations fail identically
### Questions for MA Team
1. Are the two data channels (`ma-api`, `sendspin`) created with different settings?
2. Is there any server-side buffering or rate limiting that could affect high-traffic channels?
3. Does the MA Web UI use different data channel configurations?
4. Are there known issues with the TURN relay for sustained high-traffic channels?
## Comparison: MA Web UI vs Android App
| Aspect | MA Web UI | Android App |
|--------|-----------|-------------|
| Platform | Browser tab (visible, active) | Background-capable app |
| WebRTC | Browser native | flutter_webrtc / WebView |
| Result | Works | Fails after 1-3 min |
The MA Web UI runs in a **visible, active browser tab**. Android may throttle background apps differently, but this doesn't explain why sendspin survives while ma-api dies on the same connection.
## Logs
### Health Check Pattern (shows channel death)
```
07:14:25 API sent:51 recv:54, lastRecv:2s ago <- Working
07:15:35 API sent:87 recv:91, lastRecv:2s ago <- Working
07:16:05 API sent:108 recv:97, lastRecv:17s ago <- DYING (gap growing)
07:16:15 API sent:114 recv:97, lastRecv:27s ago <- DEAD
07:16:45 API sent:130 recv:97, lastRecv:27s ago <- Still sending, no receive
07:17:30 API sent:137 recv:97, lastRecv:42s ago <- Forced reconnect
```
### Sendspin Still Working During Failure
```
07:16:17 Sendspin: Received server/time response <- Still alive!
07:16:47 Sendspin: Received server/time response <- Still alive!
```
## Requested Information
1. Data channel creation code/settings for both channels
2. Any server-side differences in channel handling
3. TURN server configuration details
4. Whether this has been reported by other Android/mobile clients
## Workaround
Currently using local WebSocket connection when on same network. Remote access via WebRTC is unusable for sustained use.
---
**Reporter**: Ensemble Android App Developer
**Date**: 2026-01-24
@@ -1,149 +0,0 @@
# Plan B: Single Data Channel Multiplexing
## Why This Might Be Needed
If upgrading flutter_webrtc doesn't fix the one-way channel failure, the root cause is likely a bug in how flutter_webrtc (or underlying libwebrtc) handles **multiple data channels**.
Evidence:
- MA Web UI works fine with local playback (same server, browser WebRTC)
- Our app uses flutter_webrtc which has known issues:
- [Issue #1428](https://github.com/flutter-webrtc/flutter-webrtc/issues/1428): Threading issues causing data loss
- [Issue #974](https://github.com/flutter-webrtc/flutter-webrtc/issues/974): EventChannel intermittently fails
- Pattern matches: second channel survives while first dies
## Current Architecture
```
┌─────────────────────────────────────────────────┐
│ WebRTC Peer Connection │
├────────────────────┬────────────────────────────┤
│ ma-api channel │ sendspin channel │
│ (JSON API) │ (JSON + binary audio) │
│ SCTP stream 0 │ SCTP stream 1 │
└────────────────────┴────────────────────────────┘
```
## Proposed Architecture
```
┌─────────────────────────────────────────────────┐
│ WebRTC Peer Connection │
├─────────────────────────────────────────────────┤
│ unified channel │
│ (multiplexed: API + Sendspin text + binary) │
│ SCTP stream 0 │
└─────────────────────────────────────────────────┘
```
## Multiplexing Protocol
### Message Framing
Each message has a 1-byte type prefix:
| Prefix | Type | Content |
|--------|------|---------|
| 0x00 | API | JSON text (MA API request/response) |
| 0x01 | Sendspin Text | JSON text (Sendspin control messages) |
| 0x02 | Sendspin Binary | Raw bytes (PCM audio data) |
### Implementation
#### Sending (Client → Server)
```dart
void sendApiMessage(String json) {
final prefix = Uint8List.fromList([0x00]);
final payload = utf8.encode(json);
final message = Uint8List(1 + payload.length);
message[0] = 0x00;
message.setRange(1, message.length, payload);
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
}
void sendSendspinText(String json) {
final prefix = Uint8List.fromList([0x01]);
final payload = utf8.encode(json);
final message = Uint8List(1 + payload.length);
message[0] = 0x01;
message.setRange(1, message.length, payload);
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
}
void sendSendspinBinary(Uint8List audio) {
final message = Uint8List(1 + audio.length);
message[0] = 0x02;
message.setRange(1, message.length, audio);
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
}
```
#### Receiving (Server → Client)
```dart
void _onMessage(RTCDataChannelMessage message) {
final data = message.binary;
if (data == null || data.isEmpty) return;
final type = data[0];
final payload = data.sublist(1);
switch (type) {
case 0x00: // API
final json = utf8.decode(payload);
_handleApiMessage(json);
break;
case 0x01: // Sendspin text
final json = utf8.decode(payload);
_handleSendspinText(json);
break;
case 0x02: // Sendspin binary
_handleSendspinBinary(payload);
break;
}
}
```
## Server-Side Changes Required
The Music Assistant server would need to:
1. Accept a single "unified" data channel instead of separate ma-api and sendspin channels
2. Multiplex responses using the same framing protocol
3. This requires changes to the MA remote connection code
**Alternative**: Keep server unchanged, do multiplexing in a proxy layer on the client.
## Client-Only Approach (No Server Changes)
If we can't change the server, we could:
1. Still create two channels to the server (ma-api + sendspin)
2. But internally route them through a single "virtual" channel for flutter_webrtc
3. This is more complex and may not avoid the underlying bug
## Files to Modify
| File | Changes |
|------|---------|
| `webrtc_connection.dart` | Single channel creation, multiplexing logic |
| `remote_bridge.dart` | Route messages based on type prefix |
## Risks
1. **Server compatibility**: Requires MA server changes or complex client proxy
2. **Performance**: Extra byte per message, decode overhead
3. **Binary handling**: Must handle all messages as binary (not text)
## Testing Plan
1. Implement client-side changes
2. Test with modified server (if available) or mock
3. Verify both API and Sendspin traffic flows correctly
4. Long-duration stability test
## Decision Point
Try this approach if:
- flutter_webrtc 0.14.x doesn't fix the issue
- The one-way failure pattern continues
- We've confirmed the issue is multi-channel specific
@@ -1,406 +0,0 @@
# WebRTC Data Channel Disconnect Fixes
## Document Purpose
Track all fixes attempted for the WebRTC data channel disconnection issues in Ensemble's remote connection feature. This document should be referenced when debugging future disconnection problems.
---
## Problem Summary
The WebRTC data channel between the Ensemble mobile app and Music Assistant server can fail in several ways:
1. **One-way failure**: Data channel appears "open" but only sends work, receives stop
2. **Silent disconnect**: No error events fire, channel just stops working
3. **ICE failure**: ICE connection fails, properly detected but causes disconnect
4. **Signaling disconnect**: Signaling WebSocket closes, causing ICE/WebRTC to fail
---
## Key Files
| File | Purpose |
|------|---------|
| `lib/services/remote/webrtc_connection.dart` | WebRTC peer connection, data channel management |
| `lib/services/remote/remote_bridge.dart` | WebSocket bridge, health monitoring, reconnection logic |
| `lib/services/remote/signaling_client.dart` | Signaling server WebSocket connection |
---
## Implemented Fixes (Chronological)
### Fix 1: Data Channel Keepalive (Original)
**Problem**: Data channel could silently die without any error events firing.
**Solution**: Added periodic keepalive ping/pong on the data channel itself.
**Code** (`webrtc_connection.dart`):
- `_keepaliveTimer`: Runs every 10 seconds
- `_checkKeepalive()`: Checks time since last data received
- `_sendKeepalivePing()`: Sends JSON ping message
- `_recordDataReceived()`: Called on every message receipt
**Constants**:
```dart
static const _keepaliveInterval = Duration(seconds: 10);
static const _keepaliveTimeout = Duration(seconds: 20); // Reduced from 30
static const _maxKeepaliveMisses = 2; // Reduced from 3
```
### Fix 2: ICE Recovery
**Problem**: ICE connection could temporarily disconnect and might auto-recover.
**Solution**: Added ICE recovery timer before declaring failure.
**Code** (`webrtc_connection.dart`):
- `_startIceRecovery()`: Starts 5-second timer when ICE disconnects
- `_cancelIceRecovery()`: Cancels if ICE recovers on its own
- `_attemptIceRestart()`: Calls `restartIce()` if recovery timer expires
### Fix 3: Flow Control (Buffer Overflow Prevention)
**Problem**: Fire-and-forget sends could overflow the 16MB SCTP buffer, causing silent channel death.
**Solution**: Added send-side flow control with buffering.
**Code** (`webrtc_connection.dart`):
```dart
static const _maxBufferedAmount = 1048576; // 1MB threshold
static const _bufferLowThreshold = 65536; // 64KB resume threshold
bool _sendPaused = false;
final _sendQueue = <String>[];
```
**Modified `sendRaw()`**:
- Checks `bufferedAmount` before sending
- Queues messages if buffer > 1MB
- `onBufferedAmountLow` callback drains queue
### Fix 4: Health Check Stale Detection
**Problem**: Health check could see "30s since last data" but not trigger reconnection.
**Solution**: Reduced stale detection threshold from 45s to 30s.
**Code** (`remote_bridge.dart`):
```dart
if (_state == RemoteBridgeState.connected && timeSinceLastData > 30 && !_isReconnecting) {
// Force reconnection
}
```
### Fix 5: Reduced Keepalive Timeouts
**Problem**: 30s timeout × 3 misses = 90s minimum before detection.
**Solution**: Reduced to 20s timeout × 2 misses = ~40s detection.
### Fix 6: Recv Stagnation Detection
**Problem**: One-way failure where recv stops but sent continues not detected quickly enough.
**Solution**: Track recv count across health checks. If recv stuck while sent increases for 2 consecutive checks (20s), force reconnection.
**Code** (`remote_bridge.dart`):
```dart
// Track across health checks
int _lastHealthCheckRecvCount = 0;
int _lastHealthCheckSentCount = 0;
int _recvStagnationCount = 0;
// In health check:
if (recvStuck && sentIncreased) {
_recvStagnationCount++;
if (_recvStagnationCount >= 2) {
// Force reconnection - channel is one-way
}
}
```
**Detection time**: ~20 seconds (2 × 10s health check interval)
---
## Log Patterns to Watch For
### Healthy Connection
```
Health check - API sent:93 recv:88 ... lastRecv:0s ago
```
- `sent` and `recv` both increasing
- `lastRecv` low (< 5s typically)
### One-Way Failure (CRITICAL)
```
Health check - API sent:206 recv:163 ... lastRecv:30s ago
Health check - API sent:218 recv:163 ... lastRecv:40s ago
```
- `sent` increases, `recv` STUCK
- `lastRecv` keeps increasing
- Data channel still shows "open"
### Signaling Disconnect
```
WebRTC: Signaling disconnected
... (6-16 seconds later) ...
WebRTC: ICE connection state: RTCIceConnectionStateDisconnected
WebRTC: ICE connection state: RTCIceConnectionStateFailed
```
### Network Failure (DNS)
```
Signaling: WebSocket closed
... (reconnection attempt) ...
Signaling: Failed to connect: SocketException: Failed host lookup: 'signaling.music-assistant.io'
```
This indicates the phone lost network connectivity entirely.
### Reconnection Storm (Signaling Instability)
```
Signaling: Connecting to wss://signaling.music-assistant.io/ws
Signaling: Sending connect-request
Signaling: WebSocket closed ← Closes within 100-500ms
WebRTC: Signaling disconnected
RemoteBridge: Scheduling reconnect attempt X/10 in Ys
```
Rapid reconnection loop - signaling never stays connected long enough.
### Flow Control Engaged
```
WebRTC: ⚠️ Buffer full (1234567 bytes) or paused - queueing message
WebRTC: Buffer drained to 12345 bytes - draining 5 queued messages
WebRTC: ✅ Send queue drained (5 messages) - resuming normal sends
```
### Keepalive Detection
```
WebRTC: ⚠️ Keepalive timeout! No data for 22s (missed: 1/2)
WebRTC: ❌ Data channel appears dead - triggering reconnection
```
---
## Known Issues / Limitations
### 1. flutter_webrtc Limitations
- `RTCDataChannel.state` may show "open" when actually dead (Issue #843)
- `bufferedAmount` may not be accurate on all platforms
- `onBufferedAmountLow` callback support varies
### 2. SCTP Receive Window
- If server sends faster than client processes, receive window fills
- This causes server-side to stop sending, client sees no data
- No way to monitor receive-side buffer from client
### 3. TURN Server Issues
- aiortc (server-side) has known issues with TURN + DataChannel (#1156)
- Data channel may fail silently when using TURN relay
### 4. Network Transitions
- WiFi → Cellular or vice versa can cause ICE to fail
- App backgrounding on mobile can close WebSocket/WebRTC
---
## Root Cause Categories
### Category A: Network Issues (Not Code Bugs)
These are environmental issues, not bugs in our code:
- **DNS failure**: `Failed host lookup` - Phone lost internet
- **WiFi/Cellular switch**: Network changed, connections dropped
- **Server unreachable**: Signaling server down or blocked
- **Firewall/NAT issues**: TURN relay needed but unavailable
**Evidence**: Signaling WebSocket closes, DNS lookups fail, reconnection attempts fail repeatedly.
### Category B: WebRTC/SCTP Issues (Partial Code Control)
These can be mitigated but not fully prevented:
- **One-way channel death**: Channel appears open but data stops flowing
- **Buffer overflow**: Too much data sent, SCTP buffer exceeded
- **ICE failure**: Network path lost, no recovery possible
**Evidence**: Data channel shows "open" but `recv` stuck, `lastRecv` increasing.
### Category C: Code Bugs (Fixable)
Issues in our implementation:
- **Detection too slow**: Timeouts too long, misses too many
- **Recovery not triggered**: State checks wrong, reconnection not started
- **Resource leaks**: Timers not cancelled, connections not cleaned up
**Evidence**: Logs show problem but no action taken, or wrong action taken.
---
## Debugging Checklist
When investigating a disconnect:
1. **Find the last successful response**:
```
grep "WebRTC API → WS" logs.txt | head -5
```
2. **Check health check progression**:
```
grep "Health check" logs.txt
```
Look for `recv` getting stuck while `sent` increases.
3. **Check for signaling issues**:
```
grep -i "signaling" logs.txt
```
4. **Check ICE state changes**:
```
grep "ICE connection state" logs.txt
```
5. **Check for keepalive detection**:
```
grep -i "keepalive" logs.txt
```
6. **Check buffer status**:
```
grep -E "(buffer|queue|paused)" logs.txt
```
---
## Potential Future Improvements
### 1. Server-Side Backpressure
MA protocol doesn't have explicit backpressure. Could add:
- Client-side request throttling
- Application-level ACKs for critical messages
### 2. More Aggressive Detection
- Track recv count across health checks, detect stagnation
- Shorter keepalive interval (5s instead of 10s)
### 3. Proactive Health Signals
- Monitor `bufferedAmount` trend (increasing = problem)
- Track round-trip latency of keepalive pings
### 4. Connection Quality Metrics
- Expose packet loss, jitter from WebRTC stats
- Use to predict imminent failure
---
## Test Scenarios
| Scenario | How to Test | Expected Behavior |
|----------|-------------|-------------------|
| Normal usage | Browse library, play music | No warnings |
| Heavy sync | Pull-to-refresh library | Buffer may queue, should drain |
| Long session | Leave connected 2+ hours | Should stay connected |
| Rapid navigation | Quick tab switching | Brief queue usage, recovers |
| Network change | WiFi → Cellular | Should detect and reconnect |
| App background | Background for 5+ min | May disconnect, should reconnect |
---
## Observed Failure Patterns
### Pattern 1: One-Way Death After Library Sync
**Observed**: 2026-01-23 (multiple times)
```
17:13:06.175 - Last response (playlists sync)
17:13:06.264 - Local batch save
-- 5 seconds silence --
17:13:11.005 - Send players/all → NO RESPONSE
17:13:13.914 - ICE state: completed (looks healthy!)
17:13:28 - Keepalive detects timeout
17:13:38 - Reconnection triggered
```
**Characteristics**:
- Often happens after library sync (large batch responses)
- ICE shows "completed" even though data channel is dead
- Signaling ping/pong continues working
- Only data channel receive is affected
**Hypothesis**:
- Server-side SCTP buffer exhausted from sending large responses?
- Client receive window full, server blocks?
- flutter_webrtc onMessage callback stops firing?
### Pattern 2: Reconnection Storm / Signaling Instability
**Observed**: 2026-01-23 17:30-17:31
```
17:31:05.093 - Signaling: Connecting
17:31:05.216 - Signaling: Sending connect-request
17:31:05.218 - Signaling: WebSocket closed (123ms later!)
17:31:05.218 - WebRTC: Signaling disconnected
```
**Characteristics**:
- Signaling WebSocket closes almost immediately after connect
- Multiple rapid reconnection attempts (storm)
- Connection never stable long enough for data flow
- Often happens after network instability
- Artist detail, albums etc don't load because connection keeps failing
**Possible Causes**:
- Network instability (WiFi/cellular issues)
- Signaling server rate limiting
- DNS resolution failures
- Server rejecting rapid reconnection attempts
**Side Effects**:
- UI shows errors like "Not connected to Music Assistant server"
- Artist/album detail screens fail to load
- Players list empty or stale
### Pattern 3: API Channel Dies While Sendspin Lives
**Observed**: 2026-01-23 17:53
```
17:53:35.755 - Send album_tracks request → NO RESPONSE
17:53:40.596 - Health check: recv stuck at 197, sent increased
17:53:55.204 - Sendspin channel RECEIVES server/time response!
17:54:02.790 - Keepalive detects dead API channel
```
**Characteristics**:
- API data channel (ma-api) stops receiving
- Sendspin data channel continues working normally
- Same WebRTC peer connection, different data channels
- This proves it's NOT a network issue - Sendspin traffic flows fine
**Hypothesis**:
- flutter_webrtc bug where `onMessage` callback stops firing for one channel
- Possible memory/GC issue with the callback
- Server-side might be sending to wrong channel?
**Key Evidence**:
- Recv count stays at 197 for API
- Sendspin recv count increases (7→8)
- Both channels use same peer connection
---
## Version History
| Date | Build | Changes |
|------|-------|---------|
| 2026-01-23 | ~v49 | Initial flow control implementation |
| 2026-01-23 | ~v49 | Reduced keepalive timeout 30→20s, misses 3→2 |
| 2026-01-23 | ~v49 | Reduced health check stale threshold 45→30s |
| 2026-01-23 | ~v49 | Fixed excessive "queue drained" logging |
| 2026-01-23 | ~v49 | Created WEBRTC_DISCONNECT_FIXES.md documentation |
| 2026-01-23 | ~v49 | Added recv stagnation detection (20s detection) |
| 2026-01-23 | ~v49 | Documented Pattern 2: Reconnection storm / signaling instability |
| 2026-01-23 | ~v49 | Fixed race condition in _scheduleReconnect (multiple simultaneous calls) |
| 2026-01-23 | ~v49 | Added WebSocket close code/reason logging to signaling |
| 2026-01-23 | ~v49 | **CRITICAL FIX**: _reconnectWebRTC() was returning immediately due to _isReconnecting already being true |
| 2026-01-23 | ~v49 | Fixed old WebRTC callbacks firing during reconnection cleanup (clear callbacks before disconnect) |
| 2026-01-23 | ~v49 | Fixed onStateChanged handler calling _handleWebRTCDisconnection during active reconnection |
---
## References
- flutter_webrtc Issue #843: Data channel state unreliable
- aiortc Issue #1156: TURN + DataChannel bugs
- WebRTC SCTP: Max buffer is 16MB, recommended threshold 1MB
- Music Assistant remote protocol: No explicit flow control
-118
View File
@@ -1,118 +0,0 @@
# WebRTC Remote Connection Progress
## Problem Statement
The flutter_webrtc library has reliability issues causing data channel failures during remote connections via Nabu Casa. The ma-api data channel becomes one-way (can send, but receives stop) after some time, while the sendspin channel continues working.
## Approaches Tried
### 1. Original Implementation (flutter_webrtc)
- **Status**: Fails
- **Issue**: ma-api data channel goes one-way after some time
- **Files**: `lib/services/remote/webrtc_connection.dart`
### 2. WebView-based WebRTC Implementation
- **Status**: Same failure mode
- **Hypothesis**: Use browser-native WebRTC via InAppWebView, since MA Web UI works
- **Result**: Same one-way channel death occurs
- **Key Finding**: This proves the issue is NOT specific to flutter_webrtc
## Key Observations
From the logs:
```
lastRecv:26s ago <- No messages received in 26 seconds
API sent:674 recv:618 <- Gap growing, responses stopped
```
- Signaling ping/pong continues working
- Sendspin channel continues receiving (`server/time` responses)
- Only ma-api channel becomes one-way
- WebRTC peer connection itself remains "connected"
## Differences from Working MA Web UI
The MA Web UI runs in a **visible, active browser tab**. Our implementations:
1. flutter_webrtc: Native WebRTC in background app
2. WebView: Browser WebRTC in **hidden 1x1 pixel WebView**
Android aggressively throttles hidden WebViews to save battery.
## Current Implementation (WebView)
### Files Created
| File | Purpose |
|------|---------|
| `lib/services/remote/webview_webrtc_connection.dart` | Dart WebView wrapper |
| `assets/webrtc_engine.js` | JavaScript WebRTC engine |
### Files Modified
| File | Changes |
|------|---------|
| `pubspec.yaml` | Added `flutter_inappwebview: ^6.0.0` |
| `lib/services/remote/remote_bridge.dart` | Uses `WebViewWebRTCConnection` |
| `lib/providers/music_assistant_provider.dart` | Exposes WebView widget |
| `lib/main.dart` | Adds WebView to widget tree |
### Architecture
```
MusicAssistantAPI -> localhost WebSocket -> RemoteBridge
|
WebViewWebRTCConnection
|
Hidden InAppWebView
|
JavaScript WebRTC Engine
|
MA Server via TURN
```
## Current Fixes Being Tested
### 1. Prevent WebView Throttling
- Changed WebView position from off-screen (-10,-10) to on-screen (0,0) with Opacity(0)
- Added various InAppWebViewSettings to prevent background throttling
### 2. Data Channel Settings
- Changed from `ordered: true` to `ordered: false` with `maxRetransmits: 3`
- Hypothesis: Ordered delivery may cause buffer bloat issues
### 3. Buffer Monitoring
- Added `bufferedAmount` checking in JavaScript
- Logs warning when buffer exceeds 1MB
- Added `getChannelHealth()` function for diagnostics
### 4. Reduced Logging
- Removed per-message logging (was filling logs too fast)
- Keep only state changes, errors, and warnings
## Experiments Completed
### 5. Server-Created Channels (Failed)
- **Hypothesis**: Let server create data channels to avoid ID collisions
- **Result**: Connection timeout - server expects CLIENT to create channels
- **Learning**: Client must create channels before sending offer
## Next Steps to Try
1. **Make WebView actually visible** - Android may still throttle Opacity(0) WebView
2. **Single data channel** - Use only ma-api, disable sendspin
3. **Periodic channel recreation** - Detect stale channel and recreate
4. **Report to MA team** - Browser WebRTC also fails, suggesting server-side issue
## Test APK Location
`/home/chris/Ensemble/ensemble-webview-webrtc.apk`
## Useful Debug Commands
Check channel health (in JS console or via Dart):
```javascript
getChannelHealth()
// Returns: { peerConnection, iceConnection, apiChannel, apiBuffered, sendspinChannel, sendspinBuffered, signalingConnected }
```
## Open Questions
1. Why does sendspin channel survive when ma-api dies?
2. Is there a difference in how MA server handles the two channels?
3. Does MA Web UI use different data channel settings?
4. Is there a TURN relay issue specific to high-traffic channels?
File diff suppressed because it is too large Load Diff
@@ -1,2 +0,0 @@
New package ID (com.collotsspot.ensemble) - reinstall required.
Prepares for F-Droid and Play Store submission.
@@ -1 +0,0 @@
Add fastlane metadata for F-Droid.
@@ -1 +0,0 @@
Disable dependency metadata in APK for F-Droid compliance.
@@ -1,13 +0,0 @@
Ensemble is an unofficial, community-built mobile client for Music Assistant, providing both local playback and remote control of your music library.
Features:
* Stream music directly to your phone with background playback
* Control any speaker or device connected to Music Assistant
* Multi-room grouping capabilities
* Queue management with drag-to-reorder
* Background playback with media notifications
* Fast ~300ms response for play/pause controls
Ensemble supports both local playback (streaming to your phone) and remote control of any Music Assistant player in your home.
Note: Requires a running Music Assistant server. This is a community project and is not affiliated with the official Music Assistant team.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 909 KiB

@@ -1 +0,0 @@
Unofficial mobile client for Music Assistant
@@ -1 +0,0 @@
Ensemble
-6
View File
@@ -32,12 +32,6 @@ class HeroTags {
/// Podcast title hero tag prefix /// Podcast title hero tag prefix
static const String podcastTitle = 'podcast_title_'; static const String podcastTitle = 'podcast_title_';
/// Podcast episode cover hero tag prefix
static const String podcastEpisodeCover = 'podcast_episode_cover_';
/// Podcast episode title hero tag prefix
static const String podcastEpisodeTitle = 'podcast_episode_title_';
/// Playlist cover hero tag prefix /// Playlist cover hero tag prefix
static const String playlistCover = 'playlist_cover_'; static const String playlistCover = 'playlist_cover_';
-3
View File
@@ -10,7 +10,4 @@ class NetworkConstants {
/// Default HTTP port /// Default HTTP port
static const int defaultHttpPort = 80; static const int defaultHttpPort = 80;
/// Maximum number of pending WebSocket requests to prevent memory leaks
static const int maxPendingRequests = 100;
} }
-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';
}
+8 -115
View File
@@ -78,10 +78,6 @@ class LibraryCache extends Table {
/// Whether this item was deleted on the server /// Whether this item was deleted on the server
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))(); BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
/// Provider instance IDs that provided this item (JSON array)
/// Used for client-side filtering by source provider
TextColumn get sourceProviders => text().withDefault(const Constant('[]'))();
@override @override
Set<Column> get primaryKey => {cacheKey}; Set<Column> get primaryKey => {cacheKey};
} }
@@ -195,7 +191,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override @override
int get schemaVersion => 6; int get schemaVersion => 5;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@@ -230,10 +226,6 @@ class AppDatabase extends _$AppDatabase {
await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player ON cached_queue (player_id)'); await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player ON cached_queue (player_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player_position ON cached_queue (player_id, position)'); await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player_position ON cached_queue (player_id, position)');
} }
// Migration from v5 to v6: Add source_providers column for client-side filtering
if (from < 6) {
await customStatement("ALTER TABLE library_cache ADD COLUMN source_providers TEXT NOT NULL DEFAULT '[]'");
}
}, },
); );
} }
@@ -376,57 +368,20 @@ class AppDatabase extends _$AppDatabase {
// ============================================ // ============================================
/// Cache a library item /// Cache a library item
/// [sourceProvider] - the provider instance ID that provided this item (for filtering)
Future<void> cacheItem({ Future<void> cacheItem({
required String itemType, required String itemType,
required String itemId, required String itemId,
required String data, required String data,
String? sourceProvider,
}) async { }) async {
final cacheKey = '${itemType}_$itemId'; final cacheKey = '${itemType}_$itemId';
// If sourceProvider is specified, we need to merge with existing providers await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
if (sourceProvider != null) { cacheKey: cacheKey,
final existing = await getCachedItem(itemType, itemId); itemType: itemType,
List<String> providers = []; itemId: itemId,
if (existing != null) { data: data,
try { lastSynced: DateTime.now(),
final existingProviders = existing.sourceProviders; ));
if (existingProviders.isNotEmpty && existingProviders != '[]') {
providers = List<String>.from(
(await Future.value(existingProviders))
.replaceAll('[', '')
.replaceAll(']', '')
.replaceAll('"', '')
.split(',')
.where((s) => s.trim().isNotEmpty)
.map((s) => s.trim()),
);
}
} catch (_) {}
}
if (!providers.contains(sourceProvider)) {
providers.add(sourceProvider);
}
final sourceProvidersJson = '[${providers.map((p) => '"$p"').join(',')}]';
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
cacheKey: cacheKey,
itemType: itemType,
itemId: itemId,
data: data,
lastSynced: DateTime.now(),
sourceProviders: Value(sourceProvidersJson),
));
} else {
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
cacheKey: cacheKey,
itemType: itemType,
itemId: itemId,
data: data,
lastSynced: DateTime.now(),
));
}
} }
/// Get cached items by type /// Get cached items by type
@@ -695,66 +650,4 @@ class AppDatabase extends _$AppDatabase {
Future<void> clearSearchHistory() async { Future<void> clearSearchHistory() async {
await delete(searchHistory).go(); await delete(searchHistory).go();
} }
// ============================================
// Batch Cache Operations
// ============================================
/// Batch size for chunked inserts to prevent SQLite limits
static const int _batchChunkSize = 500;
/// Batch cache multiple items efficiently using drift batch API
/// This is much faster than individual inserts for large datasets.
/// Items are chunked into batches of 500 to prevent SQLite variable limits.
///
/// [items] - List of items to cache, each containing:
/// - itemType: 'album', 'artist', 'track', 'playlist', 'audiobook', etc.
/// - itemId: the item's ID
/// - data: JSON string of the item
/// - sourceProviders: list of provider instance IDs
Future<void> batchCacheItems(List<BatchCacheItem> items) async {
if (items.isEmpty) return;
// Process in chunks to avoid SQLite variable limits
for (var i = 0; i < items.length; i += _batchChunkSize) {
final chunk = items.skip(i).take(_batchChunkSize).toList();
await batch((b) {
for (final item in chunk) {
final cacheKey = '${item.itemType}_${item.itemId}';
final sourceProvidersJson = item.sourceProviders.isEmpty
? '[]'
: '[${item.sourceProviders.map((p) => '"$p"').join(',')}]';
b.insert(
libraryCache,
LibraryCacheCompanion.insert(
cacheKey: cacheKey,
itemType: item.itemType,
itemId: item.itemId,
data: item.data,
lastSynced: DateTime.now(),
sourceProviders: Value(sourceProvidersJson),
),
mode: InsertMode.insertOrReplace,
);
}
});
}
}
}
/// Data class for batch cache operations
class BatchCacheItem {
final String itemType;
final String itemId;
final String data;
final List<String> sourceProviders;
const BatchCacheItem({
required this.itemType,
required this.itemId,
required this.data,
this.sourceProviders = const [],
});
} }
+11 -82
View File
@@ -856,24 +856,9 @@ class $LibraryCacheTable extends LibraryCache
defaultConstraints: defaultConstraints:
GeneratedColumn.constraintIsAlways('CHECK ("is_deleted" IN (0, 1))'), GeneratedColumn.constraintIsAlways('CHECK ("is_deleted" IN (0, 1))'),
defaultValue: const Constant(false)); defaultValue: const Constant(false));
static const VerificationMeta _sourceProvidersMeta =
const VerificationMeta('sourceProviders');
@override @override
late final GeneratedColumn<String> sourceProviders = GeneratedColumn<String>( List<GeneratedColumn> get $columns =>
'source_providers', aliasedName, false, [cacheKey, itemType, itemId, data, lastSynced, isDeleted];
type: DriftSqlType.string,
requiredDuringInsert: false,
defaultValue: const Constant('[]'));
@override
List<GeneratedColumn> get $columns => [
cacheKey,
itemType,
itemId,
data,
lastSynced,
isDeleted,
sourceProviders
];
@override @override
String get aliasedName => _alias ?? actualTableName; String get aliasedName => _alias ?? actualTableName;
@override @override
@@ -920,12 +905,6 @@ class $LibraryCacheTable extends LibraryCache
context.handle(_isDeletedMeta, context.handle(_isDeletedMeta,
isDeleted.isAcceptableOrUnknown(data['is_deleted']!, _isDeletedMeta)); isDeleted.isAcceptableOrUnknown(data['is_deleted']!, _isDeletedMeta));
} }
if (data.containsKey('source_providers')) {
context.handle(
_sourceProvidersMeta,
sourceProviders.isAcceptableOrUnknown(
data['source_providers']!, _sourceProvidersMeta));
}
return context; return context;
} }
@@ -947,8 +926,6 @@ class $LibraryCacheTable extends LibraryCache
.read(DriftSqlType.dateTime, data['${effectivePrefix}last_synced'])!, .read(DriftSqlType.dateTime, data['${effectivePrefix}last_synced'])!,
isDeleted: attachedDatabase.typeMapping isDeleted: attachedDatabase.typeMapping
.read(DriftSqlType.bool, data['${effectivePrefix}is_deleted'])!, .read(DriftSqlType.bool, data['${effectivePrefix}is_deleted'])!,
sourceProviders: attachedDatabase.typeMapping.read(
DriftSqlType.string, data['${effectivePrefix}source_providers'])!,
); );
} }
@@ -977,18 +954,13 @@ class LibraryCacheData extends DataClass
/// Whether this item was deleted on the server /// Whether this item was deleted on the server
final bool isDeleted; final bool isDeleted;
/// Provider instance IDs that provided this item (JSON array)
/// Used for client-side filtering by source provider
final String sourceProviders;
const LibraryCacheData( const LibraryCacheData(
{required this.cacheKey, {required this.cacheKey,
required this.itemType, required this.itemType,
required this.itemId, required this.itemId,
required this.data, required this.data,
required this.lastSynced, required this.lastSynced,
required this.isDeleted, required this.isDeleted});
required this.sourceProviders});
@override @override
Map<String, Expression> toColumns(bool nullToAbsent) { Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{}; final map = <String, Expression>{};
@@ -998,7 +970,6 @@ class LibraryCacheData extends DataClass
map['data'] = Variable<String>(data); map['data'] = Variable<String>(data);
map['last_synced'] = Variable<DateTime>(lastSynced); map['last_synced'] = Variable<DateTime>(lastSynced);
map['is_deleted'] = Variable<bool>(isDeleted); map['is_deleted'] = Variable<bool>(isDeleted);
map['source_providers'] = Variable<String>(sourceProviders);
return map; return map;
} }
@@ -1010,7 +981,6 @@ class LibraryCacheData extends DataClass
data: Value(data), data: Value(data),
lastSynced: Value(lastSynced), lastSynced: Value(lastSynced),
isDeleted: Value(isDeleted), isDeleted: Value(isDeleted),
sourceProviders: Value(sourceProviders),
); );
} }
@@ -1024,7 +994,6 @@ class LibraryCacheData extends DataClass
data: serializer.fromJson<String>(json['data']), data: serializer.fromJson<String>(json['data']),
lastSynced: serializer.fromJson<DateTime>(json['lastSynced']), lastSynced: serializer.fromJson<DateTime>(json['lastSynced']),
isDeleted: serializer.fromJson<bool>(json['isDeleted']), isDeleted: serializer.fromJson<bool>(json['isDeleted']),
sourceProviders: serializer.fromJson<String>(json['sourceProviders']),
); );
} }
@override @override
@@ -1037,7 +1006,6 @@ class LibraryCacheData extends DataClass
'data': serializer.toJson<String>(data), 'data': serializer.toJson<String>(data),
'lastSynced': serializer.toJson<DateTime>(lastSynced), 'lastSynced': serializer.toJson<DateTime>(lastSynced),
'isDeleted': serializer.toJson<bool>(isDeleted), 'isDeleted': serializer.toJson<bool>(isDeleted),
'sourceProviders': serializer.toJson<String>(sourceProviders),
}; };
} }
@@ -1047,8 +1015,7 @@ class LibraryCacheData extends DataClass
String? itemId, String? itemId,
String? data, String? data,
DateTime? lastSynced, DateTime? lastSynced,
bool? isDeleted, bool? isDeleted}) =>
String? sourceProviders}) =>
LibraryCacheData( LibraryCacheData(
cacheKey: cacheKey ?? this.cacheKey, cacheKey: cacheKey ?? this.cacheKey,
itemType: itemType ?? this.itemType, itemType: itemType ?? this.itemType,
@@ -1056,7 +1023,6 @@ class LibraryCacheData extends DataClass
data: data ?? this.data, data: data ?? this.data,
lastSynced: lastSynced ?? this.lastSynced, lastSynced: lastSynced ?? this.lastSynced,
isDeleted: isDeleted ?? this.isDeleted, isDeleted: isDeleted ?? this.isDeleted,
sourceProviders: sourceProviders ?? this.sourceProviders,
); );
LibraryCacheData copyWithCompanion(LibraryCacheCompanion data) { LibraryCacheData copyWithCompanion(LibraryCacheCompanion data) {
return LibraryCacheData( return LibraryCacheData(
@@ -1067,9 +1033,6 @@ class LibraryCacheData extends DataClass
lastSynced: lastSynced:
data.lastSynced.present ? data.lastSynced.value : this.lastSynced, data.lastSynced.present ? data.lastSynced.value : this.lastSynced,
isDeleted: data.isDeleted.present ? data.isDeleted.value : this.isDeleted, isDeleted: data.isDeleted.present ? data.isDeleted.value : this.isDeleted,
sourceProviders: data.sourceProviders.present
? data.sourceProviders.value
: this.sourceProviders,
); );
} }
@@ -1081,15 +1044,14 @@ class LibraryCacheData extends DataClass
..write('itemId: $itemId, ') ..write('itemId: $itemId, ')
..write('data: $data, ') ..write('data: $data, ')
..write('lastSynced: $lastSynced, ') ..write('lastSynced: $lastSynced, ')
..write('isDeleted: $isDeleted, ') ..write('isDeleted: $isDeleted')
..write('sourceProviders: $sourceProviders')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@override @override
int get hashCode => Object.hash( int get hashCode =>
cacheKey, itemType, itemId, data, lastSynced, isDeleted, sourceProviders); Object.hash(cacheKey, itemType, itemId, data, lastSynced, isDeleted);
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
@@ -1099,8 +1061,7 @@ class LibraryCacheData extends DataClass
other.itemId == this.itemId && other.itemId == this.itemId &&
other.data == this.data && other.data == this.data &&
other.lastSynced == this.lastSynced && other.lastSynced == this.lastSynced &&
other.isDeleted == this.isDeleted && other.isDeleted == this.isDeleted);
other.sourceProviders == this.sourceProviders);
} }
class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> { class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
@@ -1110,7 +1071,6 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
final Value<String> data; final Value<String> data;
final Value<DateTime> lastSynced; final Value<DateTime> lastSynced;
final Value<bool> isDeleted; final Value<bool> isDeleted;
final Value<String> sourceProviders;
final Value<int> rowid; final Value<int> rowid;
const LibraryCacheCompanion({ const LibraryCacheCompanion({
this.cacheKey = const Value.absent(), this.cacheKey = const Value.absent(),
@@ -1119,7 +1079,6 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
this.data = const Value.absent(), this.data = const Value.absent(),
this.lastSynced = const Value.absent(), this.lastSynced = const Value.absent(),
this.isDeleted = const Value.absent(), this.isDeleted = const Value.absent(),
this.sourceProviders = const Value.absent(),
this.rowid = const Value.absent(), this.rowid = const Value.absent(),
}); });
LibraryCacheCompanion.insert({ LibraryCacheCompanion.insert({
@@ -1129,7 +1088,6 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
required String data, required String data,
required DateTime lastSynced, required DateTime lastSynced,
this.isDeleted = const Value.absent(), this.isDeleted = const Value.absent(),
this.sourceProviders = const Value.absent(),
this.rowid = const Value.absent(), this.rowid = const Value.absent(),
}) : cacheKey = Value(cacheKey), }) : cacheKey = Value(cacheKey),
itemType = Value(itemType), itemType = Value(itemType),
@@ -1143,7 +1101,6 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
Expression<String>? data, Expression<String>? data,
Expression<DateTime>? lastSynced, Expression<DateTime>? lastSynced,
Expression<bool>? isDeleted, Expression<bool>? isDeleted,
Expression<String>? sourceProviders,
Expression<int>? rowid, Expression<int>? rowid,
}) { }) {
return RawValuesInsertable({ return RawValuesInsertable({
@@ -1153,7 +1110,6 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
if (data != null) 'data': data, if (data != null) 'data': data,
if (lastSynced != null) 'last_synced': lastSynced, if (lastSynced != null) 'last_synced': lastSynced,
if (isDeleted != null) 'is_deleted': isDeleted, if (isDeleted != null) 'is_deleted': isDeleted,
if (sourceProviders != null) 'source_providers': sourceProviders,
if (rowid != null) 'rowid': rowid, if (rowid != null) 'rowid': rowid,
}); });
} }
@@ -1165,7 +1121,6 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
Value<String>? data, Value<String>? data,
Value<DateTime>? lastSynced, Value<DateTime>? lastSynced,
Value<bool>? isDeleted, Value<bool>? isDeleted,
Value<String>? sourceProviders,
Value<int>? rowid}) { Value<int>? rowid}) {
return LibraryCacheCompanion( return LibraryCacheCompanion(
cacheKey: cacheKey ?? this.cacheKey, cacheKey: cacheKey ?? this.cacheKey,
@@ -1174,7 +1129,6 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
data: data ?? this.data, data: data ?? this.data,
lastSynced: lastSynced ?? this.lastSynced, lastSynced: lastSynced ?? this.lastSynced,
isDeleted: isDeleted ?? this.isDeleted, isDeleted: isDeleted ?? this.isDeleted,
sourceProviders: sourceProviders ?? this.sourceProviders,
rowid: rowid ?? this.rowid, rowid: rowid ?? this.rowid,
); );
} }
@@ -1200,9 +1154,6 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
if (isDeleted.present) { if (isDeleted.present) {
map['is_deleted'] = Variable<bool>(isDeleted.value); map['is_deleted'] = Variable<bool>(isDeleted.value);
} }
if (sourceProviders.present) {
map['source_providers'] = Variable<String>(sourceProviders.value);
}
if (rowid.present) { if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value); map['rowid'] = Variable<int>(rowid.value);
} }
@@ -1218,7 +1169,6 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
..write('data: $data, ') ..write('data: $data, ')
..write('lastSynced: $lastSynced, ') ..write('lastSynced: $lastSynced, ')
..write('isDeleted: $isDeleted, ') ..write('isDeleted: $isDeleted, ')
..write('sourceProviders: $sourceProviders, ')
..write('rowid: $rowid') ..write('rowid: $rowid')
..write(')')) ..write(')'))
.toString(); .toString();
@@ -2996,8 +2946,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 +3139,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 +3204,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(
@@ -3561,7 +3507,6 @@ typedef $$LibraryCacheTableCreateCompanionBuilder = LibraryCacheCompanion
required String data, required String data,
required DateTime lastSynced, required DateTime lastSynced,
Value<bool> isDeleted, Value<bool> isDeleted,
Value<String> sourceProviders,
Value<int> rowid, Value<int> rowid,
}); });
typedef $$LibraryCacheTableUpdateCompanionBuilder = LibraryCacheCompanion typedef $$LibraryCacheTableUpdateCompanionBuilder = LibraryCacheCompanion
@@ -3572,7 +3517,6 @@ typedef $$LibraryCacheTableUpdateCompanionBuilder = LibraryCacheCompanion
Value<String> data, Value<String> data,
Value<DateTime> lastSynced, Value<DateTime> lastSynced,
Value<bool> isDeleted, Value<bool> isDeleted,
Value<String> sourceProviders,
Value<int> rowid, Value<int> rowid,
}); });
@@ -3602,10 +3546,6 @@ class $$LibraryCacheTableFilterComposer
ColumnFilters<bool> get isDeleted => $composableBuilder( ColumnFilters<bool> get isDeleted => $composableBuilder(
column: $table.isDeleted, builder: (column) => ColumnFilters(column)); column: $table.isDeleted, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get sourceProviders => $composableBuilder(
column: $table.sourceProviders,
builder: (column) => ColumnFilters(column));
} }
class $$LibraryCacheTableOrderingComposer class $$LibraryCacheTableOrderingComposer
@@ -3634,10 +3574,6 @@ class $$LibraryCacheTableOrderingComposer
ColumnOrderings<bool> get isDeleted => $composableBuilder( ColumnOrderings<bool> get isDeleted => $composableBuilder(
column: $table.isDeleted, builder: (column) => ColumnOrderings(column)); column: $table.isDeleted, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get sourceProviders => $composableBuilder(
column: $table.sourceProviders,
builder: (column) => ColumnOrderings(column));
} }
class $$LibraryCacheTableAnnotationComposer class $$LibraryCacheTableAnnotationComposer
@@ -3666,9 +3602,6 @@ class $$LibraryCacheTableAnnotationComposer
GeneratedColumn<bool> get isDeleted => GeneratedColumn<bool> get isDeleted =>
$composableBuilder(column: $table.isDeleted, builder: (column) => column); $composableBuilder(column: $table.isDeleted, builder: (column) => column);
GeneratedColumn<String> get sourceProviders => $composableBuilder(
column: $table.sourceProviders, builder: (column) => column);
} }
class $$LibraryCacheTableTableManager extends RootTableManager< class $$LibraryCacheTableTableManager extends RootTableManager<
@@ -3703,7 +3636,6 @@ class $$LibraryCacheTableTableManager extends RootTableManager<
Value<String> data = const Value.absent(), Value<String> data = const Value.absent(),
Value<DateTime> lastSynced = const Value.absent(), Value<DateTime> lastSynced = const Value.absent(),
Value<bool> isDeleted = const Value.absent(), Value<bool> isDeleted = const Value.absent(),
Value<String> sourceProviders = const Value.absent(),
Value<int> rowid = const Value.absent(), Value<int> rowid = const Value.absent(),
}) => }) =>
LibraryCacheCompanion( LibraryCacheCompanion(
@@ -3713,7 +3645,6 @@ class $$LibraryCacheTableTableManager extends RootTableManager<
data: data, data: data,
lastSynced: lastSynced, lastSynced: lastSynced,
isDeleted: isDeleted, isDeleted: isDeleted,
sourceProviders: sourceProviders,
rowid: rowid, rowid: rowid,
), ),
createCompanionCallback: ({ createCompanionCallback: ({
@@ -3723,7 +3654,6 @@ class $$LibraryCacheTableTableManager extends RootTableManager<
required String data, required String data,
required DateTime lastSynced, required DateTime lastSynced,
Value<bool> isDeleted = const Value.absent(), Value<bool> isDeleted = const Value.absent(),
Value<String> sourceProviders = const Value.absent(),
Value<int> rowid = const Value.absent(), Value<int> rowid = const Value.absent(),
}) => }) =>
LibraryCacheCompanion.insert( LibraryCacheCompanion.insert(
@@ -3733,7 +3663,6 @@ class $$LibraryCacheTableTableManager extends RootTableManager<
data: data, data: data,
lastSynced: lastSynced, lastSynced: lastSynced,
isDeleted: isDeleted, isDeleted: isDeleted,
sourceProviders: sourceProviders,
rowid: rowid, rowid: rowid,
), ),
withReferenceMapper: (p0) => p0 withReferenceMapper: (p0) => p0
+1 -11
View File
@@ -295,15 +295,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"
} }
+2 -339
View File
@@ -51,7 +51,7 @@
"description": "Hint for port field" "description": "Hint for port field"
}, },
"portDescription": "Leave blank unless using a custom port", "portDescription": "Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.",
"@portDescription": { "@portDescription": {
"description": "Description for port field" "description": "Description for port field"
}, },
@@ -221,36 +221,6 @@
"description": "Home screen section title" "description": "Home screen section title"
}, },
"discoveryMixes": "Discovery Mixes",
"@discoveryMixes": {
"description": "Provider discovery mixes (e.g., Tidal playlists)"
},
"discoveryMixesDescription": "Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen",
"@discoveryMixesDescription": {
"description": "Description for discovery mixes setting"
},
"discoveryRows": "Discovery rows from Music Assistant",
"@discoveryRows": {
"description": "Label for discovery rows section"
},
"refreshDiscoveryRows": "Refresh available rows",
"@refreshDiscoveryRows": {
"description": "Button to refresh discovery rows"
},
"noDiscoveryRows": "No discovery rows available",
"@noDiscoveryRows": {
"description": "Empty state when no discovery rows available"
},
"discoveryRowBadge": "Music Assistant",
"@discoveryRowBadge": {
"description": "Badge label for discovery rows"
},
"continueListening": "Continue Listening", "continueListening": "Continue Listening",
"@continueListening": { "@continueListening": {
"description": "Home screen section title" "description": "Home screen section title"
@@ -634,11 +604,6 @@
"tracksAddedToQueue": "Tracks added to queue", "tracksAddedToQueue": "Tracks added to queue",
"@tracksAddedToQueue": { "@tracksAddedToQueue": {
"description": "Snackbar message for tracks added to queue"
},
"trackAddedToQueue": "Track added to queue",
"@trackAddedToQueue": {
"description": "Confirmation when tracks added" "description": "Confirmation when tracks added"
}, },
@@ -1104,10 +1069,6 @@
"sortAlphabetically": "Sort alphabetically", "sortAlphabetically": "Sort alphabetically",
"sortByYear": "Sort by year", "sortByYear": "Sort by year",
"sortBySeriesOrder": "Sort by series order", "sortBySeriesOrder": "Sort by series order",
"sortByDuration": "Sort by duration",
"newestFirst": "Newest first",
"oldestFirst": "Oldest first",
"viewDetails": "View details",
"listView": "List view", "listView": "List view",
"gridView": "Grid view", "gridView": "Grid view",
"noBooksInSeries": "No books found in this series", "noBooksInSeries": "No books found in this series",
@@ -1126,12 +1087,11 @@
"showFavoritePodcasts": "Show a row of your favorite podcasts", "showFavoritePodcasts": "Show a row of your favorite podcasts",
"extractColorsFromArtwork": "Extract colors from album and artist artwork", "extractColorsFromArtwork": "Extract colors from album and artist artwork",
"chooseHomeScreenRows": "Choose which rows to display on the home screen", "chooseHomeScreenRows": "Choose which rows to display on the home screen",
"favorite": "Favorite",
"addedToFavorites": "Added to favorites", "addedToFavorites": "Added to favorites",
"removedFromFavorites": "Removed from favorites", "removedFromFavorites": "Removed from favorites",
"addedToLibrary": "Added to library", "addedToLibrary": "Added to library",
"removedFromLibrary": "Removed from library", "removedFromLibrary": "Removed from library",
"addToLibrary": "Add", "addToLibrary": "Add to library",
"unknown": "Unknown", "unknown": "Unknown",
"noUpcomingTracks": "No upcoming tracks", "noUpcomingTracks": "No upcoming tracks",
"showAll": "Show all", "showAll": "Show all",
@@ -1180,10 +1140,6 @@
"audiobookLibraries": "Audiobook Libraries", "audiobookLibraries": "Audiobook Libraries",
"chooseAudiobookLibraries": "Choose which Audiobookshelf libraries to include", "chooseAudiobookLibraries": "Choose which Audiobookshelf libraries to include",
"unknownLibrary": "Unknown Library", "unknownLibrary": "Unknown Library",
"musicProviders": "Music Providers",
"musicProvidersDescription": "Choose which accounts to show in your library",
"libraryRefreshing": "Refreshing library...",
"cannotDisableLastProvider": "Cannot disable the last provider",
"noSeriesFound": "No Series Found", "noSeriesFound": "No Series Found",
"ensembleBugReport": "Ensemble Bug Report", "ensembleBugReport": "Ensemble Bug Report",
"byOwner": "By {owner}", "byOwner": "By {owner}",
@@ -1247,21 +1203,11 @@
"description": "Bottom sheet title for adding album to queue" "description": "Bottom sheet title for adding album to queue"
}, },
"addToQueue": "Add to queue",
"@addToQueue": {
"description": "Add item to queue on selected player"
},
"addToQueueOn": "Add to queue on...", "addToQueueOn": "Add to queue on...",
"@addToQueueOn": { "@addToQueueOn": {
"description": "Bottom sheet title for adding to queue" "description": "Bottom sheet title for adding to queue"
}, },
"startRadio": "Start radio",
"@startRadio": {
"description": "Start radio on selected player"
},
"startRadioOn": "Start {name} radio on...", "startRadioOn": "Start {name} radio on...",
"@startRadioOn": { "@startRadioOn": {
"description": "Bottom sheet title for starting radio", "description": "Bottom sheet title for starting radio",
@@ -1342,16 +1288,6 @@
"description": "Description for smart sort players setting" "description": "Description for smart sort players setting"
}, },
"disableAutoSwitch": "Disable Auto-Switch",
"@disableAutoSwitch": {
"description": "Setting to disable automatic player switching"
},
"disableAutoSwitchDescription": "Never automatically switch to a different player",
"@disableAutoSwitchDescription": {
"description": "Description for disable auto-switch setting"
},
"playerStateUnavailable": "Unavailable", "playerStateUnavailable": "Unavailable",
"@playerStateUnavailable": { "@playerStateUnavailable": {
"description": "Player state when unavailable" "description": "Player state when unavailable"
@@ -1407,31 +1343,6 @@
"description": "Description for show hints toggle" "description": "Description for show hints toggle"
}, },
"volumePrecisionMode": "Volume Precision Mode",
"@volumePrecisionMode": {
"description": "Setting for fine volume control"
},
"volumePrecisionModeDescription": "Hold still while adjusting volume for fine control",
"@volumePrecisionModeDescription": {
"description": "Description for volume precision mode setting"
},
"display": "Display",
"@display": {
"description": "Settings section title for display options"
},
"showProviderIcons": "Show Provider Icons",
"@showProviderIcons": {
"description": "Toggle label for showing provider icons on album art"
},
"showProviderIconsDescription": "Display provider logos on album art",
"@showProviderIconsDescription": {
"description": "Description for show provider icons toggle"
},
"pullToSelectPlayers": "Pull to select players", "pullToSelectPlayers": "Pull to select players",
"@pullToSelectPlayers": { "@pullToSelectPlayers": {
"description": "Hint shown when mini player bounces" "description": "Hint shown when mini player bounces"
@@ -1520,253 +1431,5 @@
"longPressPodcastHint": "Long-press a podcast and tap the heart to add it to favorites", "longPressPodcastHint": "Long-press a podcast and tap the heart to add it to favorites",
"@longPressPodcastHint": { "@longPressPodcastHint": {
"description": "Hint for favoriting podcasts" "description": "Hint for favoriting podcasts"
},
"failedToPlayRadioStation": "Failed to play radio station: {error}",
"@failedToPlayRadioStation": {
"description": "Error when playing radio station fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"itemAlreadyInLibrary": "Item is already in library",
"@itemAlreadyInLibrary": {
"description": "Message when trying to add item already in library"
},
"failedToAddToLibrary": "Failed to add to library",
"@failedToAddToLibrary": {
"description": "Error when adding to library fails"
},
"cannotFindLibraryId": "Cannot find library ID for removal",
"@cannotFindLibraryId": {
"description": "Error when library ID not found for removal"
},
"failedToRemoveFromLibrary": "Failed to remove from library",
"@failedToRemoveFromLibrary": {
"description": "Error when removing from library fails"
},
"failedToPlayAlbum": "Failed to play album: {error}",
"@failedToPlayAlbum": {
"description": "Error when playing album fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"playingOnPlayer": "Playing {name} on {playerName}",
"@playingOnPlayer": {
"description": "Status when playing item on specific player",
"placeholders": {
"name": {
"type": "String"
},
"playerName": {
"type": "String"
}
}
},
"failedToPlayPlaylist": "Failed to play playlist: {error}",
"@failedToPlayPlaylist": {
"description": "Error when playing playlist fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"failedToPlayAudiobook": "Failed to play audiobook: {error}",
"@failedToPlayAudiobook": {
"description": "Error when playing audiobook fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"noOtherPlayersAvailable": "No other players available",
"@noOtherPlayersAvailable": {
"description": "Message when no other players available for transfer"
},
"transferQueue": "Transfer queue",
"@transferQueue": {
"description": "Tooltip for transfer queue button"
},
"clearQueue": "Clear queue",
"@clearQueue": {
"description": "Tooltip for clear queue button"
},
"syncingPlayer": "Syncing {playerName}...",
"@syncingPlayer": {
"description": "Status when syncing player",
"placeholders": {
"playerName": {
"type": "String"
}
}
},
"switchPlayer": "Switch Player",
"@switchPlayer": {
"description": "Label for switch player action"
},
"logsCopiedToClipboard": "Logs copied to clipboard",
"@logsCopiedToClipboard": {
"description": "Confirmation when logs copied"
},
"auto": "Auto",
"@auto": {
"description": "Auto option for language selection"
},
"transferQueueTo": "Transfer Queue To",
"@transferQueueTo": {
"description": "Title for transfer queue dialog"
},
"queueTransferredTo": "Queue transferred to {playerName}",
"@queueTransferredTo": {
"description": "Success message when queue transferred",
"placeholders": {
"playerName": {
"type": "String"
}
}
},
"failedToTransferQueue": "Failed to transfer queue: {error}",
"@failedToTransferQueue": {
"description": "Error when queue transfer fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"failedToLoadQueue": "Failed to load queue: {error}",
"@failedToLoadQueue": {
"description": "Error when queue loading fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"transferTo": "Transfer to...",
"@transferTo": {
"description": "Label for transfer destination selection"
},
"transferQueueToPlayer": "Transfer queue to another player",
"@transferQueueToPlayer": {
"description": "Tooltip for transfer queue button"
},
"selectPlayerToPlay": "Select a player",
"@selectPlayerToPlay": {
"description": "Generic hint for selecting a player in player selector"
},
"selectPlayerToPlayAlbum": "Select player to play album",
"@selectPlayerToPlayAlbum": {
"description": "Hint for selecting player to play album"
},
"selectPlayerToPlayPlaylist": "Select player to play playlist",
"@selectPlayerToPlayPlaylist": {
"description": "Hint for selecting player to play playlist"
},
"selectPlayerForRadio": "Select player for radio",
"@selectPlayerForRadio": {
"description": "Hint for selecting player to start radio"
},
"selectPlayerForEpisode": "Select player for episode",
"@selectPlayerForEpisode": {
"description": "Hint for selecting player to play episode"
},
"selectPlayerForAudiobook": "Select player for audiobook",
"@selectPlayerForAudiobook": {
"description": "Hint for selecting player to play audiobook"
},
"selectPlayerToAddToQueue": "Select player to add to queue",
"@selectPlayerToAddToQueue": {
"description": "Hint for selecting player to add to queue"
},
"aboutAndSupport": "About & Support",
"@aboutAndSupport": {
"description": "About and support section title"
},
"supportDevelopment": "Support Development",
"@supportDevelopment": {
"description": "Support development section"
},
"ensembleIsUnofficial": "Ensemble is an unofficial, community-built client. Consider supporting its development.",
"@ensembleIsUnofficial": {
"description": "Description about Ensemble being unofficial"
},
"sponsorOnGitHub": "Sponsor on GitHub",
"@sponsorOnGitHub": {
"description": "GitHub Sponsors button label"
},
"buyMeAKoFi": "Buy me a Ko-fi",
"@buyMeAKoFi": {
"description": "Ko-fi donation button label"
},
"aboutEnsemble": "About Ensemble",
"@aboutEnsemble": {
"description": "About Ensemble section"
},
"version": "Version",
"@version": {
"description": "Version label"
},
"visitGitHubRepo": "Visit GitHub Repository",
"@visitGitHubRepo": {
"description": "Link to GitHub repo"
},
"reportABug": "Report a Bug",
"@reportABug": {
"description": "Link to report issues"
},
"noRowsEnabled": "No rows enabled",
"@noRowsEnabled": {
"description": "Empty state when no home screen rows are enabled"
},
"pullToRefreshHint": "Pull down to refresh or enable rows in settings",
"@pullToRefreshHint": {
"description": "Hint when home screen is empty"
} }
} }
+1 -11
View File
@@ -491,15 +491,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"
} }
+1 -11
View File
@@ -1396,15 +1396,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"
} }
+17 -411
View File
@@ -64,8 +64,7 @@ import 'app_localizations_fr.dart';
/// be consistent with the languages listed in the S.supportedLocales /// be consistent with the languages listed in the S.supportedLocales
/// property. /// property.
abstract class S { abstract class S {
S(String locale) S(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString());
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName; final String localeName;
@@ -85,8 +84,7 @@ abstract class S {
/// Additional delegates can be added by appending to this list in /// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list /// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required. /// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[
<LocalizationsDelegate<dynamic>>[
delegate, delegate,
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
@@ -164,7 +162,7 @@ abstract class S {
/// Description for port field /// Description for port field
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Leave blank unless using a custom port'** /// **'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.'**
String get portDescription; String get portDescription;
/// Username field label /// Username field label
@@ -359,42 +357,6 @@ abstract class S {
/// **'Discover Albums'** /// **'Discover Albums'**
String get discoverAlbums; String get discoverAlbums;
/// Provider discovery mixes (e.g., Tidal playlists)
///
/// In en, this message translates to:
/// **'Discovery Mixes'**
String get discoveryMixes;
/// Description for discovery mixes setting
///
/// In en, this message translates to:
/// **'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen'**
String get discoveryMixesDescription;
/// Label for discovery rows section
///
/// In en, this message translates to:
/// **'Discovery rows from Music Assistant'**
String get discoveryRows;
/// Button to refresh discovery rows
///
/// In en, this message translates to:
/// **'Refresh available rows'**
String get refreshDiscoveryRows;
/// Empty state when no discovery rows available
///
/// In en, this message translates to:
/// **'No discovery rows available'**
String get noDiscoveryRows;
/// Badge label for discovery rows
///
/// In en, this message translates to:
/// **'Music Assistant'**
String get discoveryRowBadge;
/// Home screen section title /// Home screen section title
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -797,18 +759,12 @@ abstract class S {
/// **'Album added to queue'** /// **'Album added to queue'**
String get albumAddedToQueue; String get albumAddedToQueue;
/// Snackbar message for tracks added to queue /// Confirmation when tracks added
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Tracks added to queue'** /// **'Tracks added to queue'**
String get tracksAddedToQueue; String get tracksAddedToQueue;
/// Confirmation when tracks added
///
/// In en, this message translates to:
/// **'Track added to queue'**
String get trackAddedToQueue;
/// Play button label /// Play button label
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1271,30 +1227,6 @@ abstract class S {
/// **'Sort by series order'** /// **'Sort by series order'**
String get sortBySeriesOrder; String get sortBySeriesOrder;
/// No description provided for @sortByDuration.
///
/// In en, this message translates to:
/// **'Sort by duration'**
String get sortByDuration;
/// No description provided for @newestFirst.
///
/// In en, this message translates to:
/// **'Newest first'**
String get newestFirst;
/// No description provided for @oldestFirst.
///
/// In en, this message translates to:
/// **'Oldest first'**
String get oldestFirst;
/// No description provided for @viewDetails.
///
/// In en, this message translates to:
/// **'View details'**
String get viewDetails;
/// No description provided for @listView. /// No description provided for @listView.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1403,12 +1335,6 @@ abstract class S {
/// **'Choose which rows to display on the home screen'** /// **'Choose which rows to display on the home screen'**
String get chooseHomeScreenRows; String get chooseHomeScreenRows;
/// No description provided for @favorite.
///
/// In en, this message translates to:
/// **'Favorite'**
String get favorite;
/// No description provided for @addedToFavorites. /// No description provided for @addedToFavorites.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1436,7 +1362,7 @@ abstract class S {
/// No description provided for @addToLibrary. /// No description provided for @addToLibrary.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Add'** /// **'Add to library'**
String get addToLibrary; String get addToLibrary;
/// No description provided for @unknown. /// No description provided for @unknown.
@@ -1637,30 +1563,6 @@ abstract class S {
/// **'Unknown Library'** /// **'Unknown Library'**
String get unknownLibrary; String get unknownLibrary;
/// No description provided for @musicProviders.
///
/// In en, this message translates to:
/// **'Music Providers'**
String get musicProviders;
/// No description provided for @musicProvidersDescription.
///
/// In en, this message translates to:
/// **'Choose which accounts to show in your library'**
String get musicProvidersDescription;
/// No description provided for @libraryRefreshing.
///
/// In en, this message translates to:
/// **'Refreshing library...'**
String get libraryRefreshing;
/// No description provided for @cannotDisableLastProvider.
///
/// In en, this message translates to:
/// **'Cannot disable the last provider'**
String get cannotDisableLastProvider;
/// No description provided for @noSeriesFound. /// No description provided for @noSeriesFound.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1745,24 +1647,12 @@ abstract class S {
/// **'Add album to queue on...'** /// **'Add album to queue on...'**
String get addAlbumToQueueOn; String get addAlbumToQueueOn;
/// Add item to queue on selected player
///
/// In en, this message translates to:
/// **'Add to queue'**
String get addToQueue;
/// Bottom sheet title for adding to queue /// Bottom sheet title for adding to queue
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Add to queue on...'** /// **'Add to queue on...'**
String get addToQueueOn; String get addToQueueOn;
/// Start radio on selected player
///
/// In en, this message translates to:
/// **'Start radio'**
String get startRadio;
/// Bottom sheet title for starting radio /// Bottom sheet title for starting radio
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1847,18 +1737,6 @@ abstract class S {
/// **'Sort by status (playing, on, off) instead of alphabetically'** /// **'Sort by status (playing, on, off) instead of alphabetically'**
String get smartSortPlayersDescription; String get smartSortPlayersDescription;
/// Setting to disable automatic player switching
///
/// In en, this message translates to:
/// **'Disable Auto-Switch'**
String get disableAutoSwitch;
/// Description for disable auto-switch setting
///
/// In en, this message translates to:
/// **'Never automatically switch to a different player'**
String get disableAutoSwitchDescription;
/// Player state when unavailable /// Player state when unavailable
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1919,36 +1797,6 @@ abstract class S {
/// **'Display helpful tips for discovering features'** /// **'Display helpful tips for discovering features'**
String get showHintsDescription; String get showHintsDescription;
/// Setting for fine volume control
///
/// In en, this message translates to:
/// **'Volume Precision Mode'**
String get volumePrecisionMode;
/// Description for volume precision mode setting
///
/// In en, this message translates to:
/// **'Hold still while adjusting volume for fine control'**
String get volumePrecisionModeDescription;
/// Settings section title for display options
///
/// In en, this message translates to:
/// **'Display'**
String get display;
/// Toggle label for showing provider icons on album art
///
/// In en, this message translates to:
/// **'Show Provider Icons'**
String get showProviderIcons;
/// Description for show provider icons toggle
///
/// In en, this message translates to:
/// **'Display provider logos on album art'**
String get showProviderIconsDescription;
/// Hint shown when mini player bounces /// Hint shown when mini player bounces
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2044,246 +1892,6 @@ abstract class S {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Long-press a podcast and tap the heart to add it to favorites'** /// **'Long-press a podcast and tap the heart to add it to favorites'**
String get longPressPodcastHint; String get longPressPodcastHint;
/// Error when playing radio station fails
///
/// In en, this message translates to:
/// **'Failed to play radio station: {error}'**
String failedToPlayRadioStation(String error);
/// Message when trying to add item already in library
///
/// In en, this message translates to:
/// **'Item is already in library'**
String get itemAlreadyInLibrary;
/// Error when adding to library fails
///
/// In en, this message translates to:
/// **'Failed to add to library'**
String get failedToAddToLibrary;
/// Error when library ID not found for removal
///
/// In en, this message translates to:
/// **'Cannot find library ID for removal'**
String get cannotFindLibraryId;
/// Error when removing from library fails
///
/// In en, this message translates to:
/// **'Failed to remove from library'**
String get failedToRemoveFromLibrary;
/// Error when playing album fails
///
/// In en, this message translates to:
/// **'Failed to play album: {error}'**
String failedToPlayAlbum(String error);
/// Status when playing item on specific player
///
/// In en, this message translates to:
/// **'Playing {name} on {playerName}'**
String playingOnPlayer(String name, String playerName);
/// Error when playing playlist fails
///
/// In en, this message translates to:
/// **'Failed to play playlist: {error}'**
String failedToPlayPlaylist(String error);
/// Error when playing audiobook fails
///
/// In en, this message translates to:
/// **'Failed to play audiobook: {error}'**
String failedToPlayAudiobook(String error);
/// Message when no other players available for transfer
///
/// In en, this message translates to:
/// **'No other players available'**
String get noOtherPlayersAvailable;
/// Tooltip for transfer queue button
///
/// In en, this message translates to:
/// **'Transfer queue'**
String get transferQueue;
/// Tooltip for clear queue button
///
/// In en, this message translates to:
/// **'Clear queue'**
String get clearQueue;
/// Status when syncing player
///
/// In en, this message translates to:
/// **'Syncing {playerName}...'**
String syncingPlayer(String playerName);
/// Label for switch player action
///
/// In en, this message translates to:
/// **'Switch Player'**
String get switchPlayer;
/// Confirmation when logs copied
///
/// In en, this message translates to:
/// **'Logs copied to clipboard'**
String get logsCopiedToClipboard;
/// Auto option for language selection
///
/// In en, this message translates to:
/// **'Auto'**
String get auto;
/// Title for transfer queue dialog
///
/// In en, this message translates to:
/// **'Transfer Queue To'**
String get transferQueueTo;
/// Success message when queue transferred
///
/// In en, this message translates to:
/// **'Queue transferred to {playerName}'**
String queueTransferredTo(String playerName);
/// Error when queue transfer fails
///
/// In en, this message translates to:
/// **'Failed to transfer queue: {error}'**
String failedToTransferQueue(String error);
/// Error when queue loading fails
///
/// In en, this message translates to:
/// **'Failed to load queue: {error}'**
String failedToLoadQueue(String error);
/// Label for transfer destination selection
///
/// In en, this message translates to:
/// **'Transfer to...'**
String get transferTo;
/// Tooltip for transfer queue button
///
/// In en, this message translates to:
/// **'Transfer queue to another player'**
String get transferQueueToPlayer;
/// Generic hint for selecting a player in player selector
///
/// In en, this message translates to:
/// **'Select a player'**
String get selectPlayerToPlay;
/// Hint for selecting player to play album
///
/// In en, this message translates to:
/// **'Select player to play album'**
String get selectPlayerToPlayAlbum;
/// Hint for selecting player to play playlist
///
/// In en, this message translates to:
/// **'Select player to play playlist'**
String get selectPlayerToPlayPlaylist;
/// Hint for selecting player to start radio
///
/// In en, this message translates to:
/// **'Select player for radio'**
String get selectPlayerForRadio;
/// Hint for selecting player to play episode
///
/// In en, this message translates to:
/// **'Select player for episode'**
String get selectPlayerForEpisode;
/// Hint for selecting player to play audiobook
///
/// In en, this message translates to:
/// **'Select player for audiobook'**
String get selectPlayerForAudiobook;
/// Hint for selecting player to add to queue
///
/// In en, this message translates to:
/// **'Select player to add to queue'**
String get selectPlayerToAddToQueue;
/// About and support section title
///
/// In en, this message translates to:
/// **'About & Support'**
String get aboutAndSupport;
/// Support development section
///
/// In en, this message translates to:
/// **'Support Development'**
String get supportDevelopment;
/// Description about Ensemble being unofficial
///
/// In en, this message translates to:
/// **'Ensemble is an unofficial, community-built client. Consider supporting its development.'**
String get ensembleIsUnofficial;
/// GitHub Sponsors button label
///
/// In en, this message translates to:
/// **'Sponsor on GitHub'**
String get sponsorOnGitHub;
/// Ko-fi donation button label
///
/// In en, this message translates to:
/// **'Buy me a Ko-fi'**
String get buyMeAKoFi;
/// About Ensemble section
///
/// In en, this message translates to:
/// **'About Ensemble'**
String get aboutEnsemble;
/// Version label
///
/// In en, this message translates to:
/// **'Version'**
String get version;
/// Link to GitHub repo
///
/// In en, this message translates to:
/// **'Visit GitHub Repository'**
String get visitGitHubRepo;
/// Link to report issues
///
/// In en, this message translates to:
/// **'Report a Bug'**
String get reportABug;
/// Empty state when no home screen rows are enabled
///
/// In en, this message translates to:
/// **'No rows enabled'**
String get noRowsEnabled;
/// Hint when home screen is empty
///
/// In en, this message translates to:
/// **'Pull down to refresh or enable rows in settings'**
String get pullToRefreshHint;
} }
class _SDelegate extends LocalizationsDelegate<S> { class _SDelegate extends LocalizationsDelegate<S> {
@@ -2295,29 +1903,27 @@ class _SDelegate extends LocalizationsDelegate<S> {
} }
@override @override
bool isSupported(Locale locale) => bool isSupported(Locale locale) => <String>['de', 'en', 'es', 'fr'].contains(locale.languageCode);
<String>['de', 'en', 'es', 'fr'].contains(locale.languageCode);
@override @override
bool shouldReload(_SDelegate old) => false; bool shouldReload(_SDelegate old) => false;
} }
S lookupS(Locale locale) { S lookupS(Locale locale) {
// Lookup logic when only language code is specified. // Lookup logic when only language code is specified.
switch (locale.languageCode) { switch (locale.languageCode) {
case 'de': case 'de': return SDe();
return SDe(); case 'en': return SEn();
case 'en': case 'es': return SEs();
return SEn(); case 'fr': return SFr();
case 'es':
return SEs();
case 'fr':
return SFr();
} }
throw FlutterError( throw FlutterError(
'S.delegate failed to load unsupported locale "$locale". This is likely ' 'S.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue ' 'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration ' 'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.'); 'that was used.'
);
} }
+42 -302
View File
@@ -1,5 +1,5 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl; import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart'; import 'app_localizations.dart';
// ignore_for_file: type=lint // ignore_for_file: type=lint
@@ -39,8 +39,7 @@ class SDe extends S {
String get portHint => 'z.B. 8095 oder leer lassen'; String get portHint => 'z.B. 8095 oder leer lassen';
@override @override
String get portDescription => String get portDescription => 'Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.';
'Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.';
@override @override
String get username => 'Benutzername'; String get username => 'Benutzername';
@@ -61,12 +60,10 @@ class SDe extends S {
String get authServerUrlOptional => 'Auth-Server URL (Optional)'; String get authServerUrlOptional => 'Auth-Server URL (Optional)';
@override @override
String get authServerUrlHint => String get authServerUrlHint => 'z.B. auth.example.com (wenn abweichend vom Server)';
'z.B. auth.example.com (wenn abweichend vom Server)';
@override @override
String get authServerUrlDescription => String get authServerUrlDescription => 'Leer lassen, wenn Authentifizierung auf demselben Server';
'Leer lassen, wenn Authentifizierung auf demselben Server';
@override @override
String detectedAuthType(String authType) { String detectedAuthType(String authType) {
@@ -83,49 +80,40 @@ class SDe extends S {
String get unencryptedConnection => 'Unverschlusselte Verbindung'; String get unencryptedConnection => 'Unverschlusselte Verbindung';
@override @override
String get usingHttpOverTailscale => String get usingHttpOverTailscale => 'HTTP uber Tailscale (verschlusselter Tunnel)';
'HTTP uber Tailscale (verschlusselter Tunnel)';
@override @override
String get httpsFailedUsingHttp => 'HTTPS fehlgeschlagen, verwende HTTP'; String get httpsFailedUsingHttp => 'HTTPS fehlgeschlagen, verwende HTTP';
@override @override
String get httpNotEncrypted => String get httpNotEncrypted => 'HTTP-Verbindung - Daten sind nicht verschlusselt';
'HTTP-Verbindung - Daten sind nicht verschlusselt';
@override @override
String get pleaseEnterServerAddress => String get pleaseEnterServerAddress => 'Bitte gib deine Music Assistant Serveradresse ein';
'Bitte gib deine Music Assistant Serveradresse ein';
@override @override
String get pleaseEnterName => 'Bitte gib deinen Namen ein'; String get pleaseEnterName => 'Bitte gib deinen Namen ein';
@override @override
String get pleaseEnterValidPort => String get pleaseEnterValidPort => 'Bitte gib eine gultige Portnummer ein (1-65535)';
'Bitte gib eine gultige Portnummer ein (1-65535)';
@override @override
String get pleaseEnterCredentials => String get pleaseEnterCredentials => 'Bitte gib Benutzername und Passwort ein';
'Bitte gib Benutzername und Passwort ein';
@override @override
String get authFailed => String get authFailed => 'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
@override @override
String get maLoginFailed => String get maLoginFailed => 'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
@override @override
String get connectionFailed => String get connectionFailed => 'Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.';
'Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.';
@override @override
String get detectingAuth => 'Erkenne Authentifizierung...'; String get detectingAuth => 'Erkenne Authentifizierung...';
@override @override
String get cannotDetermineAuth => String get cannotDetermineAuth => 'Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.';
'Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.';
@override @override
String get noAuthentication => 'Keine Authentifizierung'; String get noAuthentication => 'Keine Authentifizierung';
@@ -151,25 +139,6 @@ class SDe extends S {
@override @override
String get discoverAlbums => 'Alben entdecken'; String get discoverAlbums => 'Alben entdecken';
@override
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override @override
String get continueListening => 'Weiterhoren'; String get continueListening => 'Weiterhoren';
@@ -252,12 +221,10 @@ class SDe extends S {
String get noRadioStations => 'Keine Radiosender'; String get noRadioStations => 'Keine Radiosender';
@override @override
String get addRadioStationsHint => String get addRadioStationsHint => 'Füge Radiosender in Music Assistant hinzu';
'Füge Radiosender in Music Assistant hinzu';
@override @override
String get searchFailed => String get searchFailed => 'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
@override @override
String get queue => 'Warteschlange'; String get queue => 'Warteschlange';
@@ -392,9 +359,6 @@ class SDe extends S {
@override @override
String get tracksAddedToQueue => 'Songs zur Warteschlange hinzugefügt'; String get tracksAddedToQueue => 'Songs zur Warteschlange hinzugefügt';
@override
String get trackAddedToQueue => 'Track added to queue';
@override @override
String get play => 'Abspielen'; String get play => 'Abspielen';
@@ -507,43 +471,37 @@ class SDe extends S {
String get noFavoriteAudiobooks => 'Keine Lieblingshörbücher'; String get noFavoriteAudiobooks => 'Keine Lieblingshörbücher';
@override @override
String get tapHeartAudiobook => String get tapHeartAudiobook => 'Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen';
'Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen';
@override @override
String get noAudiobooks => 'Keine Hörbücher'; String get noAudiobooks => 'Keine Hörbücher';
@override @override
String get addAudiobooksHint => String get addAudiobooksHint => 'Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen';
'Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen';
@override @override
String get noFavoriteArtists => 'Keine Lieblingskünstler'; String get noFavoriteArtists => 'Keine Lieblingskünstler';
@override @override
String get tapHeartArtist => String get tapHeartArtist => 'Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen';
'Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen';
@override @override
String get noFavoriteAlbums => 'Keine Lieblingsalben'; String get noFavoriteAlbums => 'Keine Lieblingsalben';
@override @override
String get tapHeartAlbum => String get tapHeartAlbum => 'Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen';
'Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen';
@override @override
String get noFavoritePlaylists => 'Keine Lieblings-Wiedergabelisten'; String get noFavoritePlaylists => 'Keine Lieblings-Wiedergabelisten';
@override @override
String get tapHeartPlaylist => String get tapHeartPlaylist => 'Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen';
'Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen';
@override @override
String get noFavoriteTracks => 'Keine Lieblingssongs'; String get noFavoriteTracks => 'Keine Lieblingssongs';
@override @override
String get longPressTrackHint => String get longPressTrackHint => 'Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen';
'Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen';
@override @override
String get loadSeries => 'Serien laden'; String get loadSeries => 'Serien laden';
@@ -555,8 +513,7 @@ class SDe extends S {
String get notConnectedTitle => 'Nicht verbunden'; String get notConnectedTitle => 'Nicht verbunden';
@override @override
String get connectHint => String get connectHint => 'Verbinde dich mit deinem Music Assistant Server, um Musik zu hören';
'Verbinde dich mit deinem Music Assistant Server, um Musik zu hören';
@override @override
String get configureServer => 'Server konfigurieren'; String get configureServer => 'Server konfigurieren';
@@ -592,8 +549,7 @@ class SDe extends S {
String get noLogsToCopy => 'Keine Protokolle zum Kopieren'; String get noLogsToCopy => 'Keine Protokolle zum Kopieren';
@override @override
String get noDebugLogsYet => String get noDebugLogsYet => 'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
@override @override
String get showDebug => 'Debug anzeigen'; String get showDebug => 'Debug anzeigen';
@@ -668,18 +624,6 @@ class SDe extends S {
@override @override
String get sortBySeriesOrder => 'Nach Serienreihenfolge sortieren'; String get sortBySeriesOrder => 'Nach Serienreihenfolge sortieren';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override @override
String get listView => 'Listenansicht'; String get listView => 'Listenansicht';
@@ -705,12 +649,10 @@ class SDe extends S {
String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen'; String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen';
@override @override
String get showRandomAudiobooks => String get showRandomAudiobooks => 'Zufällige Hörbücher zum Entdecken anzeigen';
'Zufällige Hörbücher zum Entdecken anzeigen';
@override @override
String get showRandomSeries => String get showRandomSeries => 'Zufällige Hörbuchserien zum Entdecken anzeigen';
'Zufällige Hörbuchserien zum Entdecken anzeigen';
@override @override
String get showFavoriteAlbums => 'Lieblingsalben anzeigen'; String get showFavoriteAlbums => 'Lieblingsalben anzeigen';
@@ -725,22 +667,16 @@ class SDe extends S {
String get showFavoritePlaylists => 'Show a row of your favorite playlists'; String get showFavoritePlaylists => 'Show a row of your favorite playlists';
@override @override
String get showFavoriteRadioStations => String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
'Show a row of your favorite radio stations';
@override @override
String get showFavoritePodcasts => 'Show a row of your favorite podcasts'; String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
@override @override
String get extractColorsFromArtwork => String get extractColorsFromArtwork => 'Farben aus Album- und Künstlercover extrahieren';
'Farben aus Album- und Künstlercover extrahieren';
@override @override
String get chooseHomeScreenRows => String get chooseHomeScreenRows => 'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
@override
String get favorite => 'Favorite';
@override @override
String get addedToFavorites => 'Zu Favoriten hinzugefügt'; String get addedToFavorites => 'Zu Favoriten hinzugefügt';
@@ -755,7 +691,7 @@ class SDe extends S {
String get removedFromLibrary => 'Removed from library'; String get removedFromLibrary => 'Removed from library';
@override @override
String get addToLibrary => 'Add'; String get addToLibrary => 'Add to library';
@override @override
String get unknown => 'Unbekannt'; String get unknown => 'Unbekannt';
@@ -849,32 +785,17 @@ class SDe extends S {
String get theAudioDbApiKey => 'TheAudioDB API-Schlüssel'; String get theAudioDbApiKey => 'TheAudioDB API-Schlüssel';
@override @override
String get theAudioDbApiKeyHint => String get theAudioDbApiKeyHint => '\"2\" für kostenlosen Zugang oder Premium-Schlüssel';
'\"2\" für kostenlosen Zugang oder Premium-Schlüssel';
@override @override
String get audiobookLibraries => 'Hörbuch-Bibliotheken'; String get audiobookLibraries => 'Hörbuch-Bibliotheken';
@override @override
String get chooseAudiobookLibraries => String get chooseAudiobookLibraries => 'Wähle welche Audiobookshelf-Bibliotheken einbezogen werden';
'Wähle welche Audiobookshelf-Bibliotheken einbezogen werden';
@override @override
String get unknownLibrary => 'Unbekannte Bibliothek'; String get unknownLibrary => 'Unbekannte Bibliothek';
@override
String get musicProviders => 'Music Providers';
@override
String get musicProvidersDescription =>
'Choose which accounts to show in your library';
@override
String get libraryRefreshing => 'Refreshing library...';
@override
String get cannotDisableLastProvider => 'Cannot disable the last provider';
@override @override
String get noSeriesFound => 'Keine Serien gefunden'; String get noSeriesFound => 'Keine Serien gefunden';
@@ -887,19 +808,16 @@ class SDe extends S {
} }
@override @override
String get noSeriesAvailable => String get noSeriesAvailable => 'Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.';
'Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.';
@override @override
String get pullToLoadSeries => String get pullToLoadSeries => 'Ziehe nach unten um Serien\nvon Music Assistant zu laden';
'Ziehe nach unten um Serien\nvon Music Assistant zu laden';
@override @override
String get search => 'Suche'; String get search => 'Suche';
@override @override
String get metadataApisDescription => String get metadataApisDescription => 'Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.';
'Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.';
@override @override
String get lastFmApiKey => 'Last.fm API-Schlüssel'; String get lastFmApiKey => 'Last.fm API-Schlüssel';
@@ -924,15 +842,9 @@ class SDe extends S {
@override @override
String get addAlbumToQueueOn => 'Album zur Warteschlange hinzufügen auf...'; String get addAlbumToQueueOn => 'Album zur Warteschlange hinzufügen auf...';
@override
String get addToQueue => 'Add to queue';
@override @override
String get addToQueueOn => 'Zur Warteschlange hinzufügen auf...'; String get addToQueueOn => 'Zur Warteschlange hinzufügen auf...';
@override
String get startRadio => 'Start radio';
@override @override
String startRadioOn(String name) { String startRadioOn(String name) {
return '$name Radio starten auf...'; return '$name Radio starten auf...';
@@ -977,22 +889,13 @@ class SDe extends S {
String get preferLocalPlayer => 'Lokales Gerät bevorzugen'; String get preferLocalPlayer => 'Lokales Gerät bevorzugen';
@override @override
String get preferLocalPlayerDescription => String get preferLocalPlayerDescription => 'Dieses Gerät immer zuerst auswählen, wenn verfügbar';
'Dieses Gerät immer zuerst auswählen, wenn verfügbar';
@override @override
String get smartSortPlayers => 'Intelligente Sortierung'; String get smartSortPlayers => 'Intelligente Sortierung';
@override @override
String get smartSortPlayersDescription => String get smartSortPlayersDescription => 'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
@override
String get disableAutoSwitch => 'Disable Auto-Switch';
@override
String get disableAutoSwitchDescription =>
'Never automatically switch to a different player';
@override @override
String get playerStateUnavailable => 'Nicht verfügbar'; String get playerStateUnavailable => 'Nicht verfügbar';
@@ -1024,25 +927,7 @@ class SDe extends S {
String get showHints => 'Hinweise anzeigen'; String get showHints => 'Hinweise anzeigen';
@override @override
String get showHintsDescription => String get showHintsDescription => 'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override
String get display => 'Display';
@override
String get showProviderIcons => 'Show Provider Icons';
@override
String get showProviderIconsDescription =>
'Display provider logos on album art';
@override @override
String get pullToSelectPlayers => 'Ziehen um Geräte auszuwählen'; String get pullToSelectPlayers => 'Ziehen um Geräte auszuwählen';
@@ -1060,15 +945,13 @@ class SDe extends S {
String get welcomeToEnsemble => 'Welcome to Ensemble'; String get welcomeToEnsemble => 'Welcome to Ensemble';
@override @override
String get welcomeMessage => String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
'By default your phone is the selected player.\nPull the mini player down to select another player.';
@override @override
String get skip => 'Skip'; String get skip => 'Skip';
@override @override
String get dismissPlayerHint => String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
'Swipe down, tap outside, or press back to return';
@override @override
String playingAlbum(String albumName) { String playingAlbum(String albumName) {
@@ -1084,160 +967,17 @@ class SDe extends S {
String get noTracks => 'No tracks'; String get noTracks => 'No tracks';
@override @override
String get addTracksHint => String get addTracksHint => 'Add some music to your library to see tracks here';
'Add some music to your library to see tracks here';
@override @override
String get noFavoriteRadioStations => 'No favorite radio stations'; String get noFavoriteRadioStations => 'No favorite radio stations';
@override @override
String get longPressRadioHint => String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
'Long-press a station and tap the heart to add it to favorites';
@override @override
String get noFavoritePodcasts => 'No favorite podcasts'; String get noFavoritePodcasts => 'No favorite podcasts';
@override @override
String get longPressPodcastHint => String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
'Long-press a podcast and tap the heart to add it to favorites';
@override
String failedToPlayRadioStation(String error) {
return 'Failed to play radio station: $error';
}
@override
String get itemAlreadyInLibrary => 'Item is already in library';
@override
String get failedToAddToLibrary => 'Failed to add to library';
@override
String get cannotFindLibraryId => 'Cannot find library ID for removal';
@override
String get failedToRemoveFromLibrary => 'Failed to remove from library';
@override
String failedToPlayAlbum(String error) {
return 'Failed to play album: $error';
}
@override
String playingOnPlayer(String name, String playerName) {
return 'Playing $name on $playerName';
}
@override
String failedToPlayPlaylist(String error) {
return 'Failed to play playlist: $error';
}
@override
String failedToPlayAudiobook(String error) {
return 'Failed to play audiobook: $error';
}
@override
String get noOtherPlayersAvailable => 'No other players available';
@override
String get transferQueue => 'Transfer queue';
@override
String get clearQueue => 'Clear queue';
@override
String syncingPlayer(String playerName) {
return 'Syncing $playerName...';
}
@override
String get switchPlayer => 'Switch Player';
@override
String get logsCopiedToClipboard => 'Logs copied to clipboard';
@override
String get auto => 'Auto';
@override
String get transferQueueTo => 'Transfer Queue To';
@override
String queueTransferredTo(String playerName) {
return 'Queue transferred to $playerName';
}
@override
String failedToTransferQueue(String error) {
return 'Failed to transfer queue: $error';
}
@override
String failedToLoadQueue(String error) {
return 'Failed to load queue: $error';
}
@override
String get transferTo => 'Transfer to...';
@override
String get transferQueueToPlayer => 'Transfer queue to another player';
@override
String get selectPlayerToPlay => 'Select a player';
@override
String get selectPlayerToPlayAlbum => 'Select player to play album';
@override
String get selectPlayerToPlayPlaylist => 'Select player to play playlist';
@override
String get selectPlayerForRadio => 'Select player for radio';
@override
String get selectPlayerForEpisode => 'Select player for episode';
@override
String get selectPlayerForAudiobook => 'Select player for audiobook';
@override
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get aboutAndSupport => 'Über & Unterstützung';
@override
String get supportDevelopment => 'Entwicklung unterstützen';
@override
String get ensembleIsUnofficial =>
'Ensemble ist ein inoffizieller, von der Community erstellter Client. Erwäge, die Entwicklung zu unterstützen.';
@override
String get sponsorOnGitHub => 'Auf GitHub sponsoren';
@override
String get buyMeAKoFi => 'Kauf mir einen Ko-fi';
@override
String get aboutEnsemble => 'Über Ensemble';
@override
String get version => 'Version';
@override
String get visitGitHubRepo => 'GitHub-Repository besuchen';
@override
String get reportABug => 'Einen Fehler melden';
@override
String get noRowsEnabled => 'No rows enabled';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
} }
+34 -285
View File
@@ -1,5 +1,5 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl; import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart'; import 'app_localizations.dart';
// ignore_for_file: type=lint // ignore_for_file: type=lint
@@ -39,7 +39,7 @@ class SEn extends S {
String get portHint => 'e.g., 8095 or leave blank'; String get portHint => 'e.g., 8095 or leave blank';
@override @override
String get portDescription => 'Leave blank unless using a custom port'; String get portDescription => 'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.';
@override @override
String get username => 'Username'; String get username => 'Username';
@@ -60,12 +60,10 @@ class SEn extends S {
String get authServerUrlOptional => 'Auth Server URL (Optional)'; String get authServerUrlOptional => 'Auth Server URL (Optional)';
@override @override
String get authServerUrlHint => String get authServerUrlHint => 'e.g., auth.example.com (if different from server)';
'e.g., auth.example.com (if different from server)';
@override @override
String get authServerUrlDescription => String get authServerUrlDescription => 'Leave empty if authentication is on the same server';
'Leave empty if authentication is on the same server';
@override @override
String detectedAuthType(String authType) { String detectedAuthType(String authType) {
@@ -82,8 +80,7 @@ class SEn extends S {
String get unencryptedConnection => 'Unencrypted Connection'; String get unencryptedConnection => 'Unencrypted Connection';
@override @override
String get usingHttpOverTailscale => String get usingHttpOverTailscale => 'Using HTTP over Tailscale (encrypted tunnel)';
'Using HTTP over Tailscale (encrypted tunnel)';
@override @override
String get httpsFailedUsingHttp => 'HTTPS failed, using HTTP fallback'; String get httpsFailedUsingHttp => 'HTTPS failed, using HTTP fallback';
@@ -92,37 +89,31 @@ class SEn extends S {
String get httpNotEncrypted => 'HTTP connection - data is not encrypted'; String get httpNotEncrypted => 'HTTP connection - data is not encrypted';
@override @override
String get pleaseEnterServerAddress => String get pleaseEnterServerAddress => 'Please enter your Music Assistant server address';
'Please enter your Music Assistant server address';
@override @override
String get pleaseEnterName => 'Please enter your name'; String get pleaseEnterName => 'Please enter your name';
@override @override
String get pleaseEnterValidPort => String get pleaseEnterValidPort => 'Please enter a valid port number (1-65535)';
'Please enter a valid port number (1-65535)';
@override @override
String get pleaseEnterCredentials => 'Please enter username and password'; String get pleaseEnterCredentials => 'Please enter username and password';
@override @override
String get authFailed => String get authFailed => 'Authentication failed. Please check your credentials.';
'Authentication failed. Please check your credentials.';
@override @override
String get maLoginFailed => String get maLoginFailed => 'Music Assistant login failed. Please check your credentials.';
'Music Assistant login failed. Please check your credentials.';
@override @override
String get connectionFailed => String get connectionFailed => 'Could not connect to server. Please check the address and try again.';
'Could not connect to server. Please check the address and try again.';
@override @override
String get detectingAuth => 'Detecting authentication...'; String get detectingAuth => 'Detecting authentication...';
@override @override
String get cannotDetermineAuth => String get cannotDetermineAuth => 'Cannot determine authentication requirements. Please check server URL.';
'Cannot determine authentication requirements. Please check server URL.';
@override @override
String get noAuthentication => 'No Authentication'; String get noAuthentication => 'No Authentication';
@@ -148,25 +139,6 @@ class SEn extends S {
@override @override
String get discoverAlbums => 'Discover Albums'; String get discoverAlbums => 'Discover Albums';
@override
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override @override
String get continueListening => 'Continue Listening'; String get continueListening => 'Continue Listening';
@@ -387,9 +359,6 @@ class SEn extends S {
@override @override
String get tracksAddedToQueue => 'Tracks added to queue'; String get tracksAddedToQueue => 'Tracks added to queue';
@override
String get trackAddedToQueue => 'Track added to queue';
@override @override
String get play => 'Play'; String get play => 'Play';
@@ -502,43 +471,37 @@ class SEn extends S {
String get noFavoriteAudiobooks => 'No favorite audiobooks'; String get noFavoriteAudiobooks => 'No favorite audiobooks';
@override @override
String get tapHeartAudiobook => String get tapHeartAudiobook => 'Tap the heart on an audiobook to add it to favorites';
'Tap the heart on an audiobook to add it to favorites';
@override @override
String get noAudiobooks => 'No audiobooks'; String get noAudiobooks => 'No audiobooks';
@override @override
String get addAudiobooksHint => String get addAudiobooksHint => 'Add audiobooks to your library to see them here';
'Add audiobooks to your library to see them here';
@override @override
String get noFavoriteArtists => 'No favorite artists'; String get noFavoriteArtists => 'No favorite artists';
@override @override
String get tapHeartArtist => String get tapHeartArtist => 'Tap the heart on an artist to add them to favorites';
'Tap the heart on an artist to add them to favorites';
@override @override
String get noFavoriteAlbums => 'No favorite albums'; String get noFavoriteAlbums => 'No favorite albums';
@override @override
String get tapHeartAlbum => String get tapHeartAlbum => 'Tap the heart on an album to add it to favorites';
'Tap the heart on an album to add it to favorites';
@override @override
String get noFavoritePlaylists => 'No favorite playlists'; String get noFavoritePlaylists => 'No favorite playlists';
@override @override
String get tapHeartPlaylist => String get tapHeartPlaylist => 'Tap the heart on a playlist to add it to favorites';
'Tap the heart on a playlist to add it to favorites';
@override @override
String get noFavoriteTracks => 'No favorite tracks'; String get noFavoriteTracks => 'No favorite tracks';
@override @override
String get longPressTrackHint => String get longPressTrackHint => 'Long-press a track and tap the heart to add it to favorites';
'Long-press a track and tap the heart to add it to favorites';
@override @override
String get loadSeries => 'Load Series'; String get loadSeries => 'Load Series';
@@ -550,8 +513,7 @@ class SEn extends S {
String get notConnectedTitle => 'Not Connected'; String get notConnectedTitle => 'Not Connected';
@override @override
String get connectHint => String get connectHint => 'Connect to your Music Assistant server to start listening';
'Connect to your Music Assistant server to start listening';
@override @override
String get configureServer => 'Configure Server'; String get configureServer => 'Configure Server';
@@ -662,18 +624,6 @@ class SEn extends S {
@override @override
String get sortBySeriesOrder => 'Sort by series order'; String get sortBySeriesOrder => 'Sort by series order';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override @override
String get listView => 'List view'; String get listView => 'List view';
@@ -717,22 +667,16 @@ class SEn extends S {
String get showFavoritePlaylists => 'Show a row of your favorite playlists'; String get showFavoritePlaylists => 'Show a row of your favorite playlists';
@override @override
String get showFavoriteRadioStations => String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
'Show a row of your favorite radio stations';
@override @override
String get showFavoritePodcasts => 'Show a row of your favorite podcasts'; String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
@override @override
String get extractColorsFromArtwork => String get extractColorsFromArtwork => 'Extract colors from album and artist artwork';
'Extract colors from album and artist artwork';
@override @override
String get chooseHomeScreenRows => String get chooseHomeScreenRows => 'Choose which rows to display on the home screen';
'Choose which rows to display on the home screen';
@override
String get favorite => 'Favorite';
@override @override
String get addedToFavorites => 'Added to favorites'; String get addedToFavorites => 'Added to favorites';
@@ -747,7 +691,7 @@ class SEn extends S {
String get removedFromLibrary => 'Removed from library'; String get removedFromLibrary => 'Removed from library';
@override @override
String get addToLibrary => 'Add'; String get addToLibrary => 'Add to library';
@override @override
String get unknown => 'Unknown'; String get unknown => 'Unknown';
@@ -847,25 +791,11 @@ class SEn extends S {
String get audiobookLibraries => 'Audiobook Libraries'; String get audiobookLibraries => 'Audiobook Libraries';
@override @override
String get chooseAudiobookLibraries => String get chooseAudiobookLibraries => 'Choose which Audiobookshelf libraries to include';
'Choose which Audiobookshelf libraries to include';
@override @override
String get unknownLibrary => 'Unknown Library'; String get unknownLibrary => 'Unknown Library';
@override
String get musicProviders => 'Music Providers';
@override
String get musicProvidersDescription =>
'Choose which accounts to show in your library';
@override
String get libraryRefreshing => 'Refreshing library...';
@override
String get cannotDisableLastProvider => 'Cannot disable the last provider';
@override @override
String get noSeriesFound => 'No Series Found'; String get noSeriesFound => 'No Series Found';
@@ -878,19 +808,16 @@ class SEn extends S {
} }
@override @override
String get noSeriesAvailable => String get noSeriesAvailable => 'No series available from your audiobook library.\nPull to refresh.';
'No series available from your audiobook library.\nPull to refresh.';
@override @override
String get pullToLoadSeries => String get pullToLoadSeries => 'Pull down to load series\nfrom Music Assistant';
'Pull down to load series\nfrom Music Assistant';
@override @override
String get search => 'Search'; String get search => 'Search';
@override @override
String get metadataApisDescription => String get metadataApisDescription => 'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.';
'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.';
@override @override
String get lastFmApiKey => 'Last.fm API Key'; String get lastFmApiKey => 'Last.fm API Key';
@@ -915,15 +842,9 @@ class SEn extends S {
@override @override
String get addAlbumToQueueOn => 'Add album to queue on...'; String get addAlbumToQueueOn => 'Add album to queue on...';
@override
String get addToQueue => 'Add to queue';
@override @override
String get addToQueueOn => 'Add to queue on...'; String get addToQueueOn => 'Add to queue on...';
@override
String get startRadio => 'Start radio';
@override @override
String startRadioOn(String name) { String startRadioOn(String name) {
return 'Start $name radio on...'; return 'Start $name radio on...';
@@ -968,22 +889,13 @@ class SEn extends S {
String get preferLocalPlayer => 'Prefer Local Player'; String get preferLocalPlayer => 'Prefer Local Player';
@override @override
String get preferLocalPlayerDescription => String get preferLocalPlayerDescription => 'Always select this device first when available';
'Always select this device first when available';
@override @override
String get smartSortPlayers => 'Smart Sort'; String get smartSortPlayers => 'Smart Sort';
@override @override
String get smartSortPlayersDescription => String get smartSortPlayersDescription => 'Sort by status (playing, on, off) instead of alphabetically';
'Sort by status (playing, on, off) instead of alphabetically';
@override
String get disableAutoSwitch => 'Disable Auto-Switch';
@override
String get disableAutoSwitchDescription =>
'Never automatically switch to a different player';
@override @override
String get playerStateUnavailable => 'Unavailable'; String get playerStateUnavailable => 'Unavailable';
@@ -1015,25 +927,7 @@ class SEn extends S {
String get showHints => 'Show Hints'; String get showHints => 'Show Hints';
@override @override
String get showHintsDescription => String get showHintsDescription => 'Display helpful tips for discovering features';
'Display helpful tips for discovering features';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override
String get display => 'Display';
@override
String get showProviderIcons => 'Show Provider Icons';
@override
String get showProviderIconsDescription =>
'Display provider logos on album art';
@override @override
String get pullToSelectPlayers => 'Pull to select players'; String get pullToSelectPlayers => 'Pull to select players';
@@ -1051,15 +945,13 @@ class SEn extends S {
String get welcomeToEnsemble => 'Welcome to Ensemble'; String get welcomeToEnsemble => 'Welcome to Ensemble';
@override @override
String get welcomeMessage => String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
'By default your phone is the selected player.\nPull the mini player down to select another player.';
@override @override
String get skip => 'Skip'; String get skip => 'Skip';
@override @override
String get dismissPlayerHint => String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
'Swipe down, tap outside, or press back to return';
@override @override
String playingAlbum(String albumName) { String playingAlbum(String albumName) {
@@ -1075,160 +967,17 @@ class SEn extends S {
String get noTracks => 'No tracks'; String get noTracks => 'No tracks';
@override @override
String get addTracksHint => String get addTracksHint => 'Add some music to your library to see tracks here';
'Add some music to your library to see tracks here';
@override @override
String get noFavoriteRadioStations => 'No favorite radio stations'; String get noFavoriteRadioStations => 'No favorite radio stations';
@override @override
String get longPressRadioHint => String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
'Long-press a station and tap the heart to add it to favorites';
@override @override
String get noFavoritePodcasts => 'No favorite podcasts'; String get noFavoritePodcasts => 'No favorite podcasts';
@override @override
String get longPressPodcastHint => String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
'Long-press a podcast and tap the heart to add it to favorites';
@override
String failedToPlayRadioStation(String error) {
return 'Failed to play radio station: $error';
}
@override
String get itemAlreadyInLibrary => 'Item is already in library';
@override
String get failedToAddToLibrary => 'Failed to add to library';
@override
String get cannotFindLibraryId => 'Cannot find library ID for removal';
@override
String get failedToRemoveFromLibrary => 'Failed to remove from library';
@override
String failedToPlayAlbum(String error) {
return 'Failed to play album: $error';
}
@override
String playingOnPlayer(String name, String playerName) {
return 'Playing $name on $playerName';
}
@override
String failedToPlayPlaylist(String error) {
return 'Failed to play playlist: $error';
}
@override
String failedToPlayAudiobook(String error) {
return 'Failed to play audiobook: $error';
}
@override
String get noOtherPlayersAvailable => 'No other players available';
@override
String get transferQueue => 'Transfer queue';
@override
String get clearQueue => 'Clear queue';
@override
String syncingPlayer(String playerName) {
return 'Syncing $playerName...';
}
@override
String get switchPlayer => 'Switch Player';
@override
String get logsCopiedToClipboard => 'Logs copied to clipboard';
@override
String get auto => 'Auto';
@override
String get transferQueueTo => 'Transfer Queue To';
@override
String queueTransferredTo(String playerName) {
return 'Queue transferred to $playerName';
}
@override
String failedToTransferQueue(String error) {
return 'Failed to transfer queue: $error';
}
@override
String failedToLoadQueue(String error) {
return 'Failed to load queue: $error';
}
@override
String get transferTo => 'Transfer to...';
@override
String get transferQueueToPlayer => 'Transfer queue to another player';
@override
String get selectPlayerToPlay => 'Select a player';
@override
String get selectPlayerToPlayAlbum => 'Select player to play album';
@override
String get selectPlayerToPlayPlaylist => 'Select player to play playlist';
@override
String get selectPlayerForRadio => 'Select player for radio';
@override
String get selectPlayerForEpisode => 'Select player for episode';
@override
String get selectPlayerForAudiobook => 'Select player for audiobook';
@override
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get aboutAndSupport => 'About & Support';
@override
String get supportDevelopment => 'Support Development';
@override
String get ensembleIsUnofficial =>
'Ensemble is an unofficial, community-built client. Consider supporting its development.';
@override
String get sponsorOnGitHub => 'Sponsor on GitHub';
@override
String get buyMeAKoFi => 'Buy me a Ko-fi';
@override
String get aboutEnsemble => 'About Ensemble';
@override
String get version => 'Version';
@override
String get visitGitHubRepo => 'Visit GitHub Repository';
@override
String get reportABug => 'Report a Bug';
@override
String get noRowsEnabled => 'No rows enabled';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
} }
+50 -318
View File
@@ -1,5 +1,5 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl; import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart'; import 'app_localizations.dart';
// ignore_for_file: type=lint // ignore_for_file: type=lint
@@ -39,8 +39,7 @@ class SEs extends S {
String get portHint => 'ej., 8095 o dejar en blanco'; String get portHint => 'ej., 8095 o dejar en blanco';
@override @override
String get portDescription => String get portDescription => 'Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.';
'Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.';
@override @override
String get username => 'Usuario'; String get username => 'Usuario';
@@ -58,16 +57,13 @@ class SEs extends S {
String get disconnect => 'Desconectar'; String get disconnect => 'Desconectar';
@override @override
String get authServerUrlOptional => String get authServerUrlOptional => 'URL del servidor de autenticación (Opcional)';
'URL del servidor de autenticación (Opcional)';
@override @override
String get authServerUrlHint => String get authServerUrlHint => 'ej., auth.example.com (si es diferente del servidor)';
'ej., auth.example.com (si es diferente del servidor)';
@override @override
String get authServerUrlDescription => String get authServerUrlDescription => 'Dejar vacío si la autenticación está en el mismo servidor';
'Dejar vacío si la autenticación está en el mismo servidor';
@override @override
String detectedAuthType(String authType) { String detectedAuthType(String authType) {
@@ -84,49 +80,40 @@ class SEs extends S {
String get unencryptedConnection => 'Conexión sin cifrar'; String get unencryptedConnection => 'Conexión sin cifrar';
@override @override
String get usingHttpOverTailscale => String get usingHttpOverTailscale => 'Usando HTTP sobre Tailscale (túnel cifrado)';
'Usando HTTP sobre Tailscale (túnel cifrado)';
@override @override
String get httpsFailedUsingHttp => String get httpsFailedUsingHttp => 'HTTPS falló, usando HTTP como alternativa';
'HTTPS falló, usando HTTP como alternativa';
@override @override
String get httpNotEncrypted => 'Conexión HTTP - los datos no están cifrados'; String get httpNotEncrypted => 'Conexión HTTP - los datos no están cifrados';
@override @override
String get pleaseEnterServerAddress => String get pleaseEnterServerAddress => 'Por favor, introduce la dirección de tu servidor Music Assistant';
'Por favor, introduce la dirección de tu servidor Music Assistant';
@override @override
String get pleaseEnterName => 'Por favor, introduce tu nombre'; String get pleaseEnterName => 'Por favor, introduce tu nombre';
@override @override
String get pleaseEnterValidPort => String get pleaseEnterValidPort => 'Por favor, introduce un número de puerto válido (1-65535)';
'Por favor, introduce un número de puerto válido (1-65535)';
@override @override
String get pleaseEnterCredentials => String get pleaseEnterCredentials => 'Por favor, introduce usuario y contraseña';
'Por favor, introduce usuario y contraseña';
@override @override
String get authFailed => String get authFailed => 'Autenticación fallida. Por favor, verifica tus credenciales.';
'Autenticación fallida. Por favor, verifica tus credenciales.';
@override @override
String get maLoginFailed => String get maLoginFailed => 'Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.';
'Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.';
@override @override
String get connectionFailed => String get connectionFailed => 'No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.';
'No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.';
@override @override
String get detectingAuth => 'Detectando autenticación...'; String get detectingAuth => 'Detectando autenticación...';
@override @override
String get cannotDetermineAuth => String get cannotDetermineAuth => 'No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.';
'No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.';
@override @override
String get noAuthentication => 'Sin autenticación'; String get noAuthentication => 'Sin autenticación';
@@ -152,25 +139,6 @@ class SEs extends S {
@override @override
String get discoverAlbums => 'Descubrir álbumes'; String get discoverAlbums => 'Descubrir álbumes';
@override
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override @override
String get continueListening => 'Continuar escuchando'; String get continueListening => 'Continuar escuchando';
@@ -253,12 +221,10 @@ class SEs extends S {
String get noRadioStations => 'No hay emisoras de radio'; String get noRadioStations => 'No hay emisoras de radio';
@override @override
String get addRadioStationsHint => String get addRadioStationsHint => 'Añade emisoras de radio en Music Assistant';
'Añade emisoras de radio en Music Assistant';
@override @override
String get searchFailed => String get searchFailed => 'Búsqueda fallida. Por favor, verifica tu conexión.';
'Búsqueda fallida. Por favor, verifica tu conexión.';
@override @override
String get queue => 'Cola'; String get queue => 'Cola';
@@ -298,8 +264,7 @@ class SEs extends S {
String get system => 'Sistema'; String get system => 'Sistema';
@override @override
String get pullToRefresh => String get pullToRefresh => 'Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios';
'Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios';
@override @override
String get viewDebugLogs => 'Ver registros de depuración'; String get viewDebugLogs => 'Ver registros de depuración';
@@ -336,8 +301,7 @@ class SEs extends S {
String get logsCleared => 'Registros borrados'; String get logsCleared => 'Registros borrados';
@override @override
String get playerListCopied => String get playerListCopied => 'Lista de reproductores copiada al portapapeles';
'Lista de reproductores copiada al portapapeles';
@override @override
String get noLogsYet => 'Aún no hay registros'; String get noLogsYet => 'Aún no hay registros';
@@ -395,9 +359,6 @@ class SEs extends S {
@override @override
String get tracksAddedToQueue => 'Canciones añadidas a la cola'; String get tracksAddedToQueue => 'Canciones añadidas a la cola';
@override
String get trackAddedToQueue => 'Track added to queue';
@override @override
String get play => 'Reproducir'; String get play => 'Reproducir';
@@ -510,43 +471,37 @@ class SEs extends S {
String get noFavoriteAudiobooks => 'No hay audiolibros favoritos'; String get noFavoriteAudiobooks => 'No hay audiolibros favoritos';
@override @override
String get tapHeartAudiobook => String get tapHeartAudiobook => 'Toca el corazón en un audiolibro para añadirlo a favoritos';
'Toca el corazón en un audiolibro para añadirlo a favoritos';
@override @override
String get noAudiobooks => 'No hay audiolibros'; String get noAudiobooks => 'No hay audiolibros';
@override @override
String get addAudiobooksHint => String get addAudiobooksHint => 'Añade audiolibros a tu biblioteca para verlos aquí';
'Añade audiolibros a tu biblioteca para verlos aquí';
@override @override
String get noFavoriteArtists => 'No hay artistas favoritos'; String get noFavoriteArtists => 'No hay artistas favoritos';
@override @override
String get tapHeartArtist => String get tapHeartArtist => 'Toca el corazón en un artista para añadirlo a favoritos';
'Toca el corazón en un artista para añadirlo a favoritos';
@override @override
String get noFavoriteAlbums => 'No hay álbumes favoritos'; String get noFavoriteAlbums => 'No hay álbumes favoritos';
@override @override
String get tapHeartAlbum => String get tapHeartAlbum => 'Toca el corazón en un álbum para añadirlo a favoritos';
'Toca el corazón en un álbum para añadirlo a favoritos';
@override @override
String get noFavoritePlaylists => 'No hay listas de reproducción favoritas'; String get noFavoritePlaylists => 'No hay listas de reproducción favoritas';
@override @override
String get tapHeartPlaylist => String get tapHeartPlaylist => 'Toca el corazón en una lista para añadirla a favoritos';
'Toca el corazón en una lista para añadirla a favoritos';
@override @override
String get noFavoriteTracks => 'No hay canciones favoritas'; String get noFavoriteTracks => 'No hay canciones favoritas';
@override @override
String get longPressTrackHint => String get longPressTrackHint => 'Mantén presionada una canción y toca el corazón para añadirla a favoritos';
'Mantén presionada una canción y toca el corazón para añadirla a favoritos';
@override @override
String get loadSeries => 'Cargar series'; String get loadSeries => 'Cargar series';
@@ -558,8 +513,7 @@ class SEs extends S {
String get notConnectedTitle => 'No conectado'; String get notConnectedTitle => 'No conectado';
@override @override
String get connectHint => String get connectHint => 'Conéctate a tu servidor Music Assistant para empezar a escuchar';
'Conéctate a tu servidor Music Assistant para empezar a escuchar';
@override @override
String get configureServer => 'Configurar servidor'; String get configureServer => 'Configurar servidor';
@@ -595,8 +549,7 @@ class SEs extends S {
String get noLogsToCopy => 'No hay registros para copiar'; String get noLogsToCopy => 'No hay registros para copiar';
@override @override
String get noDebugLogsYet => String get noDebugLogsYet => 'Aún no hay registros de depuración. Intenta detectar autenticación.';
'Aún no hay registros de depuración. Intenta detectar autenticación.';
@override @override
String get showDebug => 'Mostrar depuración'; String get showDebug => 'Mostrar depuración';
@@ -611,8 +564,7 @@ class SEs extends S {
String get noChapters => 'Sin capítulos'; String get noChapters => 'Sin capítulos';
@override @override
String get noChapterInfo => String get noChapterInfo => 'Este audiolibro no tiene información de capítulos';
'Este audiolibro no tiene información de capítulos';
@override @override
String errorSeeking(String error) { String errorSeeking(String error) {
@@ -672,18 +624,6 @@ class SEs extends S {
@override @override
String get sortBySeriesOrder => 'Ordenar por orden de serie'; String get sortBySeriesOrder => 'Ordenar por orden de serie';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override @override
String get listView => 'Vista de lista'; String get listView => 'Vista de lista';
@@ -697,8 +637,7 @@ class SEs extends S {
String get artist => 'Artista'; String get artist => 'Artista';
@override @override
String get showRecentlyPlayedAlbums => String get showRecentlyPlayedAlbums => 'Mostrar álbumes reproducidos recientemente';
'Mostrar álbumes reproducidos recientemente';
@override @override
String get showRandomArtists => 'Mostrar artistas aleatorios para descubrir'; String get showRandomArtists => 'Mostrar artistas aleatorios para descubrir';
@@ -710,44 +649,34 @@ class SEs extends S {
String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso'; String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso';
@override @override
String get showRandomAudiobooks => String get showRandomAudiobooks => 'Mostrar audiolibros aleatorios para descubrir';
'Mostrar audiolibros aleatorios para descubrir';
@override @override
String get showRandomSeries => String get showRandomSeries => 'Mostrar series de audiolibros aleatorias para descubrir';
'Mostrar series de audiolibros aleatorias para descubrir';
@override @override
String get showFavoriteAlbums => 'Mostrar una fila de tus álbumes favoritos'; String get showFavoriteAlbums => 'Mostrar una fila de tus álbumes favoritos';
@override @override
String get showFavoriteArtists => String get showFavoriteArtists => 'Mostrar una fila de tus artistas favoritos';
'Mostrar una fila de tus artistas favoritos';
@override @override
String get showFavoriteTracks => String get showFavoriteTracks => 'Mostrar una fila de tus canciones favoritas';
'Mostrar una fila de tus canciones favoritas';
@override @override
String get showFavoritePlaylists => 'Show a row of your favorite playlists'; String get showFavoritePlaylists => 'Show a row of your favorite playlists';
@override @override
String get showFavoriteRadioStations => String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
'Show a row of your favorite radio stations';
@override @override
String get showFavoritePodcasts => 'Show a row of your favorite podcasts'; String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
@override @override
String get extractColorsFromArtwork => String get extractColorsFromArtwork => 'Extraer colores de las portadas de álbumes y artistas';
'Extraer colores de las portadas de álbumes y artistas';
@override @override
String get chooseHomeScreenRows => String get chooseHomeScreenRows => 'Elige qué filas mostrar en la pantalla de inicio';
'Elige qué filas mostrar en la pantalla de inicio';
@override
String get favorite => 'Favorite';
@override @override
String get addedToFavorites => 'Añadido a favoritos'; String get addedToFavorites => 'Añadido a favoritos';
@@ -762,7 +691,7 @@ class SEs extends S {
String get removedFromLibrary => 'Removed from library'; String get removedFromLibrary => 'Removed from library';
@override @override
String get addToLibrary => 'Add'; String get addToLibrary => 'Add to library';
@override @override
String get unknown => 'Desconocido'; String get unknown => 'Desconocido';
@@ -845,8 +774,7 @@ class SEs extends S {
String get loadingChapters => 'Cargando capítulos...'; String get loadingChapters => 'Cargando capítulos...';
@override @override
String get noChapterInfoAvailable => String get noChapterInfoAvailable => 'No hay información de capítulos disponible';
'No hay información de capítulos disponible';
@override @override
String percentComplete(int percent) { String percentComplete(int percent) {
@@ -857,32 +785,17 @@ class SEs extends S {
String get theAudioDbApiKey => 'Clave API de TheAudioDB'; String get theAudioDbApiKey => 'Clave API de TheAudioDB';
@override @override
String get theAudioDbApiKeyHint => String get theAudioDbApiKeyHint => 'Usa \"2\" para nivel gratuito o clave premium';
'Usa \"2\" para nivel gratuito o clave premium';
@override @override
String get audiobookLibraries => 'Bibliotecas de audiolibros'; String get audiobookLibraries => 'Bibliotecas de audiolibros';
@override @override
String get chooseAudiobookLibraries => String get chooseAudiobookLibraries => 'Elige qué bibliotecas de Audiobookshelf incluir';
'Elige qué bibliotecas de Audiobookshelf incluir';
@override @override
String get unknownLibrary => 'Biblioteca desconocida'; String get unknownLibrary => 'Biblioteca desconocida';
@override
String get musicProviders => 'Music Providers';
@override
String get musicProvidersDescription =>
'Choose which accounts to show in your library';
@override
String get libraryRefreshing => 'Refreshing library...';
@override
String get cannotDisableLastProvider => 'Cannot disable the last provider';
@override @override
String get noSeriesFound => 'No se encontraron series'; String get noSeriesFound => 'No se encontraron series';
@@ -895,19 +808,16 @@ class SEs extends S {
} }
@override @override
String get noSeriesAvailable => String get noSeriesAvailable => 'No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.';
'No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.';
@override @override
String get pullToLoadSeries => String get pullToLoadSeries => 'Desliza hacia abajo para cargar series\ndesde Music Assistant';
'Desliza hacia abajo para cargar series\ndesde Music Assistant';
@override @override
String get search => 'Buscar'; String get search => 'Buscar';
@override @override
String get metadataApisDescription => String get metadataApisDescription => 'Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.';
'Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.';
@override @override
String get lastFmApiKey => 'Clave API de Last.fm'; String get lastFmApiKey => 'Clave API de Last.fm';
@@ -932,15 +842,9 @@ class SEs extends S {
@override @override
String get addAlbumToQueueOn => 'Añadir álbum a la cola en...'; String get addAlbumToQueueOn => 'Añadir álbum a la cola en...';
@override
String get addToQueue => 'Add to queue';
@override @override
String get addToQueueOn => 'Añadir a la cola en...'; String get addToQueueOn => 'Añadir a la cola en...';
@override
String get startRadio => 'Start radio';
@override @override
String startRadioOn(String name) { String startRadioOn(String name) {
return 'Iniciar radio de $name en...'; return 'Iniciar radio de $name en...';
@@ -985,22 +889,13 @@ class SEs extends S {
String get preferLocalPlayer => 'Preferir dispositivo local'; String get preferLocalPlayer => 'Preferir dispositivo local';
@override @override
String get preferLocalPlayerDescription => String get preferLocalPlayerDescription => 'Seleccionar siempre este dispositivo primero cuando esté disponible';
'Seleccionar siempre este dispositivo primero cuando esté disponible';
@override @override
String get smartSortPlayers => 'Orden inteligente'; String get smartSortPlayers => 'Orden inteligente';
@override @override
String get smartSortPlayersDescription => String get smartSortPlayersDescription => 'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
@override
String get disableAutoSwitch => 'Disable Auto-Switch';
@override
String get disableAutoSwitchDescription =>
'Never automatically switch to a different player';
@override @override
String get playerStateUnavailable => 'No disponible'; String get playerStateUnavailable => 'No disponible';
@@ -1032,25 +927,7 @@ class SEs extends S {
String get showHints => 'Mostrar Sugerencias'; String get showHints => 'Mostrar Sugerencias';
@override @override
String get showHintsDescription => String get showHintsDescription => 'Mostrar consejos útiles para descubrir funciones';
'Mostrar consejos útiles para descubrir funciones';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override
String get display => 'Display';
@override
String get showProviderIcons => 'Show Provider Icons';
@override
String get showProviderIconsDescription =>
'Display provider logos on album art';
@override @override
String get pullToSelectPlayers => 'Desliza para seleccionar dispositivos'; String get pullToSelectPlayers => 'Desliza para seleccionar dispositivos';
@@ -1068,15 +945,13 @@ class SEs extends S {
String get welcomeToEnsemble => 'Welcome to Ensemble'; String get welcomeToEnsemble => 'Welcome to Ensemble';
@override @override
String get welcomeMessage => String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
'By default your phone is the selected player.\nPull the mini player down to select another player.';
@override @override
String get skip => 'Skip'; String get skip => 'Skip';
@override @override
String get dismissPlayerHint => String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
'Swipe down, tap outside, or press back to return';
@override @override
String playingAlbum(String albumName) { String playingAlbum(String albumName) {
@@ -1092,160 +967,17 @@ class SEs extends S {
String get noTracks => 'No tracks'; String get noTracks => 'No tracks';
@override @override
String get addTracksHint => String get addTracksHint => 'Add some music to your library to see tracks here';
'Add some music to your library to see tracks here';
@override @override
String get noFavoriteRadioStations => 'No favorite radio stations'; String get noFavoriteRadioStations => 'No favorite radio stations';
@override @override
String get longPressRadioHint => String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
'Long-press a station and tap the heart to add it to favorites';
@override @override
String get noFavoritePodcasts => 'No favorite podcasts'; String get noFavoritePodcasts => 'No favorite podcasts';
@override @override
String get longPressPodcastHint => String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
'Long-press a podcast and tap the heart to add it to favorites';
@override
String failedToPlayRadioStation(String error) {
return 'Failed to play radio station: $error';
}
@override
String get itemAlreadyInLibrary => 'Item is already in library';
@override
String get failedToAddToLibrary => 'Failed to add to library';
@override
String get cannotFindLibraryId => 'Cannot find library ID for removal';
@override
String get failedToRemoveFromLibrary => 'Failed to remove from library';
@override
String failedToPlayAlbum(String error) {
return 'Failed to play album: $error';
}
@override
String playingOnPlayer(String name, String playerName) {
return 'Playing $name on $playerName';
}
@override
String failedToPlayPlaylist(String error) {
return 'Failed to play playlist: $error';
}
@override
String failedToPlayAudiobook(String error) {
return 'Failed to play audiobook: $error';
}
@override
String get noOtherPlayersAvailable => 'No other players available';
@override
String get transferQueue => 'Transfer queue';
@override
String get clearQueue => 'Clear queue';
@override
String syncingPlayer(String playerName) {
return 'Syncing $playerName...';
}
@override
String get switchPlayer => 'Switch Player';
@override
String get logsCopiedToClipboard => 'Logs copied to clipboard';
@override
String get auto => 'Auto';
@override
String get transferQueueTo => 'Transfer Queue To';
@override
String queueTransferredTo(String playerName) {
return 'Queue transferred to $playerName';
}
@override
String failedToTransferQueue(String error) {
return 'Failed to transfer queue: $error';
}
@override
String failedToLoadQueue(String error) {
return 'Failed to load queue: $error';
}
@override
String get transferTo => 'Transfer to...';
@override
String get transferQueueToPlayer => 'Transfer queue to another player';
@override
String get selectPlayerToPlay => 'Select a player';
@override
String get selectPlayerToPlayAlbum => 'Select player to play album';
@override
String get selectPlayerToPlayPlaylist => 'Select player to play playlist';
@override
String get selectPlayerForRadio => 'Select player for radio';
@override
String get selectPlayerForEpisode => 'Select player for episode';
@override
String get selectPlayerForAudiobook => 'Select player for audiobook';
@override
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get aboutAndSupport => 'Acerca de y Soporte';
@override
String get supportDevelopment => 'Apoyar el Desarrollo';
@override
String get ensembleIsUnofficial =>
'Ensemble es un cliente no oficial creado por la comunidad. Considera apoyar su desarrollo.';
@override
String get sponsorOnGitHub => 'Patrocina en GitHub';
@override
String get buyMeAKoFi => 'Cómprame un Ko-fi';
@override
String get aboutEnsemble => 'Acerca de Ensemble';
@override
String get version => 'Versión';
@override
String get visitGitHubRepo => 'Visitar Repositorio GitHub';
@override
String get reportABug => 'Reportar un Error';
@override
String get noRowsEnabled => 'No rows enabled';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
} }
+58 -335
View File
@@ -1,5 +1,5 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl; import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart'; import 'app_localizations.dart';
// ignore_for_file: type=lint // ignore_for_file: type=lint
@@ -39,8 +39,7 @@ class SFr extends S {
String get portHint => 'ex., 8095 ou laisser vide'; String get portHint => 'ex., 8095 ou laisser vide';
@override @override
String get portDescription => String get portDescription => 'Laisser vide pour les proxys inverses ou les ports standards. Entrer 8095 pour une connexion directe.';
'Laisser vide pour les proxys inverses ou les ports standards. Entrer 8095 pour une connexion directe.';
@override @override
String get username => 'Nom d\'utilisateur'; String get username => 'Nom d\'utilisateur';
@@ -61,12 +60,10 @@ class SFr extends S {
String get authServerUrlOptional => 'URL du serveur d\'auth (Facultatif)'; String get authServerUrlOptional => 'URL du serveur d\'auth (Facultatif)';
@override @override
String get authServerUrlHint => String get authServerUrlHint => 'ex., auth.example.com (si different du serveur)';
'ex., auth.example.com (si different du serveur)';
@override @override
String get authServerUrlDescription => String get authServerUrlDescription => 'Laisser vide si l\'authentification est sur le meme serveur';
'Laisser vide si l\'authentification est sur le meme serveur';
@override @override
String detectedAuthType(String authType) { String detectedAuthType(String authType) {
@@ -83,50 +80,40 @@ class SFr extends S {
String get unencryptedConnection => 'Connexion non chiffrée'; String get unencryptedConnection => 'Connexion non chiffrée';
@override @override
String get usingHttpOverTailscale => String get usingHttpOverTailscale => 'Utilisation de HTTP via Tailscale (tunnel chiffre)';
'Utilisation de HTTP via Tailscale (tunnel chiffre)';
@override @override
String get httpsFailedUsingHttp => String get httpsFailedUsingHttp => 'HTTPS a échoué, utilisation de HTTP en repli';
'HTTPS a échoué, utilisation de HTTP en repli';
@override @override
String get httpNotEncrypted => String get httpNotEncrypted => 'Connexion HTTP - les donnees ne sont pas chiffrées';
'Connexion HTTP - les donnees ne sont pas chiffrées';
@override @override
String get pleaseEnterServerAddress => String get pleaseEnterServerAddress => 'Veuillez entrer l\'adresse de votre serveur Music Assistant';
'Veuillez entrer l\'adresse de votre serveur Music Assistant';
@override @override
String get pleaseEnterName => 'Veuillez entrer votre nom'; String get pleaseEnterName => 'Veuillez entrer votre nom';
@override @override
String get pleaseEnterValidPort => String get pleaseEnterValidPort => 'Veuillez entrer un numero de port valide (1-65535)';
'Veuillez entrer un numero de port valide (1-65535)';
@override @override
String get pleaseEnterCredentials => String get pleaseEnterCredentials => 'Veuillez entrer le nom d\'utilisateur et le mot de passe';
'Veuillez entrer le nom d\'utilisateur et le mot de passe';
@override @override
String get authFailed => String get authFailed => 'Echec de l\'authentification. Veuillez verifier vos identifiants.';
'Echec de l\'authentification. Veuillez verifier vos identifiants.';
@override @override
String get maLoginFailed => String get maLoginFailed => 'Echec de la connexion a Music Assistant. Veuillez verifier vos identifiants.';
'Echec de la connexion a Music Assistant. Veuillez verifier vos identifiants.';
@override @override
String get connectionFailed => String get connectionFailed => 'Impossible de se connecter au serveur. Veuillez verifier l\'adresse et reessayer.';
'Impossible de se connecter au serveur. Veuillez verifier l\'adresse et reessayer.';
@override @override
String get detectingAuth => 'Detection de l\'authentification...'; String get detectingAuth => 'Detection de l\'authentification...';
@override @override
String get cannotDetermineAuth => String get cannotDetermineAuth => 'Impossible de determiner les exigences d\'authentification. Veuillez verifier l\'URL du serveur.';
'Impossible de determiner les exigences d\'authentification. Veuillez verifier l\'URL du serveur.';
@override @override
String get noAuthentication => 'Pas d\'authentification'; String get noAuthentication => 'Pas d\'authentification';
@@ -141,8 +128,7 @@ class SFr extends S {
String get musicAssistantLogin => 'Connexion Music Assistant'; String get musicAssistantLogin => 'Connexion Music Assistant';
@override @override
String get pressBackToMinimize => String get pressBackToMinimize => 'Appuyez a nouveau sur retour pour minimiser';
'Appuyez a nouveau sur retour pour minimiser';
@override @override
String get recentlyPlayed => 'Ecoutes recemment'; String get recentlyPlayed => 'Ecoutes recemment';
@@ -153,25 +139,6 @@ class SFr extends S {
@override @override
String get discoverAlbums => 'Découvrir des albums'; String get discoverAlbums => 'Découvrir des albums';
@override
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override @override
String get continueListening => 'Continuer l\'écoute'; String get continueListening => 'Continuer l\'écoute';
@@ -254,12 +221,10 @@ class SFr extends S {
String get noRadioStations => 'Aucune station de radio'; String get noRadioStations => 'Aucune station de radio';
@override @override
String get addRadioStationsHint => String get addRadioStationsHint => 'Ajouter des stations de radio dans Music Assistant';
'Ajouter des stations de radio dans Music Assistant';
@override @override
String get searchFailed => String get searchFailed => 'La recherche a échoué. Veuillez verifier votre connexion.';
'La recherche a échoué. Veuillez verifier votre connexion.';
@override @override
String get queue => 'File d\'attente'; String get queue => 'File d\'attente';
@@ -299,8 +264,7 @@ class SFr extends S {
String get system => 'Système'; String get system => 'Système';
@override @override
String get pullToRefresh => String get pullToRefresh => 'Tirez pour rafraichir la bibliothèque et appliquer les modifications';
'Tirez pour rafraichir la bibliothèque et appliquer les modifications';
@override @override
String get viewDebugLogs => 'Voir les journaux de debogage'; String get viewDebugLogs => 'Voir les journaux de debogage';
@@ -337,8 +301,7 @@ class SFr extends S {
String get logsCleared => 'Journaux effaces'; String get logsCleared => 'Journaux effaces';
@override @override
String get playerListCopied => String get playerListCopied => 'Liste des lecteurs copiee dans le presse-papiers !';
'Liste des lecteurs copiee dans le presse-papiers !';
@override @override
String get noLogsYet => 'Pas encore de journaux'; String get noLogsYet => 'Pas encore de journaux';
@@ -396,9 +359,6 @@ class SFr extends S {
@override @override
String get tracksAddedToQueue => 'Pistes ajoutées a la file d\'attente'; String get tracksAddedToQueue => 'Pistes ajoutées a la file d\'attente';
@override
String get trackAddedToQueue => 'Track added to queue';
@override @override
String get play => 'Lire'; String get play => 'Lire';
@@ -511,43 +471,37 @@ class SFr extends S {
String get noFavoriteAudiobooks => 'Aucun livre audio favori'; String get noFavoriteAudiobooks => 'Aucun livre audio favori';
@override @override
String get tapHeartAudiobook => String get tapHeartAudiobook => 'Appuyez sur le coeur d\'un livre audio pour l\'ajoutér aux favoris';
'Appuyez sur le coeur d\'un livre audio pour l\'ajoutér aux favoris';
@override @override
String get noAudiobooks => 'Aucun livre audio'; String get noAudiobooks => 'Aucun livre audio';
@override @override
String get addAudiobooksHint => String get addAudiobooksHint => 'Ajoutez des livres audio a votre bibliothèque pour les voir ici';
'Ajoutez des livres audio a votre bibliothèque pour les voir ici';
@override @override
String get noFavoriteArtists => 'Aucun artiste favori'; String get noFavoriteArtists => 'Aucun artiste favori';
@override @override
String get tapHeartArtist => String get tapHeartArtist => 'Appuyez sur le coeur d\'un artiste pour l\'ajoutér aux favoris';
'Appuyez sur le coeur d\'un artiste pour l\'ajoutér aux favoris';
@override @override
String get noFavoriteAlbums => 'Aucun album favori'; String get noFavoriteAlbums => 'Aucun album favori';
@override @override
String get tapHeartAlbum => String get tapHeartAlbum => 'Appuyez sur le coeur d\'un album pour l\'ajoutér aux favoris';
'Appuyez sur le coeur d\'un album pour l\'ajoutér aux favoris';
@override @override
String get noFavoritePlaylists => 'Aucune playlist favorite'; String get noFavoritePlaylists => 'Aucune playlist favorite';
@override @override
String get tapHeartPlaylist => String get tapHeartPlaylist => 'Appuyez sur le coeur d\'une playlist pour l\'ajoutér aux favoris';
'Appuyez sur le coeur d\'une playlist pour l\'ajoutér aux favoris';
@override @override
String get noFavoriteTracks => 'Aucune piste favorite'; String get noFavoriteTracks => 'Aucune piste favorite';
@override @override
String get longPressTrackHint => String get longPressTrackHint => 'Appuyez longuement sur une piste et touchez le coeur pour l\'ajoutér aux favoris';
'Appuyez longuement sur une piste et touchez le coeur pour l\'ajoutér aux favoris';
@override @override
String get loadSeries => 'Charger les séries'; String get loadSeries => 'Charger les séries';
@@ -559,8 +513,7 @@ class SFr extends S {
String get notConnectedTitle => 'Not Connected'; String get notConnectedTitle => 'Not Connected';
@override @override
String get connectHint => String get connectHint => 'Connectéz-vous a votre serveur Music Assistant pour commencer a écouter';
'Connectéz-vous a votre serveur Music Assistant pour commencer a écouter';
@override @override
String get configureServer => 'Configurer le serveur'; String get configureServer => 'Configurer le serveur';
@@ -596,8 +549,7 @@ class SFr extends S {
String get noLogsToCopy => 'Aucun journal a copier'; String get noLogsToCopy => 'Aucun journal a copier';
@override @override
String get noDebugLogsYet => String get noDebugLogsYet => 'Pas encore de journaux de debogage. Essayez de détectér l\'authentification.';
'Pas encore de journaux de debogage. Essayez de détectér l\'authentification.';
@override @override
String get showDebug => 'Afficher le debogage'; String get showDebug => 'Afficher le debogage';
@@ -612,8 +564,7 @@ class SFr extends S {
String get noChapters => 'Aucun chapitre'; String get noChapters => 'Aucun chapitre';
@override @override
String get noChapterInfo => String get noChapterInfo => 'Ce livre audio n\'a pas d\'informations de chapitre';
'Ce livre audio n\'a pas d\'informations de chapitre';
@override @override
String errorSeeking(String error) { String errorSeeking(String error) {
@@ -673,18 +624,6 @@ class SFr extends S {
@override @override
String get sortBySeriesOrder => 'Trier par ordre de série'; String get sortBySeriesOrder => 'Trier par ordre de série';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override @override
String get listView => 'Vue en liste'; String get listView => 'Vue en liste';
@@ -698,12 +637,10 @@ class SFr extends S {
String get artist => 'Artiste'; String get artist => 'Artiste';
@override @override
String get showRecentlyPlayedAlbums => String get showRecentlyPlayedAlbums => 'Afficher les albums écoutes recemment';
'Afficher les albums écoutes recemment';
@override @override
String get showRandomArtists => String get showRandomArtists => 'Afficher des artistes aléatoires a découvrir';
'Afficher des artistes aléatoires a découvrir';
@override @override
String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir'; String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir';
@@ -712,46 +649,34 @@ class SFr extends S {
String get showAudiobooksInProgress => 'Afficher les livres audio en cours'; String get showAudiobooksInProgress => 'Afficher les livres audio en cours';
@override @override
String get showRandomAudiobooks => String get showRandomAudiobooks => 'Afficher des livres audio aléatoires a découvrir';
'Afficher des livres audio aléatoires a découvrir';
@override @override
String get showRandomSeries => String get showRandomSeries => 'Afficher des séries de livres audio aléatoires a découvrir';
'Afficher des séries de livres audio aléatoires a découvrir';
@override @override
String get showFavoriteAlbums => 'Afficher une rangee de vos albums favoris'; String get showFavoriteAlbums => 'Afficher une rangee de vos albums favoris';
@override @override
String get showFavoriteArtists => String get showFavoriteArtists => 'Afficher une rangee de vos artistes favoris';
'Afficher une rangee de vos artistes favoris';
@override @override
String get showFavoriteTracks => String get showFavoriteTracks => 'Afficher une rangee de vos pistes favorites';
'Afficher une rangee de vos pistes favorites';
@override @override
String get showFavoritePlaylists => String get showFavoritePlaylists => 'Afficher une rangee de vos playlists favorites';
'Afficher une rangee de vos playlists favorites';
@override @override
String get showFavoriteRadioStations => String get showFavoriteRadioStations => 'Afficher une rangee de vos stations de radio favorites';
'Afficher une rangee de vos stations de radio favorites';
@override @override
String get showFavoritePodcasts => String get showFavoritePodcasts => 'Afficher une rangee de vos podcasts favoris';
'Afficher une rangee de vos podcasts favoris';
@override @override
String get extractColorsFromArtwork => String get extractColorsFromArtwork => 'Extraire les couleurs des pochettes d\'albums et d\'artistes';
'Extraire les couleurs des pochettes d\'albums et d\'artistes';
@override @override
String get chooseHomeScreenRows => String get chooseHomeScreenRows => 'Choisir les rangees a afficher sur l\'ecran d\'accueil';
'Choisir les rangees a afficher sur l\'ecran d\'accueil';
@override
String get favorite => 'Favorite';
@override @override
String get addedToFavorites => 'Ajoute aux favoris'; String get addedToFavorites => 'Ajoute aux favoris';
@@ -796,15 +721,13 @@ class SFr extends S {
String get podcasts => 'Podcasts'; String get podcasts => 'Podcasts';
@override @override
String get podcastSupportComingSoon => String get podcastSupportComingSoon => 'Prise en charge des podcasts bientot disponible';
'Prise en charge des podcasts bientot disponible';
@override @override
String get noPodcasts => 'Aucun podcast'; String get noPodcasts => 'Aucun podcast';
@override @override
String get addPodcastsHint => String get addPodcastsHint => 'Abonnez-vous a des podcasts dans Music Assistant';
'Abonnez-vous a des podcasts dans Music Assistant';
@override @override
String get episodes => 'Episodes'; String get episodes => 'Episodes';
@@ -851,8 +774,7 @@ class SFr extends S {
String get loadingChapters => 'Chargement des chapitres...'; String get loadingChapters => 'Chargement des chapitres...';
@override @override
String get noChapterInfoAvailable => String get noChapterInfoAvailable => 'Aucune information de chapitre disponible';
'Aucune information de chapitre disponible';
@override @override
String percentComplete(int percent) { String percentComplete(int percent) {
@@ -863,32 +785,17 @@ class SFr extends S {
String get theAudioDbApiKey => 'Cle API TheAudioDB'; String get theAudioDbApiKey => 'Cle API TheAudioDB';
@override @override
String get theAudioDbApiKeyHint => String get theAudioDbApiKeyHint => 'Utilisez \"2\" pour le niveau gratuit ou une cle premium';
'Utilisez \"2\" pour le niveau gratuit ou une cle premium';
@override @override
String get audiobookLibraries => 'Bibliothèques de livres audio'; String get audiobookLibraries => 'Bibliothèques de livres audio';
@override @override
String get chooseAudiobookLibraries => String get chooseAudiobookLibraries => 'Choisir quelles bibliothèques Audiobookshelf inclure';
'Choisir quelles bibliothèques Audiobookshelf inclure';
@override @override
String get unknownLibrary => 'Bibliothèque inconnue'; String get unknownLibrary => 'Bibliothèque inconnue';
@override
String get musicProviders => 'Music Providers';
@override
String get musicProvidersDescription =>
'Choose which accounts to show in your library';
@override
String get libraryRefreshing => 'Refreshing library...';
@override
String get cannotDisableLastProvider => 'Cannot disable the last provider';
@override @override
String get noSeriesFound => 'Aucune série trouvee'; String get noSeriesFound => 'Aucune série trouvee';
@@ -901,19 +808,16 @@ class SFr extends S {
} }
@override @override
String get noSeriesAvailable => String get noSeriesAvailable => 'Aucune série disponible dans votre bibliothèque de livres audio.\nTirez pour rafraichir.';
'Aucune série disponible dans votre bibliothèque de livres audio.\nTirez pour rafraichir.';
@override @override
String get pullToLoadSeries => String get pullToLoadSeries => 'Tirez vers le bas pour charger les séries\ndepuis Music Assistant';
'Tirez vers le bas pour charger les séries\ndepuis Music Assistant';
@override @override
String get search => 'Rechercher'; String get search => 'Rechercher';
@override @override
String get metadataApisDescription => String get metadataApisDescription => 'Les images d\'artistes sont automatiquement recuperees depuis Deezer. Ajoutez les cles API ci-dessous pour les biographies d\'artistes et les descriptions d\'albums.';
'Les images d\'artistes sont automatiquement recuperees depuis Deezer. Ajoutez les cles API ci-dessous pour les biographies d\'artistes et les descriptions d\'albums.';
@override @override
String get lastFmApiKey => 'Cle API Last.fm'; String get lastFmApiKey => 'Cle API Last.fm';
@@ -936,18 +840,11 @@ class SFr extends S {
String get playOn => 'Lire sur...'; String get playOn => 'Lire sur...';
@override @override
String get addAlbumToQueueOn => String get addAlbumToQueueOn => 'Ajouter l\'album a la file d\'attente sur...';
'Ajouter l\'album a la file d\'attente sur...';
@override
String get addToQueue => 'Add to queue';
@override @override
String get addToQueueOn => 'Ajouter a la file d\'attente sur...'; String get addToQueueOn => 'Ajouter a la file d\'attente sur...';
@override
String get startRadio => 'Start radio';
@override @override
String startRadioOn(String name) { String startRadioOn(String name) {
return 'Demarrer la radio $name sur...'; return 'Demarrer la radio $name sur...';
@@ -980,8 +877,7 @@ class SFr extends S {
} }
@override @override
String get materialYouDescription => String get materialYouDescription => 'Utiliser les couleurs systeme (Android 12+)';
'Utiliser les couleurs systeme (Android 12+)';
@override @override
String get accentColor => 'Couleur d\'accentuation'; String get accentColor => 'Couleur d\'accentuation';
@@ -993,22 +889,13 @@ class SFr extends S {
String get preferLocalPlayer => 'Préférer le lecteur local'; String get preferLocalPlayer => 'Préférer le lecteur local';
@override @override
String get preferLocalPlayerDescription => String get preferLocalPlayerDescription => 'Toujours sélectionner cet appareil en premier si disponible';
'Toujours sélectionner cet appareil en premier si disponible';
@override @override
String get smartSortPlayers => 'Tri intelligent'; String get smartSortPlayers => 'Tri intelligent';
@override @override
String get smartSortPlayersDescription => String get smartSortPlayersDescription => 'Trier par statut (en lecture, allume, eteint) au lieu d\'alphabétiquement';
'Trier par statut (en lecture, allume, eteint) au lieu d\'alphabétiquement';
@override
String get disableAutoSwitch => 'Disable Auto-Switch';
@override
String get disableAutoSwitchDescription =>
'Never automatically switch to a different player';
@override @override
String get playerStateUnavailable => 'Indisponible'; String get playerStateUnavailable => 'Indisponible';
@@ -1040,25 +927,7 @@ class SFr extends S {
String get showHints => 'Afficher les astuces'; String get showHints => 'Afficher les astuces';
@override @override
String get showHintsDescription => String get showHintsDescription => 'Afficher des conseils utiles pour découvrir les fonctionnalites';
'Afficher des conseils utiles pour découvrir les fonctionnalites';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override
String get display => 'Display';
@override
String get showProviderIcons => 'Show Provider Icons';
@override
String get showProviderIconsDescription =>
'Display provider logos on album art';
@override @override
String get pullToSelectPlayers => 'Tirez pour sélectionner les lecteurs'; String get pullToSelectPlayers => 'Tirez pour sélectionner les lecteurs';
@@ -1070,22 +939,19 @@ class SFr extends S {
String get swipeToAdjustVolume => 'Balayez pour ajuster le volume'; String get swipeToAdjustVolume => 'Balayez pour ajuster le volume';
@override @override
String get selectPlayerHint => String get selectPlayerHint => 'Choisissez un lecteur ou fermez en balayant vers le bas';
'Choisissez un lecteur ou fermez en balayant vers le bas';
@override @override
String get welcomeToEnsemble => 'Bienvenue dans Ensemble'; String get welcomeToEnsemble => 'Bienvenue dans Ensemble';
@override @override
String get welcomeMessage => String get welcomeMessage => 'Par defaut, votre telephone est le lecteur selectionne.\nTirez le mini-lecteur vers le bas pour sélectionner un autre lecteur.';
'Par defaut, votre telephone est le lecteur selectionne.\nTirez le mini-lecteur vers le bas pour sélectionner un autre lecteur.';
@override @override
String get skip => 'Passer'; String get skip => 'Passer';
@override @override
String get dismissPlayerHint => String get dismissPlayerHint => 'Balayez vers le bas, touchez a l\'exterieur ou appuyez sur retour pour revenir';
'Balayez vers le bas, touchez a l\'exterieur ou appuyez sur retour pour revenir';
@override @override
String playingAlbum(String albumName) { String playingAlbum(String albumName) {
@@ -1101,160 +967,17 @@ class SFr extends S {
String get noTracks => 'No tracks'; String get noTracks => 'No tracks';
@override @override
String get addTracksHint => String get addTracksHint => 'Add some music to your library to see tracks here';
'Add some music to your library to see tracks here';
@override @override
String get noFavoriteRadioStations => 'No favorite radio stations'; String get noFavoriteRadioStations => 'No favorite radio stations';
@override @override
String get longPressRadioHint => String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
'Long-press a station and tap the heart to add it to favorites';
@override @override
String get noFavoritePodcasts => 'No favorite podcasts'; String get noFavoritePodcasts => 'No favorite podcasts';
@override @override
String get longPressPodcastHint => String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
'Long-press a podcast and tap the heart to add it to favorites';
@override
String failedToPlayRadioStation(String error) {
return 'Failed to play radio station: $error';
}
@override
String get itemAlreadyInLibrary => 'Item is already in library';
@override
String get failedToAddToLibrary => 'Failed to add to library';
@override
String get cannotFindLibraryId => 'Cannot find library ID for removal';
@override
String get failedToRemoveFromLibrary => 'Failed to remove from library';
@override
String failedToPlayAlbum(String error) {
return 'Failed to play album: $error';
}
@override
String playingOnPlayer(String name, String playerName) {
return 'Playing $name on $playerName';
}
@override
String failedToPlayPlaylist(String error) {
return 'Failed to play playlist: $error';
}
@override
String failedToPlayAudiobook(String error) {
return 'Failed to play audiobook: $error';
}
@override
String get noOtherPlayersAvailable => 'No other players available';
@override
String get transferQueue => 'Transfer queue';
@override
String get clearQueue => 'Clear queue';
@override
String syncingPlayer(String playerName) {
return 'Syncing $playerName...';
}
@override
String get switchPlayer => 'Switch Player';
@override
String get logsCopiedToClipboard => 'Logs copied to clipboard';
@override
String get auto => 'Auto';
@override
String get transferQueueTo => 'Transfer Queue To';
@override
String queueTransferredTo(String playerName) {
return 'Queue transferred to $playerName';
}
@override
String failedToTransferQueue(String error) {
return 'Failed to transfer queue: $error';
}
@override
String failedToLoadQueue(String error) {
return 'Failed to load queue: $error';
}
@override
String get transferTo => 'Transfer to...';
@override
String get transferQueueToPlayer => 'Transfer queue to another player';
@override
String get selectPlayerToPlay => 'Select a player';
@override
String get selectPlayerToPlayAlbum => 'Select player to play album';
@override
String get selectPlayerToPlayPlaylist => 'Select player to play playlist';
@override
String get selectPlayerForRadio => 'Select player for radio';
@override
String get selectPlayerForEpisode => 'Select player for episode';
@override
String get selectPlayerForAudiobook => 'Select player for audiobook';
@override
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get aboutAndSupport => 'À propos et Support';
@override
String get supportDevelopment => 'Soutenir le Développement';
@override
String get ensembleIsUnofficial =>
'Ensemble est un client non officiel créé par la communauté. Envisagez de soutenir son développement.';
@override
String get sponsorOnGitHub => 'Sponsoriser sur GitHub';
@override
String get buyMeAKoFi => 'Offrez-moi un Ko-fi';
@override
String get aboutEnsemble => 'À propos d\'Ensemble';
@override
String get version => 'Version';
@override
String get visitGitHubRepo => 'Visiter le Dépôt GitHub';
@override
String get reportABug => 'Signaler un Bug';
@override
String get noRowsEnabled => 'No rows enabled';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
} }
+54 -202
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
@@ -332,9 +223,8 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
} }
// Then check expanded player // Then check expanded player
// No haptic - Android back gesture provides its own
if (GlobalPlayerOverlay.isPlayerExpanded) { if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer(withHaptic: false); GlobalPlayerOverlay.collapsePlayer();
return true; // We handled it, don't let Navigator process it return true; // We handled it, don't let Navigator process it
} }
@@ -361,21 +251,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, _) {
@@ -571,120 +449,94 @@ 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();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_settingsLoaded = true; _settingsLoaded = true;
}); });
_logger.log('🚀 AppStartup: Settings loaded, hasCompletedOnboarding=$_hasCompletedOnboarding'); _logger.log('🚀 AppStartup: Settings loaded, hasCompletedOnboarding=$_hasCompletedOnboarding');
} catch (e) {
_logger.error('AppStartup settings load error: $e');
if (mounted) {
setState(() {
_settingsLoaded = true;
_hasCompletedOnboarding = true; // Default to true on error
});
}
}
// Now check connection // 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;
setState(() {
_savedServerUrl = serverUrl;
});
// If we have a saved server URL, attempt auto-connection
if (serverUrl != null && serverUrl.isNotEmpty) {
setState(() { setState(() {
_savedServerUrl = serverUrl; _isConnecting = true;
}); });
// If we have a saved server URL, attempt auto-connection final provider = context.read<MusicAssistantProvider>();
if (serverUrl != null && serverUrl.isNotEmpty) {
setState(() {
_isConnecting = true;
});
final provider = context.read<MusicAssistantProvider>(); // Connection is handled by MusicAssistantProvider._initialize()
// Just wait for it to complete or timeout
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
// Connection is handled by MusicAssistantProvider._initialize() // For first-time users, we need to wait for both connection AND player selection
// Just wait for it to complete or timeout // so the welcome overlay can appear immediately without home screen flash.
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl'); // For returning users, just wait for connection.
final needsWelcome = !_hasCompletedOnboarding;
// For first-time users, we need to wait for both connection AND player selection // Give the provider time to connect (it restores credentials and connects in _initialize)
// so the welcome overlay can appear immediately without home screen flash. // Check connection state periodically
// For returning users, just wait for connection. for (var i = 0; i < 20; i++) {
final needsWelcome = !_hasCompletedOnboarding; await Future.delayed(const Duration(milliseconds: 250));
// Give the provider time to connect (it restores credentials and connects in _initialize) if (needsWelcome) {
// Check connection state periodically // First-time user: wait for connection + player selection
for (var i = 0; i < 20; i++) { if (provider.isConnected && provider.selectedPlayer != null) {
await Future.delayed(const Duration(milliseconds: 250)); _logger.log('🚀 AppStartup: First-time user ready (connected + player selected)');
break;
if (needsWelcome) {
// First-time user: wait for connection + player selection
if (provider.isConnected && provider.selectedPlayer != null) {
_logger.log('🚀 AppStartup: First-time user ready (connected + player selected)');
break;
}
} else {
// Returning user: just wait for connection
if (provider.isConnected) {
_logger.log('🚀 AppStartup: Connection established');
break;
}
} }
} else {
if (provider.connectionState == MAConnectionState.error) { // Returning user: just wait for connection
_logger.log('🚀 AppStartup: Connection failed with error'); if (provider.isConnected) {
_logger.log('🚀 AppStartup: Connection established');
break; break;
} }
} }
if (!provider.isConnected) { if (provider.connectionState == MAConnectionState.error) {
_logger.log('🚀 AppStartup: Connection still pending or failed'); _logger.log('🚀 AppStartup: Connection failed with error');
} break;
if (mounted) {
setState(() {
_isConnecting = false;
});
} }
} }
} catch (e) {
_logger.error('AppStartup connection error: $e'); if (!provider.isConnected) {
_logger.log('🚀 AppStartup: Connection still pending or failed');
}
if (mounted) { if (mounted) {
setState(() { setState(() {
_isConnecting = false; _isConnecting = false;
_connectionAttempted = true;
}); });
} }
} else {
setState(() {
_connectionAttempted = true;
});
} }
_connectionAttempted = true;
} }
@override @override
+67 -62
View File
@@ -16,10 +16,6 @@ class ProviderMapping {
final String providerInstance; final String providerInstance;
final bool available; final bool available;
final Map<String, dynamic>? audioFormat; final Map<String, dynamic>? audioFormat;
/// Whether this item is in the library for this specific provider instance.
/// True = this provider "owns" the item (user added it from this account)
/// False = item exists but wasn't added from this provider
final bool inLibrary;
ProviderMapping({ ProviderMapping({
required this.itemId, required this.itemId,
@@ -27,7 +23,6 @@ class ProviderMapping {
required this.providerInstance, required this.providerInstance,
required this.available, required this.available,
this.audioFormat, this.audioFormat,
this.inLibrary = true,
}); });
factory ProviderMapping.fromJson(Map<String, dynamic> json) { factory ProviderMapping.fromJson(Map<String, dynamic> json) {
@@ -37,8 +32,6 @@ class ProviderMapping {
providerInstance: json['provider_instance'] as String? ?? '', providerInstance: json['provider_instance'] as String? ?? '',
available: json['available'] as bool? ?? true, available: json['available'] as bool? ?? true,
audioFormat: json['audio_format'] as Map<String, dynamic>?, audioFormat: json['audio_format'] as Map<String, dynamic>?,
// Parse in_library field - MA uses 1/0 or true/false
inLibrary: json['in_library'] == true || json['in_library'] == 1,
); );
} }
@@ -49,7 +42,6 @@ class ProviderMapping {
'provider_instance': providerInstance, 'provider_instance': providerInstance,
'available': available, 'available': available,
if (audioFormat != null) 'audio_format': audioFormat, if (audioFormat != null) 'audio_format': audioFormat,
'in_library': inLibrary,
}; };
} }
} }
@@ -66,6 +58,10 @@ class MediaItem {
final bool? favorite; final bool? favorite;
final int? position; final int? position;
final Duration? duration; final Duration? duration;
final DateTime? timestampAdded;
final DateTime? timestampModified;
final DateTime? lastPlayed;
final int? playCount;
MediaItem({ MediaItem({
required this.itemId, required this.itemId,
@@ -79,6 +75,10 @@ class MediaItem {
this.favorite, this.favorite,
this.position, this.position,
this.duration, this.duration,
this.timestampAdded,
this.timestampModified,
this.lastPlayed,
this.playCount,
}); });
factory MediaItem.fromJson(Map<String, dynamic> json) { factory MediaItem.fromJson(Map<String, dynamic> json) {
@@ -91,6 +91,21 @@ class MediaItem {
); );
} }
// Parse timestamp fields (can be int unix timestamp or ISO string)
DateTime? parseTimestamp(dynamic value) {
if (value == null) return null;
if (value is int) {
return DateTime.fromMillisecondsSinceEpoch(value * 1000);
}
if (value is double) {
return DateTime.fromMillisecondsSinceEpoch((value * 1000).toInt());
}
if (value is String) {
return DateTime.tryParse(value);
}
return null;
}
return MediaItem( return MediaItem(
itemId: json['item_id']?.toString() ?? json['id']?.toString() ?? '', itemId: json['item_id']?.toString() ?? json['id']?.toString() ?? '',
provider: json['provider'] as String? ?? 'unknown', provider: json['provider'] as String? ?? 'unknown',
@@ -107,6 +122,10 @@ class MediaItem {
duration: json['duration'] != null duration: json['duration'] != null
? Duration(seconds: (json['duration'] as num).toInt()) ? Duration(seconds: (json['duration'] as num).toInt())
: null, : null,
timestampAdded: parseTimestamp(json['timestamp_added']),
timestampModified: parseTimestamp(json['timestamp_modified']),
lastPlayed: parseTimestamp(json['last_played']),
playCount: json['play_count'] as int?,
); );
} }
@@ -124,25 +143,12 @@ class MediaItem {
if (favorite != null) 'favorite': favorite, if (favorite != null) 'favorite': favorite,
if (position != null) 'position': position, if (position != null) 'position': position,
if (duration != null) 'duration': duration!.inSeconds, if (duration != null) 'duration': duration!.inSeconds,
if (timestampAdded != null) 'timestamp_added': timestampAdded!.millisecondsSinceEpoch ~/ 1000,
if (timestampModified != null) 'timestamp_modified': timestampModified!.millisecondsSinceEpoch ~/ 1000,
if (lastPlayed != null) 'last_played': lastPlayed!.millisecondsSinceEpoch ~/ 1000,
if (playCount != null) 'play_count': playCount,
}; };
} }
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MediaItem &&
runtimeType == other.runtimeType &&
itemId == other.itemId &&
provider == other.provider;
@override
int get hashCode => Object.hash(itemId, provider);
/// Check if this item is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
} }
class Artist extends MediaItem { class Artist extends MediaItem {
@@ -155,6 +161,10 @@ class Artist extends MediaItem {
super.providerMappings, super.providerMappings,
super.metadata, super.metadata,
super.favorite, super.favorite,
super.timestampAdded,
super.timestampModified,
super.lastPlayed,
super.playCount,
}) : super(mediaType: MediaType.artist); }) : super(mediaType: MediaType.artist);
factory Artist.fromJson(Map<String, dynamic> json) { factory Artist.fromJson(Map<String, dynamic> json) {
@@ -168,15 +178,13 @@ class Artist extends MediaItem {
providerMappings: item.providerMappings, providerMappings: item.providerMappings,
metadata: item.metadata, metadata: item.metadata,
favorite: item.favorite, favorite: item.favorite,
timestampAdded: item.timestampAdded,
timestampModified: item.timestampModified,
lastPlayed: item.lastPlayed,
playCount: item.playCount,
); );
} }
/// Check if this artist is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
@override @override
Map<String, dynamic> toJson() => super.toJson(); Map<String, dynamic> toJson() => super.toJson();
} }
@@ -198,6 +206,10 @@ class Album extends MediaItem {
super.providerMappings, super.providerMappings,
super.metadata, super.metadata,
super.favorite, super.favorite,
super.timestampAdded,
super.timestampModified,
super.lastPlayed,
super.playCount,
}) : super(mediaType: MediaType.album); }) : super(mediaType: MediaType.album);
factory Album.fromJson(Map<String, dynamic> json) { factory Album.fromJson(Map<String, dynamic> json) {
@@ -225,6 +237,10 @@ class Album extends MediaItem {
providerMappings: item.providerMappings, providerMappings: item.providerMappings,
metadata: item.metadata, metadata: item.metadata,
favorite: item.favorite, favorite: item.favorite,
timestampAdded: item.timestampAdded,
timestampModified: item.timestampModified,
lastPlayed: item.lastPlayed,
playCount: item.playCount,
); );
} }
@@ -271,6 +287,10 @@ class Track extends MediaItem {
super.favorite, super.favorite,
super.position, super.position,
super.duration, super.duration,
super.timestampAdded,
super.timestampModified,
super.lastPlayed,
super.playCount,
}) : super(mediaType: MediaType.track); }) : super(mediaType: MediaType.track);
factory Track.fromJson(Map<String, dynamic> json) { factory Track.fromJson(Map<String, dynamic> json) {
@@ -292,18 +312,16 @@ class Track extends MediaItem {
favorite: item.favorite, favorite: item.favorite,
position: item.position, position: item.position,
duration: item.duration, duration: item.duration,
timestampAdded: item.timestampAdded,
timestampModified: item.timestampModified,
lastPlayed: item.lastPlayed,
playCount: item.playCount,
); );
} }
String get artistsString => String get artistsString =>
artists?.map((a) => a.name).join(', ') ?? 'Unknown Artist'; artists?.map((a) => a.name).join(', ') ?? 'Unknown Artist';
/// Check if this track is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = super.toJson(); final json = super.toJson();
@@ -332,6 +350,10 @@ class Playlist extends MediaItem {
super.providerMappings, super.providerMappings,
super.metadata, super.metadata,
super.favorite, super.favorite,
super.timestampAdded,
super.timestampModified,
super.lastPlayed,
super.playCount,
}) : super(mediaType: MediaType.playlist); }) : super(mediaType: MediaType.playlist);
factory Playlist.fromJson(Map<String, dynamic> json) { factory Playlist.fromJson(Map<String, dynamic> json) {
@@ -348,14 +370,12 @@ class Playlist extends MediaItem {
providerMappings: item.providerMappings, providerMappings: item.providerMappings,
metadata: item.metadata, metadata: item.metadata,
favorite: item.favorite, favorite: item.favorite,
timestampAdded: item.timestampAdded,
timestampModified: item.timestampModified,
lastPlayed: item.lastPlayed,
playCount: item.playCount,
); );
} }
/// Check if this playlist is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
} }
class Chapter { class Chapter {
@@ -464,20 +484,9 @@ class Audiobook extends MediaItem {
factory Audiobook.fromJson(Map<String, dynamic> json) { factory Audiobook.fromJson(Map<String, dynamic> json) {
final item = MediaItem.fromJson(json); final item = MediaItem.fromJson(json);
// Parse year - check top level first, then metadata.release_date (ABS format) // Parse year
int? year; int? year;
final metadata = json['metadata'] as Map<String, dynamic>?; final yearValue = json['year'];
dynamic yearValue = json['year'];
if (yearValue == null && metadata != null) {
// ABS stores year in metadata.release_date (can be "2024" or "2024-01-15")
final releaseDate = metadata['release_date'];
if (releaseDate is String && releaseDate.isNotEmpty) {
// Extract year from date string (handles both "2024" and "2024-01-15" formats)
yearValue = releaseDate.split('-').first;
} else if (releaseDate is int) {
yearValue = releaseDate;
}
}
if (yearValue is int) { if (yearValue is int) {
year = yearValue; year = yearValue;
} else if (yearValue is String) { } else if (yearValue is String) {
@@ -507,6 +516,8 @@ class Audiobook extends MediaItem {
// Parse chapters - check top level first, then metadata // Parse chapters - check top level first, then metadata
List<Chapter>? chapters; List<Chapter>? chapters;
final metadata = json['metadata'] as Map<String, dynamic>?;
dynamic chaptersData = json['chapters']; dynamic chaptersData = json['chapters'];
if (chaptersData == null && metadata != null) { if (chaptersData == null && metadata != null) {
chaptersData = metadata['chapters']; chaptersData = metadata['chapters'];
@@ -552,12 +563,6 @@ class Audiobook extends MediaItem {
String get narratorsString => String get narratorsString =>
narrators?.map((n) => n.name).join(', ') ?? 'Unknown Narrator'; narrators?.map((n) => n.name).join(', ') ?? 'Unknown Narrator';
/// Check if this audiobook is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
/// Get progress as a percentage (0.0 to 1.0) /// Get progress as a percentage (0.0 to 1.0)
double get progress { double get progress {
if (fullyPlayed == true) return 1.0; if (fullyPlayed == true) return 1.0;
+24 -41
View File
@@ -17,7 +17,6 @@ class Player {
final String? activeSource; // The currently active source for this player final String? activeSource; // The currently active source for this player
final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active
final String? appId; // The app_id from MA - 'music_assistant' when MA is playing, else external source final String? appId; // The app_id from MA - 'music_assistant' when MA is playing, else external source
final String? activeQueue; // The active_queue from MA - has value when MA is controlling playback
Player({ Player({
required this.playerId, required this.playerId,
@@ -36,7 +35,6 @@ class Player {
this.activeSource, this.activeSource,
this.isExternalSource = false, this.isExternalSource = false,
this.appId, this.appId,
this.activeQueue,
}); });
/// Create a copy of this Player with some fields replaced /// Create a copy of this Player with some fields replaced
@@ -57,7 +55,6 @@ class Player {
String? activeSource, String? activeSource,
bool? isExternalSource, bool? isExternalSource,
String? appId, String? appId,
String? activeQueue,
}) { }) {
return Player( return Player(
playerId: playerId ?? this.playerId, playerId: playerId ?? this.playerId,
@@ -76,7 +73,6 @@ class Player {
activeSource: activeSource ?? this.activeSource, activeSource: activeSource ?? this.activeSource,
isExternalSource: isExternalSource ?? this.isExternalSource, isExternalSource: isExternalSource ?? this.isExternalSource,
appId: appId ?? this.appId, appId: appId ?? this.appId,
activeQueue: activeQueue ?? this.activeQueue,
); );
} }
@@ -222,61 +218,50 @@ class Player {
// Parse active_source - the currently active source for this player // Parse active_source - the currently active source for this player
final activeSource = json['active_source'] as String?; final activeSource = json['active_source'] as String?;
// Parse active_queue - this is the key indicator for MA-controlled playback // Detect external source (optical, Spotify, AirPlay, etc.)
// When MA is playing: active_queue = 'uuid:...' or similar queue ID // Primary indicator: app_id - if not 'music_assistant', it's an external source
// When external source: active_queue = null // Secondary indicators: URI patterns and media_type
final activeQueue = json['active_queue'] as String?;
// Parse app_id - helps distinguish external sources on DLNA players
final appId = json['app_id'] as String?;
// Detect external source (optical, Spotify Connect, AirPlay, etc.)
//
// Detection logic priority:
// 1. If app_id == 'music_assistant' -> MA is playing, NOT external
// 2. If active_queue has a value -> MA has a queue, NOT external
// (This handles Spotify as music provider where URI is spotify:// but MA controls playback)
// 3. If app_id is set and NOT 'music_assistant' -> external source (e.g., 'http', 'spotify')
// 4. Fallback: Check URI patterns for simple external IDs (optical, line_in, etc.)
bool isExternalSource = false; bool isExternalSource = false;
// If MA explicitly says it's playing, trust that // Check app_id first - this is the most reliable indicator for DLNA players
if (appId == 'music_assistant') { // When MA is playing: app_id = 'music_assistant'
isExternalSource = false; // When external source: app_id = 'http', 'spotify', etc.
} final appId = json['app_id'] as String?;
// If there's an active queue, MA is controlling playback (even with spotify:// URIs) if (appId != null && appId.isNotEmpty && appId != 'music_assistant') {
else if (activeQueue != null && activeQueue.isNotEmpty) { // app_id is set but not 'music_assistant' - external source is active
isExternalSource = false;
}
// If app_id is set to something else (http, spotify, etc.), it's external
else if (appId != null && appId.isNotEmpty) {
isExternalSource = true; isExternalSource = true;
} }
// Fallback: check current_media for simple external source indicators
// Only check these when we have no app_id and no active_queue // Also check current_media for additional external source indicators
else if (json.containsKey('current_media')) { if (!isExternalSource && json.containsKey('current_media')) {
final currentMedia = json['current_media'] as Map<String, dynamic>?; final currentMedia = json['current_media'] as Map<String, dynamic>?;
if (currentMedia != null) { if (currentMedia != null) {
final uri = currentMedia['uri'] as String?; final uri = currentMedia['uri'] as String?;
final mediaType = currentMedia['media_type'] as String?; final mediaType = currentMedia['media_type'] as String?;
// Only check for simple external source identifiers (physical inputs) // External source indicators:
// Do NOT flag spotify://, airplay://, etc. as external here - // 1. URI is a simple identifier like 'optical', 'line_in', 'bluetooth', 'hdmi'
// those should only be external if app_id indicates external control // 2. URI starts with external protocols: 'spotify://', 'airplay://', etc.
// 3. Media type is 'unknown' (MA doesn't recognize the source)
if (uri != null) { if (uri != null) {
final uriLower = uri.toLowerCase(); final uriLower = uri.toLowerCase();
// Simple external source identifiers (no :// or /) // Simple external source identifiers (no :// or /)
// These are physical inputs that are always external
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') && final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' || (uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' || uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
uriLower == 'coaxial' || uriLower == 'toslink'); uriLower == 'coaxial' || uriLower == 'toslink');
// External streaming protocols
final isExternalProtocol = uriLower.startsWith('spotify://') ||
uriLower.startsWith('airplay://') ||
uriLower.startsWith('cast://') ||
uriLower.startsWith('bluetooth://');
isExternalSource = isSimpleExternalId; isExternalSource = isSimpleExternalId || isExternalProtocol;
} }
// Also check media_type - 'unknown' with non-MA URI indicates external source // Also check media_type - 'unknown' often indicates external source
if (!isExternalSource && mediaType == 'unknown') { if (!isExternalSource && mediaType == 'unknown') {
// If media_type is unknown and URI doesn't look like MA content, it's external
final uri = currentMedia['uri'] as String?; final uri = currentMedia['uri'] as String?;
if (uri != null && !uri.startsWith('library://') && !uri.contains('://track/')) { if (uri != null && !uri.startsWith('library://') && !uri.contains('://track/')) {
isExternalSource = true; isExternalSource = true;
@@ -302,7 +287,6 @@ class Player {
activeSource: activeSource, activeSource: activeSource,
isExternalSource: isExternalSource, isExternalSource: isExternalSource,
appId: appId, appId: appId,
activeQueue: activeQueue,
); );
} }
@@ -324,7 +308,6 @@ class Player {
if (activeSource != null) 'active_source': activeSource, if (activeSource != null) 'active_source': activeSource,
'is_external_source': isExternalSource, 'is_external_source': isExternalSource,
if (appId != null) 'app_id': appId, if (appId != null) 'app_id': appId,
if (activeQueue != null) 'active_queue': activeQueue,
}; };
} }
} }
-128
View File
@@ -1,128 +0,0 @@
/// Represents a music provider instance from Music Assistant.
///
/// Each provider (like Spotify, Tidal) can have multiple instances
/// (e.g., two different Spotify accounts). This class represents
/// a single provider instance with its unique ID.
class ProviderInstance {
/// Unique identifier for this provider instance (e.g., "spotify--abc123")
final String instanceId;
/// The provider domain/type (e.g., "spotify", "tidal", "qobuz")
final String domain;
/// Display name for this instance (e.g., "Spotify (John's Account)")
final String name;
/// Whether this provider is currently available/connected
final bool available;
ProviderInstance({
required this.instanceId,
required this.domain,
required this.name,
required this.available,
});
/// Provider capabilities - maps domain to supported content types
/// This determines which tabs/categories show which providers
static const Map<String, Set<String>> providerCapabilities = {
// Music streaming services (support artists, albums, tracks, playlists)
// Spotify also has audiobooks and podcasts
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'tidal': {'artists', 'albums', 'tracks', 'playlists'},
'qobuz': {'artists', 'albums', 'tracks', 'playlists'},
'deezer': {'artists', 'albums', 'tracks', 'playlists'},
'ytmusic': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
'soundcloud': {'artists', 'albums', 'tracks', 'playlists'},
'apple_music': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
'amazon_music': {'artists', 'albums', 'tracks', 'playlists'},
// Self-hosted media servers (can have music, audiobooks, and podcasts)
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
// Music-only servers
'subsonic': {'artists', 'albums', 'tracks', 'playlists'},
'opensubsonic': {'artists', 'albums', 'tracks', 'playlists'},
'navidrome': {'artists', 'albums', 'tracks', 'playlists'},
'filesystem': {'artists', 'albums', 'tracks', 'playlists'},
// Audiobook and podcast providers
'audiobookshelf': {'audiobooks', 'podcasts'},
'itunes_podcasts': {'podcasts'},
// Radio providers (tunein also has podcasts)
'tunein': {'radio', 'podcasts'},
'radiobrowser': {'radio'},
// Player/system providers (no library content)
'snapcast': <String>{},
'fully_kiosk': <String>{},
};
/// Known music provider domains that provide library content
static const Set<String> musicProviderDomains = {
// Streaming services
'spotify',
'tidal',
'qobuz',
'deezer',
'ytmusic',
'soundcloud',
'apple_music',
'amazon_music',
// Self-hosted / local
'plex',
'subsonic',
'opensubsonic',
'jellyfin',
'emby',
'navidrome',
'audiobookshelf',
'filesystem',
'itunes_podcasts',
// Other
'tunein',
'radiobrowser',
'snapcast',
'fully_kiosk',
};
/// Whether this is a music provider (vs. player provider, metadata provider, etc.)
bool get isMusicProvider => musicProviderDomains.contains(domain);
/// Get supported content types for this provider
Set<String> get supportedContentTypes =>
providerCapabilities[domain] ?? <String>{};
/// Check if this provider supports a specific content type/category
bool supportsContentType(String category) =>
supportedContentTypes.contains(category);
factory ProviderInstance.fromJson(Map<String, dynamic> json) {
return ProviderInstance(
instanceId: json['instance_id'] as String? ?? '',
domain: json['domain'] as String? ?? json['type'] as String? ?? '',
name: json['name'] as String? ?? json['instance_id'] as String? ?? 'Unknown',
available: json['available'] as bool? ?? true,
);
}
Map<String, dynamic> toJson() {
return {
'instance_id': instanceId,
'domain': domain,
'name': name,
'available': available,
};
}
@override
String toString() => 'ProviderInstance($instanceId: $name)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ProviderInstance &&
runtimeType == other.runtimeType &&
instanceId == other.instanceId;
@override
int get hashCode => instanceId.hashCode;
}
-65
View File
@@ -1,65 +0,0 @@
/// Represents a provider manifest from Music Assistant.
///
/// Contains metadata about a provider type including its icons.
/// This is separate from ProviderInstance which represents a configured
/// instance of a provider.
class ProviderManifest {
/// The provider domain/type (e.g., "spotify", "tidal", "qobuz")
final String domain;
/// Display name for this provider type
final String name;
/// Description of the provider
final String? description;
/// SVG icon as XML string
final String? iconSvg;
/// SVG icon for dark theme as XML string
final String? iconSvgDark;
/// Monochrome SVG icon as XML string
final String? iconSvgMonochrome;
/// Material Design icon name (fallback)
final String? icon;
ProviderManifest({
required this.domain,
required this.name,
this.description,
this.iconSvg,
this.iconSvgDark,
this.iconSvgMonochrome,
this.icon,
});
/// Whether this manifest has any SVG icon available
bool get hasSvgIcon =>
iconSvg != null || iconSvgDark != null || iconSvgMonochrome != null;
/// Get the best available SVG icon for the given brightness
/// Prefers dark icon for dark themes, falls back to regular or monochrome
String? getSvgIcon({bool isDark = false}) {
if (isDark && iconSvgDark != null) {
return iconSvgDark;
}
return iconSvg ?? iconSvgMonochrome;
}
factory ProviderManifest.fromJson(Map<String, dynamic> json) {
return ProviderManifest(
domain: json['domain'] as String? ?? '',
name: json['name'] as String? ?? '',
description: json['description'] as String?,
iconSvg: json['icon_svg'] as String?,
iconSvgDark: json['icon_svg_dark'] as String?,
iconSvgMonochrome: json['icon_svg_monochrome'] as String?,
icon: json['icon'] as String?,
);
}
@override
String toString() => 'ProviderManifest($domain: $name)';
}
-69
View File
@@ -1,69 +0,0 @@
import 'media_item.dart';
class RecommendationFolder {
final String itemId;
final String provider;
final String name;
final String? uri;
final List<MediaItem> items;
RecommendationFolder({
required this.itemId,
required this.provider,
required this.name,
this.uri,
required this.items,
});
/// Unique ID for use in row ordering and settings
/// Uses 'discovery:' prefix to distinguish from static row IDs
String get rowId => 'discovery:$itemId';
factory RecommendationFolder.fromJson(Map<String, dynamic> json) {
final itemsJson = json['items'] as List<dynamic>?;
final parsedItems = itemsJson
?.whereType<Map<String, dynamic>>()
.map(_parseMediaItemJson)
.toList() ?? <MediaItem>[];
return RecommendationFolder(
itemId: json['item_id']?.toString() ?? json['id']?.toString() ?? '',
provider: json['provider'] as String? ?? 'unknown',
name: json['name'] as String? ?? '',
uri: json['uri'] as String?,
items: parsedItems,
);
}
Map<String, dynamic> toJson() {
return {
'item_id': itemId,
'provider': provider,
'name': name,
if (uri != null) 'uri': uri,
'items': items.map((item) => item.toJson()).toList(),
};
}
}
/// Polymorphic parser for media items in recommendation folders
/// Handles heterogeneous items (playlists, radio, etc.)
MediaItem _parseMediaItemJson(Map<String, dynamic> json) {
final mediaType = json['media_type'] as String? ?? '';
switch (mediaType) {
case 'playlist':
return Playlist.fromJson(json);
case 'album':
return Album.fromJson(json);
case 'artist':
return Artist.fromJson(json);
case 'track':
return Track.fromJson(json);
case 'audiobook':
return Audiobook.fromJson(json);
default:
// For radio, podcast, podcast_episode - use MediaItem base class
return MediaItem.fromJson(json);
}
}
-245
View File
@@ -1,245 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../services/error_handler.dart';
import '../services/auth/auth_manager.dart';
/// Callback types for connection events
typedef OnConnected = Future<void> Function();
typedef OnAuthenticated = Future<void> Function();
typedef OnDisconnected = void Function();
/// Provider for connection state management.
///
/// Extracted from MusicAssistantProvider to handle:
/// - WebSocket connection lifecycle
/// - Authentication flow (MA token, credentials)
/// - Reconnection logic
/// - Connection state broadcasting
///
/// Note: This is a partial extraction. Full integration requires updating
/// all consumers to use this provider directly.
class ConnectionProvider with ChangeNotifier {
final DebugLogger _logger = DebugLogger();
final AuthManager _authManager = AuthManager();
MusicAssistantAPI? _api;
String? _serverUrl;
String? _error;
MAConnectionState _connectionState = MAConnectionState.disconnected;
StreamSubscription? _connectionStateSubscription;
// Callbacks for connection events - set by the facade provider
OnConnected? onConnected;
OnAuthenticated? onAuthenticated;
OnDisconnected? onDisconnected;
// User settings captured during authentication
List<String> _providerFilter = [];
List<String> _playerFilter = [];
// ============================================================================
// GETTERS
// ============================================================================
MusicAssistantAPI? get api => _api;
AuthManager get authManager => _authManager;
String? get serverUrl => _serverUrl;
String? get error => _error;
MAConnectionState get connectionState => _connectionState;
bool get isConnected =>
_connectionState == MAConnectionState.connected ||
_connectionState == MAConnectionState.authenticated;
List<String> get providerFilter => _providerFilter;
List<String> get playerFilter => _playerFilter;
// ============================================================================
// CONNECTION
// ============================================================================
/// Connect to the Music Assistant server
Future<void> connect(String serverUrl) async {
try {
_error = null;
_serverUrl = serverUrl;
await SettingsService.setServerUrl(serverUrl);
// Dispose the old API to stop any pending reconnects
_api?.dispose();
_api = MusicAssistantAPI(serverUrl, _authManager);
_connectionStateSubscription?.cancel();
_connectionStateSubscription = _api!.connectionState.listen(
(state) async {
_connectionState = state;
notifyListeners();
if (state == MAConnectionState.connected) {
_logger.log('🔗 WebSocket connected to MA server');
if (_api!.authRequired && !_api!.isAuthenticated) {
_logger.log('🔐 MA auth required, attempting authentication...');
final authenticated = await _handleAuthentication();
if (!authenticated) {
_error = 'Authentication required. Please log in again.';
notifyListeners();
return;
}
return;
}
// No auth required, trigger callback
await onConnected?.call();
} else if (state == MAConnectionState.authenticated) {
_logger.log('✅ MA authentication successful');
await onAuthenticated?.call();
} else if (state == MAConnectionState.disconnected) {
_logger.log('📡 Disconnected - keeping cached data for instant resume');
onDisconnected?.call();
}
},
onError: (error) {
_logger.log('Connection state stream error: $error');
_connectionState = MAConnectionState.error;
notifyListeners();
},
);
await _api!.connect();
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Connect to server');
_error = errorInfo.userMessage;
_connectionState = MAConnectionState.error;
_logger.log('Connection error: ${errorInfo.technicalMessage}');
notifyListeners();
rethrow;
}
}
Future<bool> _handleAuthentication() async {
if (_api == null) return false;
try {
final storedToken = await SettingsService.getMaAuthToken();
if (storedToken != null) {
_logger.log('🔐 Trying stored MA token...');
final success = await _api!.authenticateWithToken(storedToken);
if (success) {
_logger.log('✅ MA authentication with stored token successful');
await _fetchUserSettings();
return true;
}
_logger.log('⚠️ Stored MA token invalid, clearing...');
await SettingsService.clearMaAuthToken();
}
final username = await SettingsService.getUsername();
final password = await SettingsService.getPassword();
if (username != null &&
password != null &&
username.isNotEmpty &&
password.isNotEmpty) {
_logger.log('🔐 Trying stored credentials...');
final accessToken = await _api!.loginWithCredentials(username, password);
if (accessToken != null) {
_logger.log('✅ MA login with stored credentials successful');
final longLivedToken = await _api!.createLongLivedToken();
if (longLivedToken != null) {
await SettingsService.setMaAuthToken(longLivedToken);
_logger.log('✅ Saved new long-lived MA token');
} else {
await SettingsService.setMaAuthToken(accessToken);
}
await _fetchUserSettings();
return true;
}
}
_logger.log('❌ MA authentication failed - no valid token or credentials');
return false;
} catch (e) {
_logger.log('❌ MA authentication error: $e');
return false;
}
}
Future<void> _fetchUserSettings() async {
if (_api == null) return;
try {
final userInfo = await _api!.getCurrentUserInfo();
if (userInfo == null) return;
// Set profile name
final displayName = userInfo['display_name'] as String?;
final username = userInfo['username'] as String?;
final profileName =
(displayName != null && displayName.isNotEmpty) ? displayName : username;
if (profileName != null && profileName.isNotEmpty) {
await SettingsService.setOwnerName(profileName);
_logger.log('✅ Set owner name from MA profile: $profileName');
}
// Capture provider filter
final providerFilterRaw = userInfo['provider_filter'];
if (providerFilterRaw is List) {
_providerFilter = providerFilterRaw.cast<String>().toList();
if (_providerFilter.isNotEmpty) {
_logger.log(
'🔒 Provider filter active: ${_providerFilter.length} providers allowed');
}
} else {
_providerFilter = [];
}
// Capture player filter
final playerFilterRaw = userInfo['player_filter'];
if (playerFilterRaw is List) {
_playerFilter = playerFilterRaw.cast<String>().toList();
if (_playerFilter.isNotEmpty) {
_logger.log(
'🔒 Player filter active: ${_playerFilter.length} players allowed');
}
} else {
_playerFilter = [];
}
} catch (e) {
_logger.log('⚠️ Could not fetch user settings (non-fatal): $e');
}
}
/// Disconnect from the server
Future<void> disconnect() async {
_connectionStateSubscription?.cancel();
await _api?.disconnect();
_connectionState = MAConnectionState.disconnected;
notifyListeners();
}
/// Check connection and reconnect if needed
Future<void> checkAndReconnect() async {
if (_serverUrl != null && !isConnected) {
_logger.log('🔄 Attempting reconnection...');
await connect(_serverUrl!);
}
}
@override
void dispose() {
_connectionStateSubscription?.cancel();
_api?.dispose();
super.dispose();
}
}
-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();
}
}
File diff suppressed because it is too large Load Diff
-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();
}
}
-133
View File
@@ -1,133 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../services/debug_logger.dart';
/// Callback type for when sleep timer expires and should pause playback
typedef OnSleepTimerExpired = void Function();
/// Provider for sleep timer functionality.
///
/// Extracted from MusicAssistantProvider to reduce complexity and improve testability.
/// This provider manages:
/// - Timed sleep (pause after X minutes)
/// - End of track sleep (pause when current track ends)
class SleepTimerProvider with ChangeNotifier {
final DebugLogger _logger = DebugLogger();
Timer? _sleepTimer;
DateTime? _sleepTimerEndTime;
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
Timer? _sleepTimerDisplayTimer;
bool _disposed = false;
/// Callback to pause playback when timer expires
OnSleepTimerExpired? onExpired;
// ============================================================================
// GETTERS
// ============================================================================
/// Whether a sleep timer is currently active
bool get isActive => _sleepTimerMinutes != null;
/// Current timer setting: null = off, -1 = end of track, positive = minutes
int? get minutes => _sleepTimerMinutes;
/// Time remaining until sleep timer expires (null if end-of-track mode or inactive)
Duration? get remaining {
if (_sleepTimerEndTime == null) return null;
final remaining = _sleepTimerEndTime!.difference(DateTime.now());
return remaining.isNegative ? Duration.zero : remaining;
}
// ============================================================================
// TIMER CONTROL
// ============================================================================
/// Set sleep timer. Pass null to turn off, -1 for end of track, or minutes.
void setTimer(int? minutes) {
// Cancel any existing timer
_cancelInternal();
_sleepTimerMinutes = minutes;
if (minutes == null) {
// Timer off
_logger.log('😴 Sleep timer: OFF');
notifyListeners();
return;
}
if (minutes == -1) {
// End of track - handled in player state updates
_sleepTimerEndTime = null; // No fixed end time
_logger.log('😴 Sleep timer: End of track');
notifyListeners();
return;
}
// Set timer for specified minutes
_sleepTimerEndTime = DateTime.now().add(Duration(minutes: minutes));
_logger.log('😴 Sleep timer: $minutes minutes (until $_sleepTimerEndTime)');
// Create the actual timer
_sleepTimer = Timer(Duration(minutes: minutes), _onTimerExpired);
// Start display update timer (every second for countdown)
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!_disposed) {
notifyListeners(); // Update UI with remaining time
}
});
notifyListeners();
}
/// Cancel sleep timer
void cancel() {
_cancelInternal();
_sleepTimerMinutes = null;
_sleepTimerEndTime = null;
_logger.log('😴 Sleep timer: Cancelled');
notifyListeners();
}
void _cancelInternal() {
_sleepTimer?.cancel();
_sleepTimer = null;
_sleepTimerDisplayTimer?.cancel();
_sleepTimerDisplayTimer = null;
}
void _onTimerExpired() {
_logger.log('😴 Sleep timer expired - pausing playback');
_cancelInternal();
_sleepTimerMinutes = null;
_sleepTimerEndTime = null;
// Trigger the callback to pause playback
onExpired?.call();
notifyListeners();
}
/// Called when track ends - checks if "end of track" sleep timer is active
void checkEndOfTrack() {
if (_sleepTimerMinutes == -1) {
_logger.log('😴 End of track sleep timer triggered');
_sleepTimerMinutes = null;
// Trigger the callback to pause playback
onExpired?.call();
notifyListeners();
}
}
@override
void dispose() {
_disposed = true;
_cancelInternal();
super.dispose();
}
}
-166
View File
@@ -1,166 +0,0 @@
import 'package:flutter/foundation.dart';
import '../models/media_item.dart';
import '../models/player.dart';
import '../models/provider_instance.dart';
/// Provider for UI-only state that doesn't belong to any specific domain.
///
/// Manages:
/// - Search state (query and results)
/// - Provider and player filters (from MA user settings)
/// - User-controlled provider enable/disable
/// - Loading states
/// - Home refresh counter
///
/// This state is UI-focused and doesn't involve business logic operations.
class UIStateProvider extends ChangeNotifier {
// Search state
String _lastSearchQuery = '';
Map<String, List<MediaItem>> _lastSearchResults = {
'artists': [],
'albums': [],
'tracks': [],
};
// Provider filter from MA user settings (empty = all providers allowed)
List<String> _providerFilter = [];
// Player filter from MA user settings (empty = all players allowed)
List<String> _playerFilter = [];
// User-controlled music provider filter (local settings in Ensemble)
// Empty list means all providers are enabled (no filtering)
List<String> _enabledProviderIds = [];
// Available music providers discovered from MA
List<ProviderInstance> _availableMusicProviders = [];
// Loading states
bool _isLoading = false;
bool _isLoadingRadio = false;
bool _isLoadingPodcasts = false;
// Home refresh counter - increments to signal home screen to refresh all rows
int _homeRefreshCounter = 0;
// Getters
String get lastSearchQuery => _lastSearchQuery;
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
List<String> get providerFilter => _providerFilter;
List<String> get playerFilter => _playerFilter;
List<String> get enabledProviderIds => _enabledProviderIds;
List<ProviderInstance> get availableMusicProviders => _availableMusicProviders;
bool get isLoading => _isLoading;
bool get isLoadingRadio => _isLoadingRadio;
bool get isLoadingPodcasts => _isLoadingPodcasts;
int get homeRefreshCounter => _homeRefreshCounter;
/// Whether provider filtering is active
bool get hasProviderFilter => _providerFilter.isNotEmpty;
/// Whether player filtering is active
bool get hasPlayerFilter => _playerFilter.isNotEmpty;
/// Whether user provider filter is active
bool get hasEnabledProviderFilter => _enabledProviderIds.isNotEmpty;
// Search state management
void saveSearchState(String query, Map<String, List<MediaItem>> results) {
_lastSearchQuery = query;
_lastSearchResults = results;
notifyListeners();
}
void clearSearchState() {
_lastSearchQuery = '';
_lastSearchResults = {'artists': [], 'albums': [], 'tracks': []};
notifyListeners();
}
// Filter management
void setProviderFilter(List<String> filter) {
_providerFilter = filter;
notifyListeners();
}
void setPlayerFilter(List<String> filter) {
_playerFilter = filter;
notifyListeners();
}
void setAvailableMusicProviders(List<ProviderInstance> providers) {
_availableMusicProviders = providers;
notifyListeners();
}
void setEnabledProviderIds(List<String> enabledIds) {
_enabledProviderIds = enabledIds;
notifyListeners();
}
/// Toggle a specific music provider on/off
void toggleProviderEnabled(String instanceId, bool enabled) {
final allIds = _availableMusicProviders.map((p) => p.instanceId).toList();
// Don't allow disabling the last provider
if (!enabled && _enabledProviderIds.isNotEmpty && _enabledProviderIds.length <= 1) {
return;
}
// Update local state
if (_enabledProviderIds.isEmpty && !enabled) {
// Switch to explicit mode
_enabledProviderIds = allIds.where((id) => id != instanceId).toList();
} else if (enabled) {
if (!_enabledProviderIds.contains(instanceId)) {
_enabledProviderIds = [..._enabledProviderIds, instanceId];
}
} else {
_enabledProviderIds = _enabledProviderIds.where((id) => id != instanceId).toList();
}
notifyListeners();
}
/// Check if a provider should be visible based on both filters
bool isProviderEnabled(String instanceId) {
// Check MA user filter (server-side restriction)
if (hasProviderFilter && !_providerFilter.contains(instanceId)) {
return false;
}
// Check user filter (local preference)
if (hasEnabledProviderFilter && !_enabledProviderIds.contains(instanceId)) {
return false;
}
return true;
}
/// Check if a player should be visible based on MA user filter
bool isPlayerAllowed(String playerId) {
if (!hasPlayerFilter) return true;
return _playerFilter.contains(playerId);
}
// Loading state management
void setLoading(bool loading) {
_isLoading = loading;
notifyListeners();
}
void setLoadingRadio(bool loading) {
_isLoadingRadio = loading;
notifyListeners();
}
void setLoadingPodcasts(bool loading) {
_isLoadingPodcasts = loading;
notifyListeners();
}
void incrementHomeRefreshCounter() {
_homeRefreshCounter++;
notifyListeners();
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+72 -274
View File
@@ -1,12 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/media_context_menu.dart'; import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/metadata_service.dart'; import '../services/metadata_service.dart';
@@ -14,9 +13,7 @@ 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';
class AudiobookAuthorScreen extends StatefulWidget { class AudiobookAuthorScreen extends StatefulWidget {
final String authorName; final String authorName;
@@ -121,149 +118,6 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
} }
} }
/// Toggle favorite status for an audiobook
Future<void> _toggleAudiobookFavorite(Audiobook book) async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !(book.favorite ?? false);
try {
bool success;
if (newState) {
// Add to favorites
String actualProvider = book.provider;
String actualItemId = book.itemId;
if (book.providerMappings != null && book.providerMappings!.isNotEmpty) {
final mapping = book.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => book.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => book.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'audiobook',
itemId: actualItemId,
provider: actualProvider,
);
} else {
// Remove from favorites
int? libraryItemId;
if (book.provider == 'library') {
libraryItemId = int.tryParse(book.itemId);
} else if (book.providerMappings != null) {
final libraryMapping = book.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => book.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId != null) {
success = await maProvider.removeFromFavorites(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
);
} else {
success = false;
}
}
if (success && mounted) {
setState(() {
final index = _audiobooks.indexWhere((b) => b.itemId == book.itemId);
if (index != -1) {
_audiobooks[index] = Audiobook(
itemId: book.itemId,
provider: book.provider,
name: book.name,
sortName: book.sortName,
uri: book.uri,
providerMappings: book.providerMappings,
favorite: newState,
metadata: book.metadata,
year: book.year,
);
}
});
}
} catch (e) {
_logger.log('Error toggling audiobook favorite: $e');
}
}
/// Toggle library status for an audiobook
Future<void> _toggleAudiobookLibrary(Audiobook book) async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !book.inLibrary;
try {
bool success;
if (newState) {
// Add to library
String? actualProvider;
String? actualItemId;
if (book.providerMappings != null && book.providerMappings!.isNotEmpty) {
final mapping = book.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => book.providerMappings!.first,
);
if (mapping.providerInstance != 'library') {
actualProvider = mapping.providerInstance;
actualItemId = mapping.itemId;
}
}
if (actualProvider != null && actualItemId != null) {
success = await maProvider.addToLibrary(
mediaType: 'audiobook',
itemId: actualItemId,
provider: actualProvider,
);
} else {
success = false;
}
} else {
// Remove from library
int? libraryItemId;
if (book.provider == 'library') {
libraryItemId = int.tryParse(book.itemId);
} else if (book.providerMappings != null) {
final libraryMapping = book.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => book.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId != null) {
success = await maProvider.removeFromLibrary(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
);
} else {
success = false;
}
}
if (success) {
// Refresh would require reloading from parent - for now just show success
_logger.log('Audiobook library status toggled');
}
} catch (e) {
_logger.log('Error toggling audiobook library: $e');
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final adaptiveTheme = context.select<ThemeProvider, bool>( final adaptiveTheme = context.select<ThemeProvider, bool>(
@@ -298,9 +152,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: [
@@ -368,84 +222,77 @@ 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Author info on the left Text(
Expanded( widget.authorName,
child: Column( style: textTheme.headlineMedium?.copyWith(
crossAxisAlignment: CrossAxisAlignment.start, color: colorScheme.onSurface,
children: [ fontWeight: FontWeight.bold,
Text(
widget.authorName,
style: textTheme.headlineMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.audiobookCount(_audiobooks.length),
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
],
), ),
), ),
// Three-dot Menu Button on the right const SizedBox(height: 8),
SizedBox( Text(
height: 50, S.of(context)!.audiobookCount(_audiobooks.length),
width: 50, style: textTheme.bodyLarge?.copyWith(
child: Builder( color: colorScheme.onSurface.withOpacity(0.7),
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 with controls
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 8.0), padding: const EdgeInsets.fromLTRB(24.0, 8.0, 12.0, 8.0),
child: Text( child: Row(
S.of(context)!.audiobooks, children: [
style: textTheme.titleLarge?.copyWith( Text(
color: colorScheme.onSurface, S.of(context)!.audiobooks,
fontWeight: FontWeight.bold, style: textTheme.titleLarge?.copyWith(
), color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
// Sort toggle
IconButton(
icon: Icon(
_sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
color: colorScheme.primary,
size: 20,
),
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// View mode toggle
IconButton(
icon: Icon(
_viewMode == 'list'
? Icons.view_list
: _viewMode == 'grid3'
? Icons.grid_view
: Icons.grid_on,
color: colorScheme.primary,
size: 20,
),
tooltip: _viewMode == 'grid2'
? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3'
? S.of(context)!.listView
: S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
), ),
), ),
), ),
@@ -499,31 +346,10 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final heroSuffix = 'author${_heroTagSuffix}'; final heroSuffix = 'author${_heroTagSuffix}';
// Get adaptive scheme for context menu return InkWell(
final themeProvider = context.read<ThemeProvider>(); onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
final isDark = Theme.of(context).brightness == Brightness.dark; child: Column(
final adaptiveScheme = themeProvider.adaptiveTheme crossAxisAlignment: CrossAxisAlignment.start,
? (isDark ? _darkColorScheme : _lightColorScheme)
: null;
return GestureDetector(
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.audiobook,
item: book,
isFavorite: book.favorite ?? false,
isInLibrary: book.inLibrary,
adaptiveColorScheme: adaptiveScheme,
onToggleFavorite: () => _toggleAudiobookFavorite(book),
onToggleLibrary: () => _toggleAudiobookLibrary(book),
);
},
child: InkWell(
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
AspectRatio( AspectRatio(
aspectRatio: 1.0, aspectRatio: 1.0,
@@ -581,11 +407,6 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
minHeight: 3, minHeight: 3,
), ),
), ),
// Provider icon overlay
if (book.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: book.providerMappings!.first.providerDomain,
),
], ],
), ),
), ),
@@ -614,8 +435,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
], ],
),
), ),
); );
} }
@@ -627,30 +447,9 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final heroSuffix = 'author${_heroTagSuffix}'; final heroSuffix = 'author${_heroTagSuffix}';
// Get adaptive scheme for context menu return ListTile(
final themeProvider = context.read<ThemeProvider>(); contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
final isDark = Theme.of(context).brightness == Brightness.dark; leading: Hero(
final adaptiveScheme = themeProvider.adaptiveTheme
? (isDark ? _darkColorScheme : _lightColorScheme)
: null;
return GestureDetector(
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.audiobook,
item: book,
isFavorite: book.favorite ?? false,
isInLibrary: book.inLibrary,
adaptiveColorScheme: adaptiveScheme,
onToggleFavorite: () => _toggleAudiobookFavorite(book),
onToggleLibrary: () => _toggleAudiobookLibrary(book),
);
},
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: Hero(
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix', tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
@@ -731,8 +530,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
), ),
) )
: null, : null,
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl), onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
),
); );
} }
+59 -208
View File
@@ -1,14 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart'; import '../widgets/player_picker_sheet.dart';
import '../widgets/media_context_menu.dart';
import '../theme/palette_helper.dart'; import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
@@ -38,7 +35,6 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
final _logger = DebugLogger(); final _logger = DebugLogger();
bool _isDescriptionExpanded = false; bool _isDescriptionExpanded = false;
bool _isFavorite = false; bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme; ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme; ColorScheme? _darkColorScheme;
int? _expandedChapterIndex; int? _expandedChapterIndex;
@@ -50,24 +46,17 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
// Use full audiobook if loaded, otherwise use widget audiobook // Use full audiobook if loaded, otherwise use widget audiobook
Audiobook get _audiobook => _fullAudiobook ?? widget.audiobook; Audiobook get _audiobook => _fullAudiobook ?? widget.audiobook;
/// Check if audiobook is in library
bool _checkIfInLibrary(Audiobook audiobook) {
if (audiobook.provider == 'library') return true;
return audiobook.providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_isFavorite = widget.audiobook.favorite ?? false; _isFavorite = widget.audiobook.favorite ?? false;
_isInLibrary = _checkIfInLibrary(widget.audiobook);
// Mark that we're on a detail screen and extract colors immediately
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { Future.delayed(const Duration(milliseconds: 350), () {
markDetailScreenEntered(context); if (mounted) {
_extractColors(); // Extract colors immediately - async so won't block _extractColors();
} }
});
// Load full audiobook details with chapters // Load full audiobook details with chapters
_loadAudiobookDetails(); _loadAudiobookDetails();
}); });
@@ -118,18 +107,15 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (imageUrl == null) return; if (imageUrl == null) return;
try { try {
// Use isolate-based extraction to avoid blocking the main thread final colorSchemes = await PaletteHelper.extractColorSchemes(
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl); CachedNetworkImageProvider(imageUrl),
);
if (colorSchemes != null && mounted) { if (colorSchemes != null && mounted) {
setState(() { setState(() {
_lightColorScheme = colorSchemes.$1; _lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2; _darkColorScheme = colorSchemes.$2;
}); });
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
} }
} catch (e) { } catch (e) {
_logger.log('Failed to extract colors for audiobook: $e'); _logger.log('Failed to extract colors for audiobook: $e');
@@ -223,93 +209,16 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
} }
} }
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
try {
final newState = !_isInLibrary;
if (newState) {
String? actualProvider;
String? actualItemId;
if (widget.audiobook.providerMappings != null && widget.audiobook.providerMappings!.isNotEmpty) {
final nonLibraryMapping = widget.audiobook.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
if (actualProvider == null || actualItemId == null) {
if (widget.audiobook.provider != 'library') {
actualProvider = widget.audiobook.provider;
actualItemId = widget.audiobook.itemId;
} else {
return;
}
}
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
maProvider.addToLibrary(
mediaType: 'audiobook',
provider: actualProvider,
itemId: actualItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
} else {
int? libraryItemId;
if (widget.audiobook.provider == 'library') {
libraryItemId = int.tryParse(widget.audiobook.itemId);
} else if (widget.audiobook.providerMappings != null) {
final libraryMapping = widget.audiobook.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.audiobook.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
maProvider.removeFromLibrary(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
}
} catch (e) {
_logger.log('Error toggling audiobook library: $e');
}
}
void _showPlayOnMenu(BuildContext context) { void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.showPlayerSelectorForAction( GlobalPlayerOverlay.hidePlayer();
contextHint: S.of(context)!.selectPlayerForAudiobook,
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async { onPlayerSelected: (player) async {
maProvider.selectPlayer(player); maProvider.selectPlayer(player);
maProvider.setCurrentAudiobook(_audiobook); maProvider.setCurrentAudiobook(_audiobook);
@@ -318,7 +227,9 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
widget.audiobook, widget.audiobook,
); );
}, },
); ).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
} }
Future<void> _markAsFinished() async { Future<void> _markAsFinished() async {
@@ -594,55 +505,41 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
tag: HeroTags.audiobookCover + (widget.audiobook.uri ?? widget.audiobook.itemId) + _heroTagSuffix, tag: HeroTags.audiobookCover + (widget.audiobook.uri ?? widget.audiobook.itemId) + _heroTagSuffix,
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: SizedBox( child: Container(
width: coverSize, width: coverSize,
height: coverSize, height: coverSize,
child: Stack( color: colorScheme.surfaceContainerHighest,
fit: StackFit.expand, child: imageUrl != null
children: [ ? CachedNetworkImage(
Container( imageUrl: imageUrl,
color: colorScheme.surfaceContainerHighest, fit: BoxFit.cover,
child: imageUrl != null // Match source memCacheWidth for smooth Hero animation
? CachedNetworkImage( memCacheWidth: 256,
imageUrl: imageUrl, memCacheHeight: 256,
fit: BoxFit.cover, fadeInDuration: Duration.zero,
// Match source memCacheWidth for smooth Hero animation fadeOutDuration: Duration.zero,
memCacheWidth: 256, placeholder: (_, __) => Center(
memCacheHeight: 256, child: Icon(
fadeInDuration: Duration.zero, MdiIcons.bookOutline,
fadeOutDuration: Duration.zero, size: coverSize * 0.43,
placeholder: (_, __) => Center( color: colorScheme.onSurfaceVariant,
child: Icon( ),
MdiIcons.bookOutline, ),
size: coverSize * 0.43, errorWidget: (_, __, ___) => Center(
color: colorScheme.onSurfaceVariant, child: Icon(
), MdiIcons.bookOutline,
), size: coverSize * 0.43,
errorWidget: (_, __, ___) => Center( color: colorScheme.onSurfaceVariant,
child: Icon( ),
MdiIcons.bookOutline, ),
size: coverSize * 0.43, )
color: colorScheme.onSurfaceVariant, : Center(
), child: Icon(
), MdiIcons.bookOutline,
) size: coverSize * 0.43,
: Center( color: colorScheme.onSurfaceVariant,
child: Icon( ),
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (widget.audiobook.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.audiobook.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
), ),
],
),
), ),
), ),
), ),
@@ -652,7 +549,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: [
@@ -748,6 +645,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
children: [ children: [
// Play/Resume Button // Play/Resume Button
Expanded( Expanded(
flex: 2,
child: SizedBox( child: SizedBox(
height: 50, height: 50,
child: ElevatedButton.icon( child: ElevatedButton.icon(
@@ -781,33 +679,26 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
), ),
child: const Icon(Icons.replay, size: 25), child: const Icon(Icons.replay),
), ),
), ),
], ],
const SizedBox(width: 12), const SizedBox(width: 12),
// Library Button // Play On Button
SizedBox( SizedBox(
height: 50, height: 50,
width: 50, width: 50,
child: FilledButton.tonal( child: FilledButton.tonal(
onPressed: _toggleLibrary, onPressed: () => _showPlayOnMenu(context),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
), ),
child: Icon( child: const Icon(Icons.speaker_group_outlined),
Symbols.book_2,
size: 25,
fill: _isInLibrary ? 1 : 0,
color: _isInLibrary
? colorScheme.primary
: Colors.white70,
),
), ),
), ),
@@ -826,50 +717,10 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
), ),
), ),
child: Icon( child: Icon(
Icons.favorite, _isFavorite ? Icons.favorite : Icons.favorite_border,
size: 25,
color: _isFavorite color: _isFavorite
? colorScheme.error ? colorScheme.error
: Colors.white70, : colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
// Three-dot Menu Button
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: widget.audiobook,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 25,
color: Colors.white70,
),
), ),
), ),
), ),
+36 -53
View File
@@ -6,7 +6,6 @@ import 'package:palette_generator/palette_generator.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart' show BottomSpacing; import '../widgets/global_player_overlay.dart' show BottomSpacing;
import '../widgets/provider_icon.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
@@ -271,8 +270,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
// Use read since we only need the provider for non-reactive operations (getImageUrl) final maProvider = context.watch<MusicAssistantProvider>();
final maProvider = context.read<MusicAssistantProvider>();
final l10n = S.of(context)!; final l10n = S.of(context)!;
return Scaffold( return Scaffold(
@@ -692,11 +690,6 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
), ),
), ),
), ),
// Provider icon overlay
if (book.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: book.providerMappings!.first.providerDomain,
),
// Progress indicator // Progress indicator
if (book.progress > 0) if (book.progress > 0)
Positioned( Positioned(
@@ -755,55 +748,45 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix', tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: SizedBox( child: Stack(
width: 56, children: [
height: 56, Container(
child: Stack( width: 56,
children: [ height: 56,
Container( color: colorScheme.surfaceContainerHighest,
width: 56, child: imageUrl != null
height: 56, ? CachedNetworkImage(
color: colorScheme.surfaceContainerHighest, imageUrl: imageUrl,
child: imageUrl != null fit: BoxFit.cover,
? CachedNetworkImage( fadeInDuration: Duration.zero,
imageUrl: imageUrl, fadeOutDuration: Duration.zero,
fit: BoxFit.cover, placeholder: (_, __) => Icon(
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.bookOutline, MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
errorWidget: (_, __, ___) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
),
if (book.progress > 0)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(
value: book.progress,
backgroundColor: Colors.black54,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
minHeight: 2,
),
), ),
// Provider icon overlay ],
if (book.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: book.providerMappings!.first.providerDomain,
size: 16,
),
if (book.progress > 0)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(
value: book.progress,
backgroundColor: Colors.black54,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
minHeight: 2,
),
),
],
),
), ),
), ),
), ),
+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,
+3 -4
View File
@@ -43,6 +43,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
@@ -65,13 +66,11 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
), ),
centerTitle: true, centerTitle: true,
), ),
body: _buildPlaylistsList(context), body: _buildPlaylistsList(context, provider),
); );
} }
Widget _buildPlaylistsList(BuildContext context) { Widget _buildPlaylistsList(BuildContext context, MusicAssistantProvider provider) {
// Use read since we only need the provider for non-reactive operations
final provider = context.read<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
// Show cached data immediately if available, even while loading // Show cached data immediately if available, even while loading
+11 -16
View File
@@ -11,6 +11,7 @@ class LibraryTracksScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
@@ -33,41 +34,35 @@ class LibraryTracksScreen extends StatelessWidget {
), ),
centerTitle: true, centerTitle: true,
), ),
body: _buildTracksList(context), body: _buildTracksList(context, provider),
); );
} }
Widget _buildTracksList(BuildContext context) { Widget _buildTracksList(
// Use select to only rebuild when tracks or isLoading changes BuildContext context, MusicAssistantProvider provider) {
final (tracks, isLoading) = context.select<MusicAssistantProvider, (List, bool)>(
(p) => (p.tracks, p.isLoading),
);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
// Show cached data immediately if available, even while loading // Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're loading // Only show spinner if we have no data at all AND we're loading
if (tracks.isEmpty && isLoading) { if (provider.tracks.isEmpty && provider.isLoading) {
return Center( return Center(
child: CircularProgressIndicator(color: colorScheme.primary), child: CircularProgressIndicator(color: colorScheme.primary),
); );
} }
if (tracks.isEmpty) { if (provider.tracks.isEmpty) {
return EmptyState.tracks( return EmptyState.tracks(context: context, onRefresh: provider.loadLibrary);
context: context,
onRefresh: context.read<MusicAssistantProvider>().loadLibrary,
);
} }
return ListView.builder( return ListView.builder(
itemCount: tracks.length, itemCount: provider.tracks.length,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
itemBuilder: (context, index) { itemBuilder: (context, index) {
final track = tracks[index] as Track; final track = provider.tracks[index];
return _buildTrackTile(context, track, index); return _buildTrackTile(context, track, provider, index);
}, },
); );
} }
@@ -75,9 +70,9 @@ class LibraryTracksScreen extends StatelessWidget {
Widget _buildTrackTile( Widget _buildTrackTile(
BuildContext context, BuildContext context,
Track track, Track track,
MusicAssistantProvider maProvider,
int index, int index,
) { ) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = track.album != null final imageUrl = track.album != null
? maProvider.getImageUrl(track.album!, size: 128) ? maProvider.getImageUrl(track.album!, size: 128)
: null; : null;
+689 -147
View File
@@ -4,6 +4,7 @@ import '../providers/music_assistant_provider.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/database_service.dart'; import '../services/database_service.dart';
import '../services/profile_service.dart'; import '../services/profile_service.dart';
import '../services/auth/auth_strategy.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../widgets/debug/debug_console.dart'; import '../widgets/debug/debug_console.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -18,91 +19,260 @@ class LoginScreen extends StatefulWidget {
class _LoginScreenState extends State<LoginScreen> { class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _serverUrlController = TextEditingController(); final TextEditingController _serverUrlController = TextEditingController();
final TextEditingController _ownerNameController = TextEditingController();
final TextEditingController _portController = TextEditingController();
final TextEditingController _authServerUrlController = TextEditingController(); // For separate auth server (Authelia)
final TextEditingController _usernameController = TextEditingController(); final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController(); final TextEditingController _passwordController = TextEditingController();
final FocusNode _usernameFocusNode = FocusNode(); final FocusNode _usernameFocusNode = FocusNode();
final _logger = DebugLogger();
bool _isConnecting = false; bool _isConnecting = false;
bool _isDetectingAuth = false;
AuthStrategy? _detectedAuthStrategy;
String? _detectedAuthType;
String? _error; String? _error;
bool _showDebug = false; bool _showDebug = false;
List<String> _debugLogs = [];
String? _resolvedServerUrl; // The actual URL after fallback (e.g., HTTP if HTTPS failed)
bool _usedHttpFallback = false; // True if HTTPS failed and we fell back to HTTP
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadSavedSettings();
}
Future<void> _loadSavedSettings() async {
final savedPort = await SettingsService.getWebSocketPort();
final savedAuthServerUrl = await SettingsService.getAuthServerUrl();
final savedOwnerName = await SettingsService.getOwnerName();
if (savedPort != null) {
setState(() {
_portController.text = savedPort.toString();
});
}
if (savedAuthServerUrl != null) {
setState(() {
_authServerUrlController.text = savedAuthServerUrl;
});
}
if (savedOwnerName != null) {
setState(() {
_ownerNameController.text = savedOwnerName;
});
}
} }
@override @override
void dispose() { void dispose() {
_serverUrlController.dispose(); _serverUrlController.dispose();
_ownerNameController.dispose();
_portController.dispose();
_authServerUrlController.dispose();
_usernameController.dispose(); _usernameController.dispose();
_passwordController.dispose(); _passwordController.dispose();
_usernameFocusNode.dispose(); _usernameFocusNode.dispose();
super.dispose(); super.dispose();
} }
/// Normalize server URL and add default port for local IPs String _normalizeServerUrl(String url) {
String _buildServerUrl(String input) { // Remove any trailing slashes
var url = input.trim(); url = url.trim();
// Remove trailing slashes
while (url.endsWith('/')) { while (url.endsWith('/')) {
url = url.substring(0, url.length - 1); url = url.substring(0, url.length - 1);
} }
// Add protocol if missing // If URL already has a protocol, return it
if (!url.startsWith('http://') && !url.startsWith('https://')) { if (url.startsWith('http://') || url.startsWith('https://')) {
if (_isLocalAddress(url)) { return url;
url = 'http://$url';
} else if (url.endsWith('.ts.net') || url.contains('.ts.net:')) {
// Tailscale - use http since VPN is encrypted
url = 'http://$url';
} else {
url = 'https://$url';
}
} }
// Add default port 8095 for local IPs without a port // If it's an IP address or localhost, default to http://
final uri = Uri.parse(url); if (url.startsWith('192.') ||
if (!uri.hasPort && _isLocalAddress(uri.host)) { url.startsWith('10.') ||
return '${uri.scheme}://${uri.host}:8095'; url.startsWith('172.') ||
url == 'localhost' ||
url.startsWith('127.')) {
return 'http://$url';
} }
return url; // Tailscale URLs (*.ts.net) - default to http:// since VPN tunnel is already encrypted
if (url.endsWith('.ts.net') || url.contains('.ts.net:')) {
return 'http://$url';
}
// For domain names, default to https://
return 'https://$url';
} }
/// Check if address is local/private /// Check if the URL is using HTTP (for security warning display)
bool _isLocalAddress(String host) { bool _isHttpConnection(String url) {
return host.startsWith('192.168.') || final normalized = _normalizeServerUrl(url);
host.startsWith('10.') || return normalized.startsWith('http://') && !normalized.startsWith('https://');
host.startsWith('172.16.') || }
host.startsWith('172.17.') ||
host.startsWith('172.18.') || /// Check if the URL is a Tailscale URL (safe for HTTP)
host.startsWith('172.19.') || bool _isTailscaleUrl(String url) {
host.startsWith('172.2') || final normalized = url.trim().toLowerCase();
host.startsWith('172.30.') || return normalized.contains('.ts.net');
host.startsWith('172.31.') || }
host == 'localhost' ||
host.startsWith('127.'); String _buildServerUrl() {
// Normalize the base URL
var url = _normalizeServerUrl(_serverUrlController.text.trim());
// Parse to check if port is already included
final uri = Uri.parse(url);
// Get port from field
final portText = _portController.text.trim();
if (portText.isEmpty) {
return url; // No port specified, use URL as-is
}
final port = int.tryParse(portText);
if (port == null) {
return url; // Invalid port, use URL as-is
}
// Skip adding port if it's the default for the scheme
final isDefaultPort = (uri.scheme == 'https' && port == 443) ||
(uri.scheme == 'http' && port == 80);
if (isDefaultPort) {
return url; // Don't add default ports
}
// Build URL with custom port
return '${uri.scheme}://${uri.host}:$port';
}
void _addDebugLog(String message) {
setState(() {
_debugLogs.add('[${DateTime.now().toString().substring(11, 19)}] $message');
});
}
Future<void> _detectAuthRequirements() async {
if (_serverUrlController.text.trim().isEmpty) {
setState(() {
_error = S.of(context)!.pleaseEnterServerAddress;
});
return;
}
setState(() {
_isDetectingAuth = true;
_error = null;
_detectedAuthStrategy = null;
_detectedAuthType = null;
_resolvedServerUrl = null;
_usedHttpFallback = false;
_debugLogs.clear();
});
try {
final serverUrl = _buildServerUrl();
_addDebugLog('Built server URL: $serverUrl');
final provider = context.read<MusicAssistantProvider>();
_addDebugLog('Starting auth detection...');
// Auto-detect authentication requirements
final strategy = await provider.authManager.detectAuthStrategy(serverUrl);
// Check if auth manager used a different URL (e.g., HTTP fallback)
final resolvedUrl = provider.authManager.lastSuccessfulUrl;
if (resolvedUrl != null && resolvedUrl != serverUrl) {
_addDebugLog('URL resolved via fallback: $resolvedUrl');
_usedHttpFallback = serverUrl.startsWith('https://') && resolvedUrl.startsWith('http://');
}
_addDebugLog('Detection complete. Strategy: ${strategy?.name ?? 'null'}');
if (strategy == null) {
setState(() {
_error = S.of(context)!.cannotDetermineAuth;
_isDetectingAuth = false;
});
return;
}
setState(() {
_detectedAuthStrategy = strategy;
_detectedAuthType = _getAuthTypeName(strategy.name);
_resolvedServerUrl = resolvedUrl;
_isDetectingAuth = false;
});
// If no auth required, connect immediately
if (strategy.name == 'none') {
await _connect();
} else {
// Auth required - focus username field after a brief delay
// to allow the new fields to build
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
_usernameFocusNode.requestFocus();
}
});
}
} catch (e) {
setState(() {
_error = 'Auth detection failed: ${e.toString()}';
_isDetectingAuth = false;
});
}
}
String _getAuthTypeName(String strategyName) {
switch (strategyName) {
case 'none':
return S.of(context)!.noAuthentication;
case 'basic':
return S.of(context)!.httpBasicAuth;
case 'authelia':
return S.of(context)!.authelia;
case 'music_assistant':
return S.of(context)!.musicAssistantLogin;
default:
return 'Unknown';
}
}
IconData _getAuthIcon(String strategyName) {
switch (strategyName) {
case 'none':
return Icons.lock_open_rounded;
case 'basic':
return Icons.vpn_key_rounded;
case 'authelia':
return Icons.shield_rounded;
case 'music_assistant':
return Icons.music_note_rounded;
default:
return Icons.security_rounded;
}
} }
Future<void> _connect() async { Future<void> _connect() async {
final serverInput = _serverUrlController.text.trim(); if (_serverUrlController.text.trim().isEmpty) {
final username = _usernameController.text.trim(); setState(() {
final password = _passwordController.text.trim(); _error = S.of(context)!.pleaseEnterServerAddress;
});
return;
}
// Validate inputs // Validate owner name - only required if NOT using MA auth
if (serverInput.isEmpty) { // (MA auth will get display_name from user profile after login)
setState(() => _error = S.of(context)!.pleaseEnterServerAddress); final isMaAuth = _detectedAuthStrategy?.name == 'music_assistant';
return; if (!isMaAuth && _ownerNameController.text.trim().isEmpty) {
} setState(() {
if (username.isEmpty) { _error = S.of(context)!.pleaseEnterName;
setState(() => _error = S.of(context)!.pleaseEnterCredentials); });
_usernameFocusNode.requestFocus();
return;
}
if (password.isEmpty) {
setState(() => _error = S.of(context)!.pleaseEnterCredentials);
return; return;
} }
@@ -112,73 +282,173 @@ class _LoginScreenState extends State<LoginScreen> {
}); });
try { try {
final serverUrl = _buildServerUrl(serverInput); // Use the resolved URL if available (e.g., from HTTP fallback), otherwise build it
_logger.log('Connecting to: $serverUrl'); final serverUrl = _resolvedServerUrl ?? _buildServerUrl();
_addDebugLog('Using server URL for connection: $serverUrl');
final port = _portController.text.trim();
// Validate port (if provided)
int? portNum;
if (port.isNotEmpty) {
portNum = int.tryParse(port);
if (portNum == null || portNum < 1 || portNum > 65535) {
setState(() {
_error = S.of(context)!.pleaseEnterValidPort;
_isConnecting = false;
});
return;
}
}
// Save owner name (if provided) and port to settings
// For MA auth, the owner name will be set from user profile after login
if (!isMaAuth && _ownerNameController.text.trim().isNotEmpty) {
final ownerName = _ownerNameController.text.trim();
await SettingsService.setOwnerName(ownerName);
// Create/activate profile in local database for manual name entry
if (DatabaseService.instance.isInitialized) {
await ProfileService.instance.onManualNameEntered(ownerName);
}
}
await SettingsService.setWebSocketPort(portNum);
final provider = context.read<MusicAssistantProvider>(); final provider = context.read<MusicAssistantProvider>();
// Connect to server // Handle authentication based on detected strategy
await provider.connectToServer(serverUrl); // Note: isMaAuth is already defined earlier in this method
// Authenticate with MA if (_detectedAuthStrategy != null && _detectedAuthStrategy!.name != 'none' && !isMaAuth) {
_logger.log('Authenticating with Music Assistant...'); // Pre-connect auth (Authelia, Basic Auth)
final username = _usernameController.text.trim();
final password = _passwordController.text.trim();
// Check if we have a stored MA token first if (username.isEmpty || password.isEmpty) {
final storedToken = await SettingsService.getMaAuthToken();
bool authSuccess = false;
if (storedToken != null && provider.api != null) {
_logger.log('Trying stored MA token...');
authSuccess = await provider.api!.authenticateWithToken(storedToken);
}
if (!authSuccess) {
_logger.log('Logging in with credentials...');
final accessToken = await provider.api?.loginWithCredentials(username, password);
if (accessToken == null) {
setState(() { setState(() {
_error = S.of(context)!.maLoginFailed; _error = S.of(context)!.pleaseEnterCredentials;
_isConnecting = false; _isConnecting = false;
}); });
return; return;
} }
// Try to create a long-lived token for future use // Get auth server URL (if provided for Authelia)
final longLivedToken = await provider.api?.createLongLivedToken(); String? authServerUrl;
final tokenToStore = longLivedToken ?? accessToken; if (_detectedAuthStrategy!.name == 'authelia') {
final authUrl = _authServerUrlController.text.trim();
if (authUrl.isNotEmpty) {
authServerUrl = _normalizeServerUrl(authUrl);
}
}
// Save the token for future auto-login // Attempt login with detected strategy
await SettingsService.setMaAuthToken(tokenToStore); final success = await provider.authManager.login(
_logger.log('MA token saved for future logins'); serverUrl,
username,
password,
_detectedAuthStrategy!,
authServerUrl: authServerUrl,
);
if (!success) {
setState(() {
_error = S.of(context)!.authFailed;
_isConnecting = false;
});
return;
}
// Save credentials for future auto-login
await SettingsService.setUsername(username);
await SettingsService.setPassword(password);
// Save auth server URL if provided
if (authServerUrl != null && authServerUrl.isNotEmpty) {
await SettingsService.setAuthServerUrl(authServerUrl);
} else {
await SettingsService.setAuthServerUrl(null);
}
// Save auth credentials to settings
final serialized = provider.authManager.serializeCredentials();
if (serialized != null) {
await SettingsService.setAuthCredentials(serialized);
}
} }
// Save credentials // Connect to server
await SettingsService.setUsername(username); await provider.connectToServer(serverUrl);
await SettingsService.setPassword(password);
await SettingsService.setServerUrl(serverUrl);
// Save auth credentials to settings // Post-connect auth (Music Assistant native auth)
await SettingsService.setAuthCredentials({ if (isMaAuth) {
'strategy': 'music_assistant', final username = _usernameController.text.trim();
'data': {'username': username}, final password = _passwordController.text.trim();
});
// Set owner name from username for profile if (username.isEmpty || password.isEmpty) {
if (DatabaseService.instance.isInitialized) { setState(() {
await ProfileService.instance.onManualNameEntered(username); _error = S.of(context)!.pleaseEnterCredentials;
_isConnecting = false;
});
return;
}
_addDebugLog('Authenticating with Music Assistant...');
// Check if we have a stored MA token first
final storedToken = await SettingsService.getMaAuthToken();
bool authSuccess = false;
if (storedToken != null && provider.api != null) {
_addDebugLog('Trying stored MA token...');
authSuccess = await provider.api!.authenticateWithToken(storedToken);
}
if (!authSuccess) {
_addDebugLog('Logging in with credentials...');
// Login with credentials over WebSocket
final accessToken = await provider.api?.loginWithCredentials(username, password);
if (accessToken == null) {
setState(() {
_error = S.of(context)!.maLoginFailed;
_isConnecting = false;
});
return;
}
// Try to create a long-lived token for future use
final longLivedToken = await provider.api?.createLongLivedToken();
final tokenToStore = longLivedToken ?? accessToken;
// Save the token for future auto-login
await SettingsService.setMaAuthToken(tokenToStore);
_addDebugLog('MA token saved for future logins');
}
// Save credentials for future auto-login
await SettingsService.setUsername(username);
await SettingsService.setPassword(password);
// Save auth strategy info
await SettingsService.setAuthCredentials({
'strategy': 'music_assistant',
'data': {'username': username},
});
} }
// Wait for connection to complete // Wait for connection and authentication to complete
// MA auth happens asynchronously after connect, so we need to wait longer
for (int i = 0; i < 10; i++) { for (int i = 0; i < 10; i++) {
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
if (provider.isConnected) break; if (provider.isConnected) break;
} }
if (provider.isConnected) { if (provider.isConnected) {
// First-time user: wait for player selection // For first-time users, wait for player selection so the welcome
// overlay can appear immediately without home screen flash.
// This matches the logic in AppStartup._checkAndConnect().
final hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding(); final hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
if (!hasCompletedOnboarding) { if (!hasCompletedOnboarding) {
// First-time user: wait for connection + player selection
for (int i = 0; i < 20; i++) { for (int i = 0; i < 20; i++) {
await Future.delayed(const Duration(milliseconds: 250)); await Future.delayed(const Duration(milliseconds: 250));
if (provider.selectedPlayer != null) break; if (provider.selectedPlayer != null) break;
@@ -187,6 +457,7 @@ class _LoginScreenState extends State<LoginScreen> {
// Navigate to home screen // Navigate to home screen
if (mounted) { if (mounted) {
// Ensure keyboard is closed before navigating
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
Navigator.of(context).pushReplacement( Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const HomeScreen()), MaterialPageRoute(builder: (context) => const HomeScreen()),
@@ -199,7 +470,6 @@ class _LoginScreenState extends State<LoginScreen> {
}); });
} }
} catch (e) { } catch (e) {
_logger.log('Connection error: $e');
setState(() { setState(() {
_error = 'Connection failed: ${e.toString()}'; _error = 'Connection failed: ${e.toString()}';
_isConnecting = false; _isConnecting = false;
@@ -212,6 +482,9 @@ class _LoginScreenState extends State<LoginScreen> {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
// Determine if we need auth fields
final needsAuth = _detectedAuthStrategy != null && _detectedAuthStrategy!.name != 'none';
return Scaffold( return Scaffold(
backgroundColor: colorScheme.background, backgroundColor: colorScheme.background,
body: SafeArea( body: SafeArea(
@@ -265,29 +538,76 @@ class _LoginScreenState extends State<LoginScreen> {
color: colorScheme.onSurface.withOpacity(0.54), color: colorScheme.onSurface.withOpacity(0.54),
), ),
), ),
enabled: !_isConnecting, enabled: !_isConnecting && !_isDetectingAuth,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Username // Your Name - only show AFTER auth detection AND only for non-MA auth
// (MA auth gets display_name from user profile automatically)
// Show for: 'none' (no auth), 'authelia', 'basic_auth', etc.
// Hide for: 'music_assistant' (gets name from profile)
if (_detectedAuthStrategy != null &&
_detectedAuthStrategy!.name != 'music_assistant') ...[
Text(
S.of(context)!.yourName,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 12),
TextField(
controller: _ownerNameController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: S.of(context)!.yourFirstName,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.person_rounded,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
enabled: !_isConnecting && !_isDetectingAuth,
keyboardType: TextInputType.name,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 24),
],
// Port
Text( Text(
S.of(context)!.username, S.of(context)!.portOptional,
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
const SizedBox(height: 8),
Text(
S.of(context)!.portDescription,
style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.6),
fontSize: 12,
),
),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
controller: _usernameController, controller: _portController,
focusNode: _usernameFocusNode,
style: TextStyle(color: colorScheme.onSurface), style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
hintText: S.of(context)!.username, hintText: S.of(context)!.portHint,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)), hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true, filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3), fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -296,50 +616,248 @@ class _LoginScreenState extends State<LoginScreen> {
borderSide: BorderSide.none, borderSide: BorderSide.none,
), ),
prefixIcon: Icon( prefixIcon: Icon(
Icons.person_rounded, Icons.settings_ethernet_rounded,
color: colorScheme.onSurface.withOpacity(0.54), color: colorScheme.onSurface.withOpacity(0.54),
), ),
), ),
enabled: !_isConnecting, enabled: !_isConnecting && !_isDetectingAuth,
textInputAction: TextInputAction.next, keyboardType: TextInputType.number,
textInputAction: needsAuth ? TextInputAction.next : TextInputAction.done,
onSubmitted: (_) => needsAuth ? null : _detectedAuthStrategy == null ? _detectAuthRequirements() : _connect(),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Password // Detected auth method indicator
Text( if (_detectedAuthType != null)
S.of(context)!.password, Container(
style: textTheme.titleMedium?.copyWith( padding: const EdgeInsets.all(16),
color: colorScheme.onBackground, margin: const EdgeInsets.only(bottom: 24),
fontWeight: FontWeight.w500, decoration: BoxDecoration(
), color: needsAuth
), ? colorScheme.primaryContainer.withOpacity(0.3)
const SizedBox(height: 12), : colorScheme.tertiaryContainer.withOpacity(0.3),
TextField(
controller: _passwordController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: S.of(context)!.password,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
), ),
prefixIcon: Icon( child: Row(
Icons.lock_rounded, children: [
color: colorScheme.onSurface.withOpacity(0.54), Icon(
_getAuthIcon(_detectedAuthStrategy!.name),
color: needsAuth ? colorScheme.primary : colorScheme.tertiary,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.detectedAuthType(_detectedAuthType!),
style: TextStyle(
color: needsAuth
? colorScheme.onPrimaryContainer
: colorScheme.onTertiaryContainer,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
if (needsAuth)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
S.of(context)!.serverRequiresAuth,
style: TextStyle(
color: colorScheme.onPrimaryContainer.withOpacity(0.7),
fontSize: 12,
),
),
),
],
),
),
],
), ),
), ),
obscureText: true,
enabled: !_isConnecting,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _connect(),
),
const SizedBox(height: 32), // HTTP connection info banner
if (_resolvedServerUrl != null && _isHttpConnection(_resolvedServerUrl!))
Container(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: 24),
decoration: BoxDecoration(
color: _isTailscaleUrl(_serverUrlController.text)
? colorScheme.secondaryContainer.withOpacity(0.3)
: colorScheme.errorContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
border: _isTailscaleUrl(_serverUrlController.text)
? null
: Border.all(color: colorScheme.error.withOpacity(0.3)),
),
child: Row(
children: [
Icon(
_isTailscaleUrl(_serverUrlController.text)
? Icons.vpn_lock_rounded
: Icons.warning_amber_rounded,
color: _isTailscaleUrl(_serverUrlController.text)
? colorScheme.secondary
: colorScheme.error,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_isTailscaleUrl(_serverUrlController.text)
? S.of(context)!.tailscaleVpnConnection
: S.of(context)!.unencryptedConnection,
style: TextStyle(
color: _isTailscaleUrl(_serverUrlController.text)
? colorScheme.onSecondaryContainer
: colorScheme.onErrorContainer,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_isTailscaleUrl(_serverUrlController.text)
? S.of(context)!.usingHttpOverTailscale
: _usedHttpFallback
? S.of(context)!.httpsFailedUsingHttp
: S.of(context)!.httpNotEncrypted,
style: TextStyle(
color: (_isTailscaleUrl(_serverUrlController.text)
? colorScheme.onSecondaryContainer
: colorScheme.onErrorContainer).withOpacity(0.7),
fontSize: 12,
),
),
),
],
),
),
],
),
),
// Authentication fields (shown only if auth required)
if (needsAuth) ...[
// Auth Server URL (only for Authelia)
if (_detectedAuthStrategy?.name == 'authelia') ...[
Text(
S.of(context)!.authServerUrlOptional,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.authServerUrlDescription,
style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.6),
fontSize: 12,
),
),
const SizedBox(height: 12),
TextField(
controller: _authServerUrlController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: S.of(context)!.authServerUrlHint,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.shield_rounded,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
enabled: !_isConnecting,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 24),
],
Text(
S.of(context)!.username,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 12),
TextField(
controller: _usernameController,
focusNode: _usernameFocusNode,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: S.of(context)!.username,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.person_rounded,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
enabled: !_isConnecting,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
Text(
S.of(context)!.password,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 12),
TextField(
controller: _passwordController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: S.of(context)!.password,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.lock_rounded,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
obscureText: true,
enabled: !_isConnecting,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _connect(),
),
const SizedBox(height: 16),
],
const SizedBox(height: 16),
// Error message // Error message
if (_error != null) if (_error != null)
@@ -375,14 +893,16 @@ class _LoginScreenState extends State<LoginScreen> {
if (_showDebug) ...[ if (_showDebug) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
DebugConsole( DebugConsole(
onClear: () {}, onClear: () => setState(() => _debugLogs.clear()),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
// Connect button // Connect button (changes based on state)
ElevatedButton( ElevatedButton(
onPressed: _isConnecting ? null : _connect, onPressed: (_isConnecting || _isDetectingAuth)
? null
: (_detectedAuthStrategy == null ? _detectAuthRequirements : _connect),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary, backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary, foregroundColor: colorScheme.onPrimary,
@@ -392,22 +912,44 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
elevation: 0, elevation: 0,
), ),
child: _isConnecting child: _isDetectingAuth
? SizedBox( ? Row(
height: 20, mainAxisAlignment: MainAxisAlignment.center,
width: 20, children: [
child: CircularProgressIndicator( SizedBox(
strokeWidth: 2, height: 20,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.onPrimary), width: 20,
), child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.onPrimary),
),
),
const SizedBox(width: 12),
Text(
S.of(context)!.detectingAuth,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
) )
: Text( : _isConnecting
S.of(context)!.connect, ? SizedBox(
style: const TextStyle( height: 20,
fontSize: 16, width: 20,
fontWeight: FontWeight.bold, child: CircularProgressIndicator(
), strokeWidth: 2,
), valueColor: AlwaysStoppedAnimation<Color>(colorScheme.onPrimary),
),
)
: Text(
_detectedAuthStrategy == null ? S.of(context)!.detectAndConnect : S.of(context)!.connect,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
), ),
], ],
), ),
+17 -311
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,6 @@ 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/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';
@@ -49,11 +47,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 +56,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 +66,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,35 +96,21 @@ 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; if (_lastArtistCount == 0 && currentCount > 0) {
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
// Check if home refresh counter changed (indicates authentication completed and cache was invalidated) _hadEmptyFavoritesOnLoad = false;
if (currentRefreshCounter != _lastHomeRefreshCounter) { _providerListeningTo!.removeListener(_onProviderChanged);
_logger.log('🔄 Home: refresh counter changed ($_lastHomeRefreshCounter -> $currentRefreshCounter), refreshing all rows'); _providerListeningTo = null;
_lastHomeRefreshCounter = currentRefreshCounter;
setState(() { setState(() {
_refreshKey = UniqueKey(); // Forces all rows to rebuild and re-fetch data _refreshKey = UniqueKey();
}); });
return; // Don't process other refresh conditions in this cycle
}
// Check for artists cache load (when favorites were empty on load)
if (_hadEmptyFavoritesOnLoad) {
final currentCount = provider.artists.length;
if (_lastArtistCount == 0 && currentCount > 0) {
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
_hadEmptyFavoritesOnLoad = false;
setState(() {
_refreshKey = UniqueKey();
});
}
_lastArtistCount = currentCount;
} }
_lastArtistCount = currentCount;
} }
/// Called when SyncService status changes /// Called when SyncService status changes
@@ -184,7 +156,6 @@ 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;
@@ -200,91 +171,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,34 +326,11 @@ 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;
@@ -483,57 +348,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
@@ -572,14 +386,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( child: Column(
// Ensure minimum height for pull-to-refresh when empty key: _refreshKey,
height: enabledRows == 0 ? availableHeight : null, crossAxisAlignment: CrossAxisAlignment.start,
child: Column( children: _buildOrderedRows(provider, rowHeight),
key: _refreshKey,
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildOrderedRows(provider, rowHeight, enabledRows == 0),
),
), ),
), ),
), ),
@@ -589,54 +399,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 +410,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));
} }
} }
@@ -796,29 +525,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;
} }
} }
File diff suppressed because it is too large Load Diff
+386 -478
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
@@ -10,9 +9,7 @@ import '../theme/theme_provider.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/recently_played_service.dart'; import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart'; import '../widgets/player_picker_sheet.dart';
import '../widgets/hires_badge.dart';
import '../widgets/media_context_menu.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart'; import '../theme/design_tokens.dart';
@@ -44,9 +41,9 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
List<Track> _tracks = []; List<Track> _tracks = [];
bool _isLoading = true; bool _isLoading = true;
bool _isFavorite = false; bool _isFavorite = false;
late bool _isInLibrary;
ColorScheme? _lightColorScheme; ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme; ColorScheme? _darkColorScheme;
int? _expandedTrackIndex;
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : ''; String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@@ -58,15 +55,15 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
void initState() { void initState() {
super.initState(); super.initState();
_isFavorite = widget.playlist.favorite ?? false; _isFavorite = widget.playlist.favorite ?? false;
_isInLibrary = widget.playlist.inLibrary;
_loadTracks(); _loadTracks();
// Mark that we're on a detail screen and extract colors immediately // Defer color extraction until after hero animation completes
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { Future.delayed(const Duration(milliseconds: 350), () {
markDetailScreenEntered(context); if (mounted) {
_extractColors(); // Extract colors immediately - async so won't block _extractColors();
} }
});
}); });
} }
@@ -77,18 +74,15 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
if (imageUrl == null) return; if (imageUrl == null) return;
try { try {
// Use isolate-based extraction to avoid blocking the main thread final colorSchemes = await PaletteHelper.extractColorSchemes(
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl); CachedNetworkImageProvider(imageUrl),
);
if (colorSchemes != null && mounted) { if (colorSchemes != null && mounted) {
setState(() { setState(() {
_lightColorScheme = colorSchemes.$1; _lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2; _darkColorScheme = colorSchemes.$2;
}); });
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
} }
} catch (e) { } catch (e) {
_logger.log('Failed to extract colors for playlist: $e'); _logger.log('Failed to extract colors for playlist: $e');
@@ -233,86 +227,6 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
} }
} }
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isInLibrary;
try {
if (newState) {
String? actualProvider;
String? actualItemId;
if (widget.playlist.providerMappings != null && widget.playlist.providerMappings!.isNotEmpty) {
final nonLibraryMapping = widget.playlist.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
if (actualProvider == null || actualItemId == null) {
if (widget.playlist.provider != 'library') {
actualProvider = widget.playlist.provider;
actualItemId = widget.playlist.itemId;
} else {
return;
}
}
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
maProvider.addToLibrary(
mediaType: 'playlist',
provider: actualProvider,
itemId: actualItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
} else {
int? libraryItemId;
if (widget.playlist.provider == 'library') {
libraryItemId = int.tryParse(widget.playlist.itemId);
} else if (widget.playlist.providerMappings != null) {
final libraryMapping = widget.playlist.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.playlist.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
maProvider.removeFromLibrary(
mediaType: 'playlist',
libraryItemId: libraryItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
}
} catch (e) {
// Silent failure
}
}
Future<void> _toggleTrackFavorite(int trackIndex) async { Future<void> _toggleTrackFavorite(int trackIndex) async {
if (trackIndex < 0 || trackIndex >= _tracks.length) return; if (trackIndex < 0 || trackIndex >= _tracks.length) return;
@@ -410,89 +324,6 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
} }
} }
Future<void> _toggleTrackLibrary(int trackIndex) async {
if (trackIndex < 0 || trackIndex >= _tracks.length) return;
final track = _tracks[trackIndex];
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = track.inLibrary;
try {
if (!currentInLibrary) {
// Add to library
String? actualProvider;
String? actualItemId;
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
final nonLibraryMapping = track.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
if (actualProvider == null || actualItemId == null) {
if (track.provider != 'library') {
actualProvider = track.provider;
actualItemId = track.itemId;
} else {
return;
}
}
await maProvider.addToLibrary(
mediaType: 'track',
provider: actualProvider,
itemId: actualItemId,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
} else {
// Remove from library
int? libraryItemId;
if (track.provider == 'library') {
libraryItemId = int.tryParse(track.itemId);
} else if (track.providerMappings != null) {
final libraryMapping = track.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => track.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
await maProvider.removeFromLibrary(
mediaType: 'track',
libraryItemId: libraryItemId,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling track library: $e');
}
}
Future<void> _playPlaylist() async { Future<void> _playPlaylist() async {
if (_tracks.isEmpty) return; if (_tracks.isEmpty) return;
@@ -547,157 +378,194 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
void _showPlayOnMenu(BuildContext context) { void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.showPlayerSelectorForAction( GlobalPlayerOverlay.hidePlayer();
contextHint: S.of(context)!.selectPlayerToPlayPlaylist,
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async { onPlayerSelected: (player) async {
maProvider.selectPlayer(player); maProvider.selectPlayer(player);
await maProvider.playTracks(player.playerId, _tracks); await maProvider.playTracks(player.playerId, _tracks);
}, },
); ).whenComplete(() {
} GlobalPlayerOverlay.showPlayer();
});
void _showMoreMenu(BuildContext context, ColorScheme colorScheme) {
final maProvider = context.read<MusicAssistantProvider>();
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
ListTile(
leading: Icon(Icons.speaker_group_outlined, color: colorScheme.onSurface),
title: Text(S.of(context)!.playOn, style: TextStyle(color: colorScheme.onSurface)),
onTap: () {
Navigator.pop(context);
_showPlayOnMenu(context);
},
),
ListTile(
leading: Icon(Icons.playlist_add, color: colorScheme.onSurface),
title: Text(S.of(context)!.addToQueue, style: TextStyle(color: colorScheme.onSurface)),
onTap: () async {
Navigator.pop(context);
// Add to queue on selected player
final player = maProvider.selectedPlayer;
if (player != null) {
try {
await maProvider.addTracksToQueue(player.playerId, _tracks);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
if (mounted) {
_showError(S.of(context)!.failedToAddToQueue(e.toString()));
}
}
} else {
_showError(S.of(context)!.noPlayerSelected);
}
},
),
ListTile(
leading: Icon(Icons.queue_music, color: colorScheme.onSurface),
title: Text(S.of(context)!.addToQueueOn, style: TextStyle(color: colorScheme.onSurface)),
onTap: () {
Navigator.pop(context);
_addPlaylistToQueue();
},
),
const SizedBox(height: 8),
],
),
),
);
} }
void _addPlaylistToQueue() { void _addPlaylistToQueue() {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
GlobalPlayerOverlay.showPlayerSelectorForAction( GlobalPlayerOverlay.hidePlayer();
contextHint: S.of(context)!.addToQueueOn,
onPlayerSelected: (player) async { showModalBottomSheet(
try { context: context,
_logger.log('Adding playlist to queue on ${player.name}'); backgroundColor: Colors.transparent,
await maProvider.addTracksToQueue( builder: (context) => Container(
player.playerId, decoration: BoxDecoration(
_tracks, color: Theme.of(context).colorScheme.surface,
); borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
_logger.log('Playlist added to queue on ${player.name}'); ),
if (mounted) { child: Column(
ScaffoldMessenger.of(context).showSnackBar( mainAxisSize: MainAxisSize.min,
SnackBar( children: [
content: Text(S.of(context)!.tracksAddedToQueue), Spacing.vGap16,
duration: const Duration(seconds: 1), Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, playerIndex) {
final player = players[playerIndex];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
try {
_logger.log('Adding playlist to queue on ${player.name}');
await maProvider.playTracks(
player.playerId,
_tracks,
startIndex: 0,
clearQueue: false,
);
_logger.log('Playlist added to queue on ${player.name}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding playlist to queue: $e');
_showError('Failed to add playlist to queue: $e');
}
},
);
},
),
), ),
); SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
} ],
} catch (e) { ),
_logger.log('Error adding playlist to queue: $e'); ),
_showError('Failed to add playlist to queue: $e'); ).whenComplete(() {
} GlobalPlayerOverlay.showPlayer();
}, });
);
} }
void _addTrackToQueue(BuildContext context, int index) { void _addTrackToQueue(BuildContext context, int index) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
GlobalPlayerOverlay.showPlayerSelectorForAction( GlobalPlayerOverlay.hidePlayer();
contextHint: S.of(context)!.addToQueueOn,
onPlayerSelected: (player) async { showModalBottomSheet(
try { context: context,
await maProvider.addTracksToQueue( backgroundColor: Colors.transparent,
player.playerId, builder: (context) => Container(
_tracks, decoration: BoxDecoration(
startIndex: index, color: Theme.of(context).colorScheme.surface,
); borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
if (mounted) { ),
ScaffoldMessenger.of(context).showSnackBar( child: Column(
SnackBar( mainAxisSize: MainAxisSize.min,
content: Text(S.of(context)!.tracksAddedToQueue), children: [
duration: const Duration(seconds: 1), Spacing.vGap16,
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, playerIndex) {
final player = players[playerIndex];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
try {
await maProvider.playTracks(
player.playerId,
_tracks,
startIndex: index,
clearQueue: false,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding to queue: $e');
_showError('Failed to add to queue: $e');
}
},
);
},
),
), ),
); SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
} ],
} catch (e) { ),
_logger.log('Error adding to queue: $e'); ),
_showError('Failed to add to queue: $e'); ).whenComplete(() {
} GlobalPlayerOverlay.showPlayer();
}, });
);
} }
void _showPlayRadioMenu(BuildContext context, int trackIndex) { void _showPlayRadioMenu(BuildContext context, int trackIndex) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final track = _tracks[trackIndex]; final track = _tracks[trackIndex];
GlobalPlayerOverlay.showPlayerSelectorForAction( GlobalPlayerOverlay.hidePlayer();
contextHint: S.of(context)!.selectPlayerForRadio,
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async { onPlayerSelected: (player) async {
maProvider.selectPlayer(player); maProvider.selectPlayer(player);
await maProvider.playRadio(player.playerId, track); await maProvider.playRadio(player.playerId, track);
}, },
); ).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
} }
void _showError(String message) { void _showError(String message) {
@@ -838,44 +706,30 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + _heroTagSuffix, tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + _heroTagSuffix,
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: SizedBox( child: Container(
width: coverSize, width: coverSize,
height: coverSize, height: coverSize,
child: Stack( color: colorScheme.surfaceContainerHighest,
fit: StackFit.expand, child: imageUrl != null
children: [ ? CachedNetworkImage(
Container( imageUrl: imageUrl,
color: colorScheme.surfaceContainerHighest, fit: BoxFit.cover,
child: imageUrl != null memCacheWidth: 256,
? CachedNetworkImage( memCacheHeight: 256,
imageUrl: imageUrl, fadeInDuration: Duration.zero,
fit: BoxFit.cover, fadeOutDuration: Duration.zero,
memCacheWidth: 256, placeholder: (_, __) => const SizedBox(),
memCacheHeight: 256, errorWidget: (_, __, ___) => Icon(
fadeInDuration: Duration.zero, Icons.playlist_play_rounded,
fadeOutDuration: Duration.zero, size: coverSize * 0.43,
placeholder: (_, __) => const SizedBox(), color: colorScheme.onSurfaceVariant,
errorWidget: (_, __, ___) => Icon( ),
Icons.playlist_play_rounded, )
size: coverSize * 0.43, : Icon(
color: colorScheme.onSurfaceVariant, Icons.playlist_play_rounded,
), size: coverSize * 0.43,
) color: colorScheme.onSurfaceVariant,
: Icon(
Icons.playlist_play_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
// Provider icon overlay
if (widget.playlist.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.playlist.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
), ),
],
),
), ),
), ),
), ),
@@ -887,7 +741,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: [
@@ -928,6 +782,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
children: [ children: [
// Main Play Button // Main Play Button
Expanded( Expanded(
flex: 2,
child: SizedBox( child: SizedBox(
height: 50, height: 50,
child: ElevatedButton.icon( child: ElevatedButton.icon(
@@ -947,6 +802,42 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
// "Play on..." Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoading || _tracks.isEmpty ? null : () => _showPlayOnMenu(context),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
),
),
const SizedBox(width: 12),
// "Add to Queue" Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoading || _tracks.isEmpty ? null : _addPlaylistToQueue,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add),
),
),
const SizedBox(width: 12),
// Favorite Button // Favorite Button
SizedBox( SizedBox(
height: 50, height: 50,
@@ -960,53 +851,10 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
), ),
), ),
child: Icon( child: Icon(
Icons.favorite, _isFavorite ? Icons.favorite : Icons.favorite_border,
size: 25,
color: _isFavorite color: _isFavorite
? colorScheme.error ? colorScheme.error
: Colors.white70, : colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
// Three-dot Menu Button
SizedBox(
height: 50,
width: 50,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
if (_isLoading || _tracks.isEmpty) return;
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.playlist,
item: widget.playlist,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 25,
color: _isLoading || _tracks.isEmpty
? Colors.white70.withOpacity(0.38)
: Colors.white70,
),
), ),
), ),
), ),
@@ -1040,108 +888,168 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
final track = _tracks[index]; final track = _tracks[index];
final isExpanded = _expandedTrackIndex == index;
final trackImageUrl = context.read<MusicAssistantProvider>().getImageUrl(track, size: 80); final trackImageUrl = context.read<MusicAssistantProvider>().getImageUrl(track, size: 80);
return GestureDetector( return Column(
onLongPressStart: (details) { mainAxisSize: MainAxisSize.min,
MediaContextMenu.show( children: [
context: context, ListTile(
position: details.globalPosition, leading: Row(
mediaType: ContextMenuMediaType.track, mainAxisSize: MainAxisSize.min,
item: track, children: [
isFavorite: track.favorite ?? false, // Track number
isInLibrary: track.inLibrary,
onToggleFavorite: () => _toggleTrackFavorite(index),
onToggleLibrary: () => _toggleTrackLibrary(index),
adaptiveColorScheme: adaptiveScheme,
);
},
child: ListTile(
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Track number
SizedBox(
width: 28,
child: Text(
'${index + 1}',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
textAlign: TextAlign.right,
),
),
const SizedBox(width: 12),
// Track artwork
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: trackImageUrl != null
? CachedNetworkImage(
imageUrl: trackImageUrl,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.music_note,
size: 24,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.music_note,
size: 24,
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
title: Text(
track.name,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
track.artistsString,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (HiResBadge.getTooltip(track) != null) ...[
HiResBadge.fromTrack(track, primaryColor: colorScheme.primary)!,
const SizedBox(width: 12),
],
if (track.duration != null)
SizedBox( SizedBox(
width: 40, width: 28,
child: Text( child: Text(
_formatDuration(track.duration!), '${index + 1}',
style: textTheme.bodySmall?.copyWith( style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5), color: colorScheme.onSurface.withOpacity(0.5),
), ),
textAlign: TextAlign.right, textAlign: TextAlign.right,
), ),
), ),
], const SizedBox(width: 12),
// Track artwork
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: trackImageUrl != null
? CachedNetworkImage(
imageUrl: trackImageUrl,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.music_note,
size: 24,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.music_note,
size: 24,
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
title: Text(
track.name,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
track.artistsString,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: track.duration != null
? Text(
_formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
)
: null,
onTap: () {
if (isExpanded) {
setState(() {
_expandedTrackIndex = null;
});
} else {
_playTrack(index);
}
},
onLongPress: () {
setState(() {
_expandedTrackIndex = isExpanded ? null : index;
});
},
), ),
onTap: () => _playTrack(index), // Expandable action buttons
), AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: isExpanded
? Padding(
padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Radio button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showPlayRadioMenu(context, index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.radio, size: 20),
),
),
const SizedBox(width: 10),
// Add to queue button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _addTrackToQueue(context, index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 20),
),
),
const SizedBox(width: 10),
// Favorite button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _toggleTrackFavorite(index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
track.favorite == true ? Icons.favorite : Icons.favorite_border,
size: 20,
color: track.favorite == true
? colorScheme.error
: colorScheme.onSurfaceVariant,
),
),
),
],
),
)
: const SizedBox.shrink(),
),
],
); );
}, },
childCount: _tracks.length, childCount: _tracks.length,
+252 -497
View File
@@ -1,23 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart'; import '../widgets/player_picker_sheet.dart';
import '../widgets/media_context_menu.dart';
import '../theme/palette_helper.dart'; import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../theme/design_tokens.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/settings_service.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/page_transitions.dart';
import 'podcast_episode_detail_screen.dart';
class PodcastDetailScreen extends StatefulWidget { class PodcastDetailScreen extends StatefulWidget {
final MediaItem podcast; final MediaItem podcast;
@@ -43,8 +36,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
bool _isLoadingEpisodes = false; bool _isLoadingEpisodes = false;
bool _isDescriptionExpanded = false; bool _isDescriptionExpanded = false;
bool _isInLibrary = false; bool _isInLibrary = false;
bool _isFavorite = false; String? _expandedEpisodeId; // Track which episode is expanded for actions
String _sortOrder = 'newest'; // 'newest', 'oldest', 'alpha', 'duration'
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : ''; String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@@ -52,109 +44,16 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_isInLibrary = _checkIfInLibrary(widget.podcast); _isInLibrary = _checkIfInLibrary(widget.podcast);
_isFavorite = widget.podcast.favorite ?? false;
_loadSortPreference();
// Mark that we're on a detail screen and extract colors immediately
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { Future.delayed(const Duration(milliseconds: 350), () {
markDetailScreenEntered(context); if (mounted) {
_extractColors(); // Extract colors immediately - async so won't block _extractColors();
} }
});
_loadEpisodes(); _loadEpisodes();
}); });
} }
Future<void> _loadSortPreference() async {
final sortOrder = await SettingsService.getPodcastEpisodesSortOrder();
if (mounted) {
setState(() {
_sortOrder = sortOrder;
});
}
}
void _toggleSortOrder() {
// Cycle through sort orders: newest -> oldest -> alpha -> duration -> newest
String newOrder;
switch (_sortOrder) {
case 'newest':
newOrder = 'oldest';
break;
case 'oldest':
newOrder = 'alpha';
break;
case 'alpha':
newOrder = 'duration';
break;
default:
newOrder = 'newest';
}
setState(() {
_sortOrder = newOrder;
_sortEpisodes();
});
SettingsService.setPodcastEpisodesSortOrder(newOrder);
}
void _sortEpisodes() {
switch (_sortOrder) {
case 'newest':
_episodes.sort((a, b) {
final aDate = _getPublishDate(a.metadata);
final bDate = _getPublishDate(b.metadata);
if (aDate == null && bDate == null) return 0;
if (aDate == null) return 1;
if (bDate == null) return -1;
return bDate.compareTo(aDate); // Newest first
});
break;
case 'oldest':
_episodes.sort((a, b) {
final aDate = _getPublishDate(a.metadata);
final bDate = _getPublishDate(b.metadata);
if (aDate == null && bDate == null) return 0;
if (aDate == null) return 1;
if (bDate == null) return -1;
return aDate.compareTo(bDate); // Oldest first
});
break;
case 'alpha':
_episodes.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
break;
case 'duration':
_episodes.sort((a, b) {
final aDur = a.duration ?? Duration.zero;
final bDur = b.duration ?? Duration.zero;
return aDur.compareTo(bDur); // Shortest first
});
break;
}
}
DateTime? _getPublishDate(Map<String, dynamic>? metadata) {
if (metadata == null) return null;
dynamic dateValue = metadata['published'] ??
metadata['pub_date'] ??
metadata['release_date'] ??
metadata['aired'] ??
metadata['timestamp'];
if (dateValue == null) return null;
try {
if (dateValue is String) {
return DateTime.tryParse(dateValue);
} else if (dateValue is int) {
return DateTime.fromMillisecondsSinceEpoch(dateValue * 1000);
}
} catch (e) {
// Silent failure
}
return null;
}
/// Check if podcast is in library /// Check if podcast is in library
bool _checkIfInLibrary(MediaItem podcast) { bool _checkIfInLibrary(MediaItem podcast) {
if (podcast.provider == 'library') return true; if (podcast.provider == 'library') return true;
@@ -167,6 +66,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
try { try {
final newState = !_isInLibrary; final newState = !_isInLibrary;
bool success;
if (newState) { if (newState) {
// Add to library - MUST use non-library provider // Add to library - MUST use non-library provider
@@ -197,35 +97,12 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
} }
} }
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
_logger.log('Adding podcast to library: provider=$actualProvider, itemId=$actualItemId'); _logger.log('Adding podcast to library: provider=$actualProvider, itemId=$actualItemId');
// Fire and forget - API call happens in background success = await maProvider.addToLibrary(
maProvider.addToLibrary(
mediaType: 'podcast', mediaType: 'podcast',
provider: actualProvider, provider: actualProvider,
itemId: actualItemId, itemId: actualItemId,
).catchError((e) { );
_logger.log('❌ Failed to add podcast to library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
} else { } else {
// Remove from library // Remove from library
int? libraryItemId; int? libraryItemId;
@@ -246,7 +123,13 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
return; return;
} }
// OPTIMISTIC UPDATE: Update UI immediately success = await maProvider.removeFromLibrary(
mediaType: 'podcast',
libraryItemId: libraryItemId,
);
}
if (success) {
setState(() { setState(() {
_isInLibrary = newState; _isInLibrary = newState;
}); });
@@ -254,25 +137,13 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(S.of(context)!.removedFromLibrary), content: Text(
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
} }
// Fire and forget - API call happens in background
maProvider.removeFromLibrary(
mediaType: 'podcast',
libraryItemId: libraryItemId,
).catchError((e) {
_logger.log('❌ Failed to remove podcast from library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
} }
} catch (e) { } catch (e) {
_logger.log('Error toggling podcast library: $e'); _logger.log('Error toggling podcast library: $e');
@@ -287,71 +158,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
} }
} }
/// Toggle favorite status
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isFavorite;
try {
bool success;
if (newState) {
String actualProvider = widget.podcast.provider;
String actualItemId = widget.podcast.itemId;
if (widget.podcast.providerMappings != null && widget.podcast.providerMappings!.isNotEmpty) {
final mapping = widget.podcast.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => widget.podcast.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => widget.podcast.providerMappings!.first,
),
);
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'podcast',
itemId: actualItemId,
provider: actualProvider,
);
} else {
int? libraryItemId;
if (widget.podcast.provider == 'library') {
libraryItemId = int.tryParse(widget.podcast.itemId);
} else if (widget.podcast.providerMappings != null) {
final libraryMapping = widget.podcast.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.podcast.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
success = await maProvider.removeFromFavorites(
mediaType: 'podcast',
libraryItemId: libraryItemId,
);
}
if (success && mounted) {
setState(() => _isFavorite = newState);
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error toggling podcast favorite: $e');
}
}
Future<void> _extractColors() async { Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
// Use initialImageUrl first for consistency with hero animation // Use initialImageUrl first for consistency with hero animation
@@ -359,17 +165,14 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
if (imageUrl != null) { if (imageUrl != null) {
try { try {
// Use isolate-based extraction to avoid blocking the main thread final colorSchemes = await PaletteHelper.extractColorSchemes(
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl); CachedNetworkImageProvider(imageUrl),
);
if (mounted && colorSchemes != null) { if (mounted && colorSchemes != null) {
setState(() { setState(() {
_lightColorScheme = colorSchemes.$1; _lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2; _darkColorScheme = colorSchemes.$2;
}); });
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
} }
} catch (e) { } catch (e) {
_logger.log('🎙️ Error extracting colors: $e'); _logger.log('🎙️ Error extracting colors: $e');
@@ -387,38 +190,10 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
try { try {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) { if (maProvider.api != null) {
List<MediaItem> episodes = []; final episodes = await maProvider.api!.getPodcastEpisodes(
widget.podcast.itemId,
// Try loading with the podcast's own ID and provider first provider: widget.podcast.provider,
try { );
episodes = await maProvider.api!.getPodcastEpisodes(
widget.podcast.itemId,
provider: widget.podcast.provider,
);
} catch (e) {
_logger.log('🎙️ Primary episode load failed: $e');
}
// If that failed and we have provider mappings, try those
if (episodes.isEmpty && widget.podcast.providerMappings != null) {
for (final mapping in widget.podcast.providerMappings!) {
if (mapping.itemId.isNotEmpty && mapping.providerInstance.isNotEmpty) {
try {
_logger.log('🎙️ Trying provider mapping: ${mapping.providerInstance}');
episodes = await maProvider.api!.getPodcastEpisodes(
mapping.itemId,
provider: mapping.providerInstance,
);
if (episodes.isNotEmpty) {
_logger.log('🎙️ Loaded episodes via provider mapping');
break;
}
} catch (e) {
_logger.log('🎙️ Provider mapping failed: $e');
}
}
}
}
if (mounted) { if (mounted) {
_logger.log('🎙️ Loaded ${episodes.length} episodes for ${widget.podcast.name}'); _logger.log('🎙️ Loaded ${episodes.length} episodes for ${widget.podcast.name}');
@@ -430,7 +205,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
} }
setState(() { setState(() {
_episodes = episodes; _episodes = episodes;
_sortEpisodes();
}); });
} }
} }
@@ -478,15 +252,22 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
void _showPlayOnMenu(BuildContext context, MediaItem episode) { void _showPlayOnMenu(BuildContext context, MediaItem episode) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.showPlayerSelectorForAction( GlobalPlayerOverlay.hidePlayer();
contextHint: S.of(context)!.selectPlayerForEpisode,
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async { onPlayerSelected: (player) async {
maProvider.selectPlayer(player); maProvider.selectPlayer(player);
// Set podcast context before playing so player UI shows correct podcast name // Set podcast context before playing so player UI shows correct podcast name
maProvider.setCurrentPodcastName(widget.podcast.name); maProvider.setCurrentPodcastName(widget.podcast.name);
await maProvider.api?.playPodcastEpisode(player.playerId, episode); await maProvider.api?.playPodcastEpisode(player.playerId, episode);
}, },
); ).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
} }
String _formatDuration(Duration? duration) { String _formatDuration(Duration? duration) {
@@ -576,35 +357,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
} }
} }
/// Show context menu for an episode
void _showEpisodeContextMenu(BuildContext context, Offset position, MediaItem episode, String? episodeImageUrl) {
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.podcastEpisode,
item: episode,
isFavorite: false,
isInLibrary: false,
onPlay: () => _playEpisode(episode),
onPlayOn: () => _showPlayOnMenu(context, episode),
onAddToQueue: () => _addToQueue(episode),
onViewDetails: () {
final maProvider = this.context.read<MusicAssistantProvider>();
final initialImageUrl = episodeImageUrl ?? maProvider.getPodcastImageUrl(widget.podcast);
Navigator.push(
this.context,
FadeSlidePageRoute(
child: PodcastEpisodeDetailScreen(
episode: episode,
podcast: widget.podcast,
initialImageUrl: initialImageUrl,
),
),
);
},
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// CRITICAL FIX: Use select() instead of watch() to prevent rebuilds during Hero animation // CRITICAL FIX: Use select() instead of watch() to prevent rebuilds during Hero animation
@@ -678,55 +430,41 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
// Match audiobook pattern: ClipRRect + CachedNetworkImage for smooth hero // Match audiobook pattern: ClipRRect + CachedNetworkImage for smooth hero
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: SizedBox( child: Container(
width: coverSize, width: coverSize,
height: coverSize, height: coverSize,
child: Stack( color: colorScheme.surfaceContainerHighest,
fit: StackFit.expand, child: imageUrl != null
children: [ ? CachedNetworkImage(
Container( imageUrl: imageUrl,
color: colorScheme.surfaceContainerHighest, fit: BoxFit.cover,
child: imageUrl != null // Match source memCacheWidth for smooth Hero animation
? CachedNetworkImage( memCacheWidth: 256,
imageUrl: imageUrl, memCacheHeight: 256,
fit: BoxFit.cover, fadeInDuration: Duration.zero,
// Match source memCacheWidth for smooth Hero animation fadeOutDuration: Duration.zero,
memCacheWidth: 256, placeholder: (_, __) => Center(
memCacheHeight: 256, child: Icon(
fadeInDuration: Duration.zero, MdiIcons.podcast,
fadeOutDuration: Duration.zero, size: coverSize * 0.43,
placeholder: (_, __) => Center( color: colorScheme.onSurfaceVariant,
child: Icon( ),
MdiIcons.podcast, ),
size: coverSize * 0.43, errorWidget: (_, __, ___) => Center(
color: colorScheme.onSurfaceVariant, child: Icon(
), MdiIcons.podcast,
), size: coverSize * 0.43,
errorWidget: (_, __, ___) => Center( color: colorScheme.onSurfaceVariant,
child: Icon( ),
MdiIcons.podcast, ),
size: coverSize * 0.43, )
color: colorScheme.onSurfaceVariant, : Center(
), child: Icon(
), MdiIcons.podcast,
) size: coverSize * 0.43,
: Center( color: colorScheme.onSurfaceVariant,
child: Icon( ),
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (widget.podcast.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.podcast.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
), ),
],
),
), ),
), ),
), ),
@@ -736,7 +474,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: [
@@ -768,6 +506,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
children: [ children: [
// Main Play Latest Episode Button // Main Play Latest Episode Button
Expanded( Expanded(
flex: 2,
child: SizedBox( child: SizedBox(
height: 50, height: 50,
child: ElevatedButton.icon( child: ElevatedButton.icon(
@@ -789,6 +528,24 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
// "Play on..." Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoadingEpisodes || _episodes.isEmpty
? null
: () => _showPlayOnMenu(context, _episodes.first),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
),
),
const SizedBox(width: 12),
// Library button // Library button
SizedBox( SizedBox(
height: 50, height: 50,
@@ -802,76 +559,10 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
), ),
), ),
child: Icon( child: Icon(
Symbols.book_2, _isInLibrary ? Icons.library_add_check : Icons.library_add,
size: 25,
fill: _isInLibrary ? 1 : 0,
color: _isInLibrary color: _isInLibrary
? colorScheme.primary ? colorScheme.primary
: Colors.white70, : colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
// Favorite button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleFavorite,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
Icons.favorite,
size: 25,
color: _isFavorite
? colorScheme.error
: Colors.white70,
),
),
),
const SizedBox(width: 12),
// Three-dot Menu Button
SizedBox(
height: 50,
width: 50,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
if (_isLoadingEpisodes || _episodes.isEmpty) return;
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.podcast,
item: widget.podcast,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
sortOrder: _sortOrder,
onToggleSort: _toggleSortOrder,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 25,
color: _isLoadingEpisodes || _episodes.isEmpty
? Colors.white70.withOpacity(0.38)
: Colors.white70,
),
), ),
), ),
), ),
@@ -978,126 +669,190 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
final episode = _episodes[index]; final episode = _episodes[index];
final episodeDescription = episode.metadata?['description'] as String?; final episodeDescription = episode.metadata?['description'] as String?;
final duration = episode.duration; final duration = episode.duration;
final episodeId = episode.uri ?? episode.itemId;
final isExpanded = _expandedEpisodeId == episodeId;
final episodeImageUrl = context.read<MusicAssistantProvider>().getImageUrl(episode, size: 256); final episodeImageUrl = context.read<MusicAssistantProvider>().getImageUrl(episode, size: 256);
// Try to get release date from metadata // Try to get release date from metadata
final releaseDate = _formatReleaseDate(episode.metadata); final releaseDate = _formatReleaseDate(episode.metadata);
return GestureDetector( return Column(
onTap: () => _playEpisode(episode), children: [
onLongPressStart: (details) => _showEpisodeContextMenu( InkWell(
context, onTap: () => _playEpisode(episode),
details.globalPosition, onLongPress: () {
episode, setState(() {
episodeImageUrl, _expandedEpisodeId = isExpanded ? null : episodeId;
), });
child: Padding( },
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Padding(
child: Row( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
crossAxisAlignment: CrossAxisAlignment.start, child: Row(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
// Episode cover children: [
Hero( // Episode cover (bigger than before)
tag: HeroTags.podcastEpisodeCover + (episode.uri ?? episode.itemId), ClipRRect(
child: ClipRRect( borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(8), child: Container(
child: Container( width: 72,
width: 72, height: 72,
height: 72, color: colorScheme.surfaceContainerHighest,
color: colorScheme.surfaceContainerHighest, child: episodeImageUrl != null
child: episodeImageUrl != null ? CachedNetworkImage(
? CachedNetworkImage( imageUrl: episodeImageUrl,
imageUrl: episodeImageUrl, fit: BoxFit.cover,
fit: BoxFit.cover, fadeInDuration: Duration.zero,
fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero,
fadeOutDuration: Duration.zero, placeholder: (_, __) => Icon(
placeholder: (_, __) => Icon( MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.podcast, MdiIcons.podcast,
size: 32, size: 32,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
errorWidget: (_, __, ___) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
),
),
),
const SizedBox(width: 12),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
episode.name,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
if (episodeDescription != null && episodeDescription.isNotEmpty) ...[ ),
const SizedBox(height: 4), const SizedBox(width: 12),
Text( // Episode info
episodeDescription, Expanded(
maxLines: 2, child: Column(
overflow: TextOverflow.ellipsis, crossAxisAlignment: CrossAxisAlignment.start,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
const SizedBox(height: 6),
// Duration and release date row
Row(
children: [ children: [
if (duration != null && duration > Duration.zero) ...[ Text(
Icon( episode.name,
Icons.access_time, style: textTheme.titleSmall?.copyWith(
size: 14, fontWeight: FontWeight.w500,
color: colorScheme.onSurface.withOpacity(0.5),
), ),
const SizedBox(width: 4), maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (episodeDescription != null && episodeDescription.isNotEmpty) ...[
const SizedBox(height: 4),
Text( Text(
_formatDuration(duration), episodeDescription,
maxLines: isExpanded ? 100 : 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5), color: colorScheme.onSurface.withOpacity(0.6),
), ),
), ),
], ],
if (duration != null && duration > Duration.zero && releaseDate != null) const SizedBox(height: 6),
Padding( // Duration and release date row
padding: const EdgeInsets.symmetric(horizontal: 8), Row(
child: Text( children: [
'•', if (duration != null && duration > Duration.zero) ...[
style: textTheme.bodySmall?.copyWith( Icon(
Icons.access_time,
size: 14,
color: colorScheme.onSurface.withOpacity(0.5), color: colorScheme.onSurface.withOpacity(0.5),
), ),
), const SizedBox(width: 4),
), Text(
if (releaseDate != null) _formatDuration(duration),
Text( style: textTheme.bodySmall?.copyWith(
releaseDate, color: colorScheme.onSurface.withOpacity(0.5),
style: textTheme.bodySmall?.copyWith( ),
color: colorScheme.onSurface.withOpacity(0.5), ),
), ],
), if (duration != null && duration > Duration.zero && releaseDate != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'•',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
if (releaseDate != null)
Text(
releaseDate,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
], ],
), ),
], ),
), ],
), ),
], ),
), ),
), // Expanded action buttons (matching album track style)
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Padding(
padding: const EdgeInsets.only(left: 100, right: 16, bottom: 12),
child: Row(
children: [
// Play button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _playEpisode(episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.play_arrow, size: 20),
),
),
const SizedBox(width: 10),
// Play on... button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showPlayOnMenu(context, episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined, size: 20),
),
),
const SizedBox(width: 10),
// Add to queue button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _addToQueue(episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 20),
),
),
],
),
),
crossFadeState: isExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
],
); );
}, },
childCount: _episodes.length, childCount: _episodes.length,
@@ -1,504 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../theme/design_tokens.dart';
import '../services/debug_logger.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
class PodcastEpisodeDetailScreen extends StatefulWidget {
final MediaItem episode;
final MediaItem podcast;
final String? heroTagSuffix;
final String? initialImageUrl;
const PodcastEpisodeDetailScreen({
super.key,
required this.episode,
required this.podcast,
this.heroTagSuffix,
this.initialImageUrl,
});
@override
State<PodcastEpisodeDetailScreen> createState() => _PodcastEpisodeDetailScreenState();
}
class _PodcastEpisodeDetailScreenState extends State<PodcastEpisodeDetailScreen> {
final _logger = DebugLogger();
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
markDetailScreenEntered(context);
_extractColors();
}
});
}
Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>();
// Try episode image first, fallback to podcast image
final imageUrl = widget.initialImageUrl ??
maProvider.getImageUrl(widget.episode, size: 512) ??
maProvider.getPodcastImageUrl(widget.podcast);
if (imageUrl != null) {
try {
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
if (mounted && colorSchemes != null) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
_logger.log('Error extracting colors for episode: $e');
}
}
}
void _playEpisode() async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer != null) {
try {
maProvider.setCurrentPodcastName(widget.podcast.name);
await maProvider.api?.playPodcastEpisode(selectedPlayer.playerId, widget.episode);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing: ${widget.episode.name}'),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error playing episode: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play episode: $e')),
);
}
}
} else {
_showPlayOnMenu();
}
}
void _showPlayOnMenu() {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForEpisode,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
maProvider.setCurrentPodcastName(widget.podcast.name);
await maProvider.api?.playPodcastEpisode(player.playerId, widget.episode);
},
);
}
Future<void> _addToQueue() async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
try {
final track = Track(
itemId: widget.episode.itemId,
provider: widget.episode.provider,
name: widget.episode.name,
uri: widget.episode.uri,
duration: widget.episode.duration,
metadata: widget.episode.metadata,
);
await maProvider.addTrackToQueue(selectedPlayer.playerId, track);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding episode to queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.failedToAddToQueue(e.toString()))),
);
}
}
}
String _formatDuration(Duration? duration) {
if (duration == null || duration == Duration.zero) return '';
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '$minutes min';
}
String? _formatPublishDate(Map<String, dynamic>? metadata) {
if (metadata == null) return null;
dynamic dateValue = metadata['published'] ??
metadata['pub_date'] ??
metadata['release_date'] ??
metadata['aired'] ??
metadata['timestamp'];
if (dateValue == null) return null;
try {
DateTime? date;
if (dateValue is String) {
date = DateTime.tryParse(dateValue);
} else if (dateValue is int) {
date = DateTime.fromMillisecondsSinceEpoch(dateValue * 1000);
}
if (date != null) {
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return '${months[date.month - 1]} ${date.day}, ${date.year}';
}
} catch (e) {
_logger.log('Error parsing release date: $e');
}
return null;
}
String _stripHtml(String htmlText) {
final withoutTags = htmlText.replaceAll(RegExp(r'<[^>]*>'), '');
final decoded = withoutTags
.replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'");
return decoded.replaceAll(RegExp(r'\s+'), ' ').trim();
}
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final episodeImageUrl = maProvider.getImageUrl(widget.episode, size: 512);
final podcastImageUrl = maProvider.getPodcastImageUrl(widget.podcast);
final imageUrl = widget.initialImageUrl ?? episodeImageUrl ?? podcastImageUrl;
final adaptiveTheme = context.select<ThemeProvider, bool>(
(provider) => provider.adaptiveTheme,
);
final adaptiveLightScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveLightScheme,
);
final adaptiveDarkScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveDarkScheme,
);
final isDark = Theme.of(context).brightness == Brightness.dark;
ColorScheme? adaptiveScheme;
if (adaptiveTheme) {
adaptiveScheme = isDark
? (_darkColorScheme ?? adaptiveDarkScheme)
: (_lightColorScheme ?? adaptiveLightScheme);
}
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final description = widget.episode.metadata?['description'] as String?;
final duration = widget.episode.duration;
final publishDate = _formatPublishDate(widget.episode.metadata);
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
clearAdaptiveColorsOnBack(context);
}
},
child: Scaffold(
backgroundColor: colorScheme.surface,
body: LayoutBuilder(
builder: (context, constraints) {
final coverSize = (constraints.maxWidth * 0.6).clamp(180.0, 280.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () {
clearAdaptiveColorsOnBack(context);
Navigator.pop(context);
},
color: colorScheme.onSurface,
),
flexibleSpace: FlexibleSpaceBar(
background: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 60),
Hero(
tag: HeroTags.podcastEpisodeCover +
(widget.episode.uri ?? widget.episode.itemId) + _heroTagSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: coverSize,
height: coverSize,
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
if (widget.episode.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.episode.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
),
],
),
),
),
),
],
),
),
),
// Header content with title, podcast name, metadata, and actions
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode title
Text(
widget.episode.name,
style: textTheme.headlineMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
Spacing.vGap8,
// Podcast name (tappable to go back)
InkWell(
onTap: () => Navigator.pop(context),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Text(
widget.podcast.name,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.primary,
),
),
),
),
Spacing.vGap16,
// Duration and publish date
Row(
children: [
if (duration != null && duration > Duration.zero) ...[
Icon(
Icons.access_time,
size: 16,
color: colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
_formatDuration(duration),
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
if (duration != null && duration > Duration.zero && publishDate != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'•',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
if (publishDate != null)
Text(
publishDate,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
const SizedBox(height: 24),
// Action buttons row
Row(
children: [
// Main Play Button
Expanded(
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
onPressed: _playEpisode,
icon: const Icon(Icons.play_arrow_rounded),
label: Text(S.of(context)!.play),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
),
const SizedBox(width: 12),
// Play On... Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _showPlayOnMenu,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined, size: 25),
),
),
const SizedBox(width: 12),
// Add to Queue Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _addToQueue,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 25),
),
),
],
),
],
),
),
),
// Full scrollable description (key feature)
if (description != null && description.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 0, 24.0, 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.about,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
Spacing.vGap12,
// Full description - no maxLines, fully scrollable with selectable text
SelectableText(
_stripHtml(description),
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.8),
height: 1.5,
),
),
],
),
),
),
// Bottom spacing for mini player
SliverToBoxAdapter(
child: SizedBox(height: BottomSpacing.withMiniPlayer),
),
],
);
},
),
),
);
}
}
+12 -13
View File
@@ -85,9 +85,9 @@ class _QueueScreenState extends State<QueueScreen> {
if (targetPlayers.isEmpty) { if (targetPlayers.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(
content: Text(S.of(context)!.noOtherPlayersAvailable), content: Text('No other players available to transfer to'),
duration: const Duration(seconds: 2), duration: Duration(seconds: 2),
), ),
); );
return; return;
@@ -96,7 +96,7 @@ class _QueueScreenState extends State<QueueScreen> {
// Show player picker sheet // Show player picker sheet
await showPlayerPickerSheet( await showPlayerPickerSheet(
context: context, context: context,
title: S.of(context)!.transferQueueTo, title: 'Transfer Queue To',
players: targetPlayers, players: targetPlayers,
onPlayerSelected: (targetPlayer) async { onPlayerSelected: (targetPlayer) async {
try { try {
@@ -109,7 +109,7 @@ class _QueueScreenState extends State<QueueScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(S.of(context)!.queueTransferredTo(targetPlayer.name)), content: Text('Queue transferred to ${targetPlayer.name}'),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -119,7 +119,7 @@ class _QueueScreenState extends State<QueueScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(S.of(context)!.failedToTransferQueue(e.toString())), content: Text('Failed to transfer queue: $e'),
backgroundColor: Colors.red.shade700, backgroundColor: Colors.red.shade700,
duration: const Duration(seconds: 3), duration: const Duration(seconds: 3),
), ),
@@ -132,8 +132,8 @@ class _QueueScreenState extends State<QueueScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Use select to only rebuild when selectedPlayer changes final maProvider = context.watch<MusicAssistantProvider>();
final player = context.select<MusicAssistantProvider, dynamic>((p) => p.selectedPlayer); final player = maProvider.selectedPlayer;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFF1a1a1a), backgroundColor: const Color(0xFF1a1a1a),
@@ -152,8 +152,8 @@ class _QueueScreenState extends State<QueueScreen> {
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.swap_horiz_rounded), icon: const Icon(Icons.swap_horiz_rounded),
tooltip: S.of(context)!.transferQueue, tooltip: 'Transfer queue',
onPressed: () => _handleTransferQueue(context.read<MusicAssistantProvider>()), onPressed: () => _handleTransferQueue(maProvider),
), ),
IconButton( IconButton(
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
@@ -161,12 +161,11 @@ class _QueueScreenState extends State<QueueScreen> {
), ),
], ],
), ),
body: _buildBody(context), body: _buildBody(maProvider),
); );
} }
Widget _buildBody(BuildContext context) { Widget _buildBody(MusicAssistantProvider maProvider) {
final maProvider = context.read<MusicAssistantProvider>();
if (_isLoading) { if (_isLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -157,17 +157,6 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
// Note: We don't actually stop playback - this button is repurposed for player switching // Note: We don't actually stop playback - this button is repurposed for player switching
} }
/// Fully stop the foreground service and release resources
/// Called after idle timeout to save battery
Future<void> stopService() async {
_logger.log('MassivAudioHandler: Stopping foreground service (idle timeout)');
_isRemoteMode = false;
_currentMediaItem = null;
await _player.stop();
// Call the base stop() to properly stop the foreground service
await super.stop();
}
@override @override
Future<void> seek(Duration position) => _player.seek(position); Future<void> seek(Duration position) => _player.seek(position);

Some files were not shown because too many files have changed in this diff Show More