Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f7da70352 | ||
|
|
526727955f | ||
|
|
0342caaf5c | ||
|
|
d85dc9e592 | ||
|
|
9338828ac7 | ||
|
|
d920abd241 | ||
|
|
02f7d501ff | ||
|
|
5f72cca25e | ||
|
|
c2f75797e1 | ||
|
|
3d982892de | ||
|
|
c48f9d19c4 | ||
|
|
e4f24766d2 | ||
|
|
d7ac8befac | ||
|
|
8a2a3b8a14 | ||
|
|
ea1133c4d6 | ||
|
|
cb2595e36a | ||
|
|
70e983de51 | ||
|
|
251579d32a | ||
|
|
9d5e8abc11 | ||
|
|
225a1bd010 | ||
|
|
ef5f6b6483 | ||
|
|
4a369fa5f5 | ||
|
|
52489fa97a | ||
|
|
5c52ef2738 | ||
|
|
447b8c8a93 | ||
|
|
eb34663d3f | ||
|
|
2d359db25c | ||
|
|
0845d6d29a | ||
|
|
c80a86abe8 | ||
|
|
8358274f73 | ||
|
|
9200af705a | ||
|
|
1eed678deb | ||
|
|
4ab31c11df | ||
|
|
4c7ecfbf88 | ||
|
|
d376fd3725 | ||
|
|
a7893e6ad9 | ||
|
|
d3474a2665 | ||
|
|
5bfa91c566 | ||
|
|
884b457139 | ||
|
|
5e2599bdb8 | ||
|
|
4dec8f43a2 | ||
|
|
ee7a08a859 | ||
|
|
4351a32cde | ||
|
|
587115dc2b | ||
|
|
862c9d5ef3 | ||
|
|
ed7613e6ad | ||
|
|
74d2b1b1eb | ||
|
|
951c8a68ea | ||
|
|
c24dec51c5 | ||
|
|
11ed7e7d34 | ||
|
|
40f72f8db9 | ||
|
|
5e2fb6ff32 | ||
|
|
47894e0bb9 | ||
|
|
c9f7321bec | ||
|
|
277020616f | ||
|
|
cd43ad0868 | ||
|
|
98175719bf | ||
|
|
e5274ca72e | ||
|
|
6a78405dc1 | ||
|
|
49e8bbbb30 | ||
|
|
beeebeb996 | ||
|
|
49077b9858 | ||
|
|
9dbcf9971f | ||
|
|
c5bcc509e0 | ||
|
|
2f933da413 | ||
|
|
2fdd3b4286 | ||
|
|
779810674b | ||
|
|
0dc069b071 | ||
|
|
ca7b5d8d35 | ||
|
|
b15de9775f | ||
|
|
5fcfe1d3b3 | ||
|
|
6a8a4972dd | ||
|
|
73a23594cf | ||
|
|
02d7ea12cb | ||
|
|
601dd8ac7a | ||
|
|
6dbd0bbdfd | ||
|
|
dedd1d2144 | ||
|
|
29eb7165c2 | ||
|
|
92333f8216 | ||
|
|
1970bbf910 | ||
|
|
cbb5c68d5e | ||
|
|
a7df388a5f | ||
|
|
b351cbd774 | ||
|
|
05f09f76c9 | ||
|
|
46ab36726a | ||
|
|
4643288ff8 | ||
|
|
e7c03c65a9 | ||
|
|
aff9cb1819 | ||
|
|
473f076b90 | ||
|
|
c009603302 | ||
|
|
e89a4e26e5 | ||
|
|
139ced9ae4 | ||
|
|
9030a2bb0d | ||
|
|
440a02e79f | ||
|
|
fdd51af7db | ||
|
|
3bc8ef91c6 | ||
|
|
4edcf858af | ||
|
|
2e274851d2 | ||
|
|
5d00705f91 | ||
|
|
71238981a3 | ||
|
|
28d91956f1 | ||
|
|
036d74a91e | ||
|
|
038fcccdd0 | ||
|
|
df38f8e828 | ||
|
|
6d7e233b7d | ||
|
|
8408db3abb | ||
|
|
5cbca93a6d | ||
|
|
f7caf67f56 | ||
|
|
377d2a54cd | ||
|
|
3b1a33d405 | ||
|
|
62f36a0d8b | ||
|
|
5addb922ad | ||
|
|
e160d12075 | ||
|
|
c3c9ff4d4b | ||
|
|
8b722ffdda | ||
|
|
e7996bcf78 | ||
|
|
fb90502887 | ||
|
|
d39727e585 | ||
|
|
7ca9b557ee | ||
|
|
a39a45bfa4 | ||
|
|
2669d05a00 | ||
|
|
2636a09d85 | ||
|
|
ba5d0566d1 | ||
|
|
cfdef31274 | ||
|
|
0124a955f0 | ||
|
|
9848399343 | ||
|
|
66f93c933f | ||
|
|
db995c5643 | ||
|
|
a3af055688 | ||
|
|
29ea825a12 | ||
|
|
212a1e0818 | ||
|
|
751d317f2a | ||
|
|
9b38f4fa3c | ||
|
|
07b2210a6a | ||
|
|
2e95cb6c95 | ||
|
|
1e64a6bbce | ||
|
|
fea33a9720 | ||
|
|
7b372a7984 | ||
|
|
2dcc6d5e24 | ||
|
|
460f7208eb | ||
|
|
a4c08cadc8 | ||
|
|
36e5ee7afd | ||
|
|
a8bb34ff8c | ||
|
|
2883899e75 | ||
|
|
3e91bbb10c | ||
|
|
b63d35bbe9 | ||
|
|
8e4014bd56 | ||
|
|
dc6bdae5df | ||
|
|
7bb1121867 | ||
|
|
57e1a9904f | ||
|
|
de0290ce66 | ||
|
|
1e64673ffe | ||
|
|
660c4d8388 | ||
|
|
00ccbf9e59 | ||
|
|
adc37c3320 | ||
|
|
a3f0a47b96 | ||
|
|
cd5bef04c2 | ||
|
|
bd3be91095 | ||
|
|
5c7970029c | ||
|
|
2721e83cbf | ||
|
|
1b669aecec | ||
|
|
05f44a6cc0 | ||
|
|
999e56d116 | ||
|
|
ef68252ba5 | ||
|
|
7d47fa18f5 | ||
|
|
9542cdb7d7 | ||
|
|
eb8225b440 | ||
|
|
7d558ea135 | ||
|
|
b19acdadc4 | ||
|
|
4577242e0f | ||
|
|
58e8265d2a | ||
|
|
4a9923d431 | ||
|
|
d81a3ba3a5 | ||
|
|
1bc35a9a16 | ||
|
|
dd738120f1 | ||
|
|
f26548cc17 | ||
|
|
1bb6a49e22 |
@@ -1,136 +1,257 @@
|
|||||||
# Ensemble Flutter App - Code Audit Report
|
# Ensemble Codebase Audit Report
|
||||||
|
|
||||||
**Date:** January 4, 2026
|
**Date**: 2026-01-14
|
||||||
**Branch:** `audit/code-quality-review`
|
**Scope**: Full codebase audit (104 Dart files, 18 screens)
|
||||||
**Files Audited:** 93 Dart files
|
**Focus Areas**: UI Continuity, Performance, Logic/Best Practices
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Executive Summary
|
## Executive Summary
|
||||||
|
|
||||||
The codebase is generally well-maintained with good existing performance optimizations (RepaintBoundary, ListView.builder, cacheExtent settings). Key improvement areas identified:
|
The Ensemble codebase is a **mature, feature-rich Flutter application** with many good patterns already in place. However, the audit identified **significant architectural debt** that should be addressed:
|
||||||
|
|
||||||
| Category | Issues Found | Priority |
|
| Severity | Count | Key Themes |
|
||||||
|----------|-------------|----------|
|
|----------|-------|------------|
|
||||||
| Deprecated APIs | 20 instances | HIGH |
|
| **Critical** | 6 | God classes (5,443 & 4,209 lines), design token adoption, startup blocking |
|
||||||
| Missing const constructors | 100+ instances | MEDIUM |
|
| **High** | 15 | Touch targets, batch DB operations, over-notification, code duplication |
|
||||||
| Image cache parameters | 7 locations | MEDIUM |
|
| **Medium** | 25+ | Inconsistencies, memory issues, missing error boundaries |
|
||||||
| Code duplication | 8 view mode functions | LOW |
|
| **Low** | 15+ | Minor polish items |
|
||||||
| Color constants | 3 files with duplicates | LOW |
|
|
||||||
|
**Top 3 Priorities:**
|
||||||
|
1. **Split `MusicAssistantProvider` (5,443 lines)** - God object handling everything
|
||||||
|
2. **Split `new_library_screen.dart` (4,209 lines)** - 10 scroll controllers, 15+ view modes
|
||||||
|
3. **Fix startup performance** - Sequential blocking ops could save 1-8 seconds
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. CRITICAL: Deprecated API Usage
|
## Critical Issues
|
||||||
|
|
||||||
### `colorScheme.background` (20 instances)
|
### 1. God Object: MusicAssistantProvider (5,443 lines)
|
||||||
Flutter Material 3 deprecates `ColorScheme.background`. Replace with `colorScheme.surface`.
|
**File**: `lib/providers/music_assistant_provider.dart`
|
||||||
|
- 54 `notifyListeners()` calls
|
||||||
|
- 30+ state variables
|
||||||
|
- 100+ public methods
|
||||||
|
- Handles: connection, auth, players, library, search, caching, local playback, Sendspin
|
||||||
|
- **Fix**: Split into ConnectionManager, PlayerStateManager, LibraryStateManager, SearchStateManager
|
||||||
|
|
||||||
**Files affected:**
|
### 2. God Object: NewLibraryScreen (4,209 lines)
|
||||||
- lib/main.dart:335, 336
|
**File**: `lib/screens/new_library_screen.dart`
|
||||||
- lib/screens/library_artists_screen.dart:21
|
- 10 scroll controllers
|
||||||
- lib/screens/new_home_screen.dart:121
|
- 15+ view mode states
|
||||||
- lib/screens/artist_details_screen.dart:708, 720
|
- 15+ sort order states
|
||||||
- lib/screens/library_playlists_screen.dart:53
|
- Handles: Music, Books, Podcasts, Radio tabs
|
||||||
- lib/screens/library_tracks_screen.dart:19
|
- **Fix**: Extract to ArtistsTab, AlbumsTab, TracksTab, PlaylistsTab, BooksTab, PodcastsTab, RadioTab
|
||||||
- lib/screens/login_screen.dart:489
|
|
||||||
- lib/screens/new_library_screen.dart:933, 957, 1020, 1021
|
### 3. Sequential Blocking Startup (500-1500ms wasted)
|
||||||
- lib/screens/album_details_screen.dart:807, 819
|
**File**: `lib/main.dart:33-66`
|
||||||
- lib/screens/library_albums_screen.dart:19
|
```dart
|
||||||
- lib/screens/search_screen.dart:427, 738, 739
|
await DatabaseService.instance.initialize();
|
||||||
- lib/screens/settings_screen.dart:245
|
await ProfileService.instance.migrateFromOwnerName();
|
||||||
|
await SettingsService.migrateToSecureStorage();
|
||||||
|
await SyncService.instance.loadFromCache();
|
||||||
|
audioHandler = await AudioService.init(...);
|
||||||
|
```
|
||||||
|
- **Fix**: Parallelize with `Future.wait()`, defer AudioService to post-first-frame
|
||||||
|
|
||||||
|
### 4. Design Tokens Exist But Unused
|
||||||
|
**File**: `lib/theme/design_tokens.dart`
|
||||||
|
- `Spacing.*` used in only ~6 of 34+ widget files
|
||||||
|
- `Radii.*` used in only ~5 files
|
||||||
|
- `IconSizes.*` used in only ~4 files
|
||||||
|
- **Fix**: Systematic replacement of hardcoded values
|
||||||
|
|
||||||
|
### 5. Touch Targets Too Small (32x32)
|
||||||
|
**File**: `lib/screens/new_library_screen.dart:1805-1867`
|
||||||
|
- Sort, favorites, view mode buttons are 32x32
|
||||||
|
- Material Design minimum: 48x48
|
||||||
|
- **Fix**: Increase to 48x48 or add padding for hit area
|
||||||
|
|
||||||
|
### 6. No Batch Database Inserts
|
||||||
|
**File**: `lib/services/sync_service.dart:212-273`
|
||||||
|
- 1000 albums = 1000 separate DB transactions
|
||||||
|
- **Fix**: Wrap in single transaction or use Drift batch insert
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. HIGH: Missing const Constructors
|
## High Severity Issues
|
||||||
|
|
||||||
### EdgeInsets.zero (64 instances)
|
### Performance
|
||||||
High-impact optimization - used extensively in button padding.
|
| Issue | File | Impact |
|
||||||
|
|-------|------|--------|
|
||||||
|
| `context.watch()` in nested tabs | new_library_screen.dart:2960 | All tabs rebuild on any provider change |
|
||||||
|
| Consumer wrapping 800-line player | expandable_player.dart:1234 | Full rebuild on every state change |
|
||||||
|
| Inline sorting during build | new_library_screen.dart:3647 | 5-20ms for 1000+ albums |
|
||||||
|
| Multiple setState for author images | new_library_screen.dart:1017 | 20+ rebuilds as images load |
|
||||||
|
| SecureStorage migration every startup | secure_storage_service.dart:103 | 100-300ms wasted |
|
||||||
|
|
||||||
**Key files:**
|
### Architecture
|
||||||
- lib/screens/album_details_screen.dart (12 instances)
|
| Issue | File | Impact |
|
||||||
- lib/screens/artist_details_screen.dart (8 instances)
|
|-------|------|--------|
|
||||||
- lib/screens/audiobook_detail_screen.dart (3 instances)
|
| State duplication across 4 providers | providers/*.dart | Maintenance burden, sync issues |
|
||||||
- lib/screens/audiobook_series_screen.dart (2 instances)
|
| 8 duplicate `_cycle*ViewMode()` methods | new_library_screen.dart:251-402 | DRY violation |
|
||||||
- lib/screens/audiobook_author_screen.dart (2 instances)
|
| 60+ duplicate getter/setter pairs | settings_service.dart:500-1000 | Code bloat |
|
||||||
|
| Global singleton NavigationProvider | navigation_provider.dart:40 | Untestable |
|
||||||
|
| CacheService duplicated in providers | Multiple | Memory waste, inconsistency |
|
||||||
|
|
||||||
### BorderRadius.circular (40+ instances)
|
### UI/UX
|
||||||
Cannot use `const BorderRadius.circular()` directly. Alternative: `const BorderRadius.all(Radius.circular(X))`.
|
| Issue | File | Impact |
|
||||||
|
|-------|------|--------|
|
||||||
|
| Hardcoded bottom spacing (140, 164, 100) | Multiple detail screens | Content obscured by player |
|
||||||
|
| Inconsistent button sizes (44 vs 50) | Multiple screens | Visual disharmony |
|
||||||
|
| No debounce on card taps | album_card.dart:118 | Double navigation possible |
|
||||||
|
| Missing PopScope on AudiobookSeriesScreen | audiobook_series_screen.dart:276 | Adaptive colors not cleared |
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
| Issue | File | Impact |
|
||||||
|
|-------|------|--------|
|
||||||
|
| No Flutter error boundaries | main.dart | Red error screen on build failure |
|
||||||
|
| API returns empty list on error | music_assistant_api.dart | Can't distinguish error from empty |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. MEDIUM: Scroll Performance - Missing Image Cache Parameters
|
## Medium Severity Issues
|
||||||
|
|
||||||
### CachedNetworkImage without memCacheWidth/Height
|
### Performance
|
||||||
|
- PaletteGenerator on main isolate (10-30ms blocking)
|
||||||
|
- Multiple `SharedPreferences.getInstance()` calls (132 occurrences)
|
||||||
|
- AppStartup polling loop with fixed 250ms delay
|
||||||
|
- SystemThemeHelper FutureBuilder recreated on every build
|
||||||
|
|
||||||
**lib/screens/new_library_screen.dart:**
|
### UI Consistency
|
||||||
- Line 1395-1402: Author image (48x48) - missing cache params
|
- Border radius varies: 8, 12, 20, 22, 25 for same element types
|
||||||
- Line 1480-1486: Author card image - missing cache params
|
- `SizedBox(height: 8)` appears 40+ times instead of `Spacing.vGap8`
|
||||||
- Line 1572-1580: Audiobook cover (56x56) - missing cache params
|
- Hardcoded colors for status (Colors.red, Colors.green)
|
||||||
- Line 1740-1745: Audiobook grid cover - missing cache params
|
- Inconsistent icon sizes across screens
|
||||||
- Line 1959-1969: Playlist cover (56x56) - missing cache params
|
|
||||||
- Line 2143-2152: Playlist grid covers - missing cache params
|
|
||||||
- Line 2973-2981: Album cover (56x56) - missing cache params
|
|
||||||
|
|
||||||
**Note:** DecorationImage with CachedNetworkImageProvider cannot have cache size specified (structural Flutter limitation).
|
### State Management
|
||||||
|
- Over-notification (multiple notifyListeners in single methods)
|
||||||
|
- Wide Consumer scope (watch instead of select)
|
||||||
|
- Missing dispose() in some providers
|
||||||
|
- Inconsistent async state handling (loading/error)
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
- `isQueuePanelOpen` vs `isQueuePanelTargetOpen` inconsistency
|
||||||
|
- `popToHome()` doesn't clear adaptive colors
|
||||||
|
- Infinite navigation loop possible (Album → Artist → Album)
|
||||||
|
- Orphaned Hero animations in podcast_card, radio_station_card
|
||||||
|
|
||||||
|
### Data/Caching
|
||||||
|
- Unbounded `_podcastCoverCache`
|
||||||
|
- SyncService listener not removed on error
|
||||||
|
- Redundant API calls between providers
|
||||||
|
- Fire-and-forget persistence race condition
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- No zone error handler for async exceptions
|
||||||
|
- `retryCritical()` retries all errors including non-recoverable
|
||||||
|
- No user notification of offline queue status
|
||||||
|
- DisconnectedState not used consistently
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. LOW: Code Duplication
|
## Quick Wins (High Impact, Low Effort)
|
||||||
|
|
||||||
### View Mode Cycling Functions (8 nearly identical functions)
|
| # | Task | File(s) | Effort |
|
||||||
**File:** lib/screens/new_library_screen.dart
|
|---|------|---------|--------|
|
||||||
|
| 1 | Replace hardcoded bottom spacing with `BottomSpacing.withMiniPlayer` | Detail screens | 30 min |
|
||||||
Functions that follow identical switch/case pattern:
|
| 2 | Add navigation debounce to card widgets | album_card, artist_card, etc. | 1 hr |
|
||||||
- `_cycleArtistsViewMode()` (line 184)
|
| 3 | Fix touch targets to 48x48 minimum | new_library_screen.dart | 15 min |
|
||||||
- `_cycleAlbumsViewMode()` (line 200)
|
| 4 | Add migration skip flag for SecureStorage | secure_storage_service.dart | 30 min |
|
||||||
- `_cyclePlaylistsViewMode()` (line 216)
|
| 5 | Add Flutter error boundary | main.dart | 30 min |
|
||||||
- `_cycleAuthorsViewMode()` (line 232)
|
| 6 | Use `context.select()` instead of `context.watch()` | global_player_overlay, etc. | 2 hr |
|
||||||
- `_cycleAudiobooksViewMode()` (line 248)
|
| 7 | Cache `SharedPreferences.getInstance()` | settings_service.dart | 30 min |
|
||||||
- `_cycleSeriesViewMode()` (line 286)
|
|
||||||
- `_cycleRadioViewMode()` (line 303)
|
|
||||||
- `_cyclePodcastsViewMode()` (line 319)
|
|
||||||
|
|
||||||
**Recommendation:** Create generic utility function.
|
|
||||||
|
|
||||||
### Duplicated Color Constants (3 files)
|
|
||||||
Series fallback colors defined identically in:
|
|
||||||
- lib/widgets/series_row.dart:246-253
|
|
||||||
- lib/screens/audiobook_series_screen.dart:466-473
|
|
||||||
- lib/screens/new_library_screen.dart:2112-2119
|
|
||||||
|
|
||||||
**Recommendation:** Extract to lib/constants/colors.dart
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. POSITIVE FINDINGS (Already Optimized)
|
## Refactoring Roadmap
|
||||||
|
|
||||||
- **RepaintBoundary**: Properly implemented in all list item widgets
|
### Phase 1: Quick Wins (1-2 days)
|
||||||
- **ListView.builder**: Used consistently with cacheExtent: 1000
|
- Fix touch targets
|
||||||
- **ScrollController disposal**: All 8+ controllers properly disposed
|
- Fix bottom spacing
|
||||||
- **Animation patterns**: Proper listener cleanup in expandable_player.dart
|
- Add navigation debounce
|
||||||
- **Debounced operations**: Color extraction debounced to 300ms
|
- Add error boundaries
|
||||||
|
- Cache SharedPreferences singleton
|
||||||
|
|
||||||
|
### Phase 2: Performance (3-5 days)
|
||||||
|
- Parallelize startup operations
|
||||||
|
- Replace `context.watch()` with `context.select()`
|
||||||
|
- Batch database inserts
|
||||||
|
- Add SecureStorage migration skip flag
|
||||||
|
- Fix over-notification in provider
|
||||||
|
|
||||||
|
### Phase 3: Design System (1 week)
|
||||||
|
- Systematic design token adoption
|
||||||
|
- Standardize button sizes (44 vs 50)
|
||||||
|
- Standardize border radius values
|
||||||
|
- Create StatusColors semantic tokens
|
||||||
|
- Replace all magic numbers
|
||||||
|
|
||||||
|
### Phase 4: Architecture (2-3 weeks)
|
||||||
|
- ~~Split MusicAssistantProvider into focused managers~~ **DEFERRED** - MusicAssistantProvider has evolved with Sendspin, Cast-Sendspin ID mapping, position tracker integration. Splitting would require ~1600 lines of sync work. Code is working and tested.
|
||||||
|
- ~~Split new_library_screen into tab widgets~~ **DEFERRED** - Tabs have tight state coupling (15+ view modes, 15+ sort orders, 10 scroll controllers). Extraction would require InheritedWidget/callbacks for all shared state. Risk outweighs benefit.
|
||||||
|
- ✅ Extract generic helpers for SettingsService - Reduced from 1,031 to 874 lines (~15%)
|
||||||
|
- ✅ Remove/integrate unused providers - Deleted PlayerProvider (964 lines), LibraryProvider, ConnectionProvider (651 lines total)
|
||||||
|
- ~~Split music_assistant_api.dart by domain~~ **DEFERRED** - All methods share WebSocket connection and auth state. Clean extraction would require connection manager abstraction.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Recommended Action Plan
|
## Positive Findings
|
||||||
|
|
||||||
### Phase 1: Quick Wins (Low Risk)
|
The codebase already demonstrates many good practices:
|
||||||
1. Replace `colorScheme.background` with `colorScheme.surface` (20 locations)
|
|
||||||
2. Add `const` to EdgeInsets.zero instances
|
|
||||||
|
|
||||||
### Phase 2: Image Optimization
|
- **Performance awareness**: `// PERF:` comments, RepaintBoundary (55 occurrences), image caching (80 memCacheWidth/Height)
|
||||||
1. Add memCacheWidth/memCacheHeight to CachedNetworkImage instances in new_library_screen.dart
|
- **Caching**: Multi-tier caching with LRU eviction, proper TTLs, proper DB indexes
|
||||||
|
- **Error handling**: DebugLogger, RetryHelper, OfflineActionQueue well-implemented
|
||||||
### Phase 3: Code Cleanup (Optional)
|
- **Theme system**: Material You support, adaptive colors from album art
|
||||||
1. Extract view mode cycling to utility function
|
- **Offline support**: Cache-first loading, graceful degradation
|
||||||
2. Centralize color constants
|
- **Navigation**: Consistent FadeSlidePageRoute, proper Hero animations (mostly)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Files by Complexity
|
## Files by Priority
|
||||||
|
|
||||||
| File | Lines | Status |
|
| Priority | File | Lines | Key Issues |
|
||||||
|------|-------|--------|
|
|----------|------|-------|------------|
|
||||||
| lib/screens/search_screen.dart | 3,489 | Good structure, minor fixes needed |
|
| 1 | music_assistant_provider.dart | 5,443 | God object, over-notification |
|
||||||
| lib/screens/new_library_screen.dart | 3,351 | Good performance, some duplication |
|
| 2 | new_library_screen.dart | 4,209 | God class, performance, touch targets |
|
||||||
| lib/widgets/expandable_player.dart | 2,577 | Well-optimized animations |
|
| 3 | main.dart | 549 | Startup blocking, error boundaries |
|
||||||
|
| 4 | music_assistant_api.dart | 3,434 | Silent failures, needs splitting |
|
||||||
|
| 5 | settings_service.dart | 1,019 | Code duplication, no caching |
|
||||||
|
| 6 | expandable_player.dart | ~800 | Wide Consumer scope |
|
||||||
|
| 7 | global_player_overlay.dart | ~500 | BottomSpacing source, Consumer scope |
|
||||||
|
| 8 | All detail screens | Various | Hardcoded bottom spacing |
|
||||||
|
| 9 | All card widgets | Various | Navigation debounce, design tokens |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit Methodology
|
||||||
|
|
||||||
|
This audit was conducted using 9 parallel sub-agents analyzing:
|
||||||
|
|
||||||
|
**UI Continuity (3 agents)**
|
||||||
|
1. Navigation & Flow - back button, Hero animations, tab preservation
|
||||||
|
2. Visual Consistency - design tokens, spacing, typography
|
||||||
|
3. Component Placement - touch targets, bottom spacing, modals
|
||||||
|
|
||||||
|
**Performance (3 agents)**
|
||||||
|
4. Rendering Performance - ListView.builder, const, rebuilds
|
||||||
|
5. Data & Caching - streams, invalidation, memory bounds
|
||||||
|
6. Startup & Load Time - blocking operations, initialization order
|
||||||
|
|
||||||
|
**Logic/Best Practices (3 agents)**
|
||||||
|
7. State Management - Provider patterns, notification frequency
|
||||||
|
8. Error Handling - try-catch, boundaries, resilience
|
||||||
|
9. Code Quality - file size, duplication, SOLID principles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The Ensemble app is well-architected at the macro level but has accumulated technical debt in its largest files. The most impactful improvements would be:
|
||||||
|
|
||||||
|
1. **Splitting the two 4,000+ line files** into focused components
|
||||||
|
2. **Adopting design tokens systematically** for visual consistency
|
||||||
|
3. **Optimizing startup sequence** for faster app launch
|
||||||
|
4. **Using granular state management** (select vs watch) for better performance
|
||||||
|
|
||||||
|
The quick wins section provides immediate value with minimal risk, while the roadmap offers a path to sustainable improvement.
|
||||||
|
|||||||
@@ -20,44 +20,63 @@ This application was built with AI-assisted development using **Claude Code** an
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Local Playback
|
### Local Playback
|
||||||
- **Stream to Your Phone** - Play music from your Music Assistant library directly on your mobile device
|
- **Stream to Your Phone** - Play music from your Music Assistant library directly on your mobile device via Sendspin protocol
|
||||||
- **Background Playback** - Music continues playing when the app is minimized
|
- **Background Playback** - Music continues playing when the app is minimized
|
||||||
- **Media Notifications** - Control playback from your notification shade with album art display
|
- **Media Notifications** - Control playback from your notification shade with album art display
|
||||||
|
- **Instant Response** - Pause/resume in ~300ms
|
||||||
|
|
||||||
### Remote Control
|
### Remote Control
|
||||||
- **Multi-Player Support** - Control any speaker or device connected to Music Assistant
|
- **Multi-Player Support** - Control any speaker or device connected to Music Assistant
|
||||||
- **Device Selector** - Quickly switch between your phone and other players
|
- **Device Selector** - Swipe down on mini player to reveal all your devices
|
||||||
|
- **Multi-Room Grouping** - Long-press any player to sync it with the current player
|
||||||
- **Full Playback Controls** - Play, pause, skip, seek, and adjust volume
|
- **Full Playback Controls** - Play, pause, skip, seek, and adjust volume
|
||||||
- **Queue Management** - View and manage the playback queue
|
- **Volume Precision Mode** - Hold the volume slider for fine-grained control with haptic feedback
|
||||||
|
- **Power Control** - Turn players on/off directly from the mini player
|
||||||
|
|
||||||
|
### Queue Management
|
||||||
|
- **View & Manage Queue** - See upcoming tracks in the playback queue
|
||||||
|
- **Drag to Reorder** - Instant drag handles for reordering tracks
|
||||||
|
- **Swipe to Delete** - Remove tracks with a simple swipe gesture
|
||||||
|
|
||||||
### Home Screen
|
### Home Screen
|
||||||
- **Customizable Rows** - Toggle Recently Played, Discover Artists, and Discover Albums
|
- **Customizable Rows** - Toggle and reorder: Recently Played, Discover Artists, Discover Albums
|
||||||
- **Favorites Rows** - Optional rows for Favorite Albums, Artists, and Tracks
|
- **Favorites Rows** - Optional rows for Favorite Albums, Artists, Tracks, Playlists, and Radio Stations
|
||||||
- **Adaptive Layout** - Rows scale properly for different screen sizes and aspect ratios
|
- **Adaptive Layout** - Rows scale properly for different screen sizes and aspect ratios
|
||||||
- **Pull to Refresh** - Refresh content with a simple pull gesture
|
- **Pull to Refresh** - Refresh content with a simple pull gesture
|
||||||
|
|
||||||
### Library Browsing
|
### Library
|
||||||
- **Browse Your Collection** - Artists, albums, playlists, and tracks from all your music sources
|
- **Music** - Browse artists, albums, playlists, and tracks from all your music sources
|
||||||
|
- **Radio Stations** - Browse and play radio stations with list or grid view
|
||||||
|
- **Podcasts** - Browse podcasts, view episodes with descriptions and publish dates
|
||||||
|
- **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
|
||||||
- **Album Details** - View track listings with artwork
|
- **Letter Scrollbar** - Fast navigation through long lists
|
||||||
- **Artist Details** - View artist albums and top tracks
|
|
||||||
- **Playlist Support** - Browse and play your playlists
|
### Search
|
||||||
- **Search** - Find music across your entire library
|
- **Universal Search** - Find music, podcasts, radio stations, playlists, and audiobooks
|
||||||
|
- **Fuzzy Matching** - Typo-tolerant search (e.g., "beetles" finds "Beatles")
|
||||||
|
- **Smart Scoring** - Results ranked by relevance with colored type indicators
|
||||||
|
- **Search History** - Quickly access your recent searches
|
||||||
|
- **Quick Actions** - Long-press any result to add to queue or play next
|
||||||
|
|
||||||
### Audiobooks
|
### Audiobooks
|
||||||
- **Audiobooks Tab** - Browse your audiobook library with grid/list view options
|
|
||||||
- **Series Support** - View audiobooks organized by series with collage cover art
|
|
||||||
- **Author Browsing** - Browse audiobooks by author
|
|
||||||
- **Chapter Navigation** - Jump between chapters with timestamp display
|
- **Chapter Navigation** - Jump between chapters with timestamp display
|
||||||
- **Progress Tracking** - Track your listening progress across sessions
|
- **Progress Tracking** - Track your listening progress across sessions
|
||||||
- **Continue Listening** - Pick up where you left off
|
- **Continue Listening** - Pick up where you left off
|
||||||
- **Mark as Finished/Unplayed** - Manage your reading progress
|
- **Mark as Finished/Unplayed** - Manage your reading progress
|
||||||
|
- **Series Support** - View audiobooks organized by series with collage cover art
|
||||||
|
|
||||||
|
### Podcasts
|
||||||
|
- **Episode Browser** - View full episode list with artwork and descriptions
|
||||||
|
- **Skip Controls** - Skip forward/backward during playback
|
||||||
|
- **High-Resolution Artwork** - Fetched via iTunes for best quality
|
||||||
|
|
||||||
### Smart Features
|
### Smart Features
|
||||||
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
|
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
|
||||||
- **Auto-Reconnect** - Automatically reconnects when connection is lost
|
- **Auto-Reconnect** - Automatically reconnects when connection is lost
|
||||||
- **Offline Browsing** - Browse your cached library even when disconnected
|
- **Offline Browsing** - Browse your cached library even when disconnected
|
||||||
- **Hero Animations** - Smooth transitions between screens
|
- **Hero Animations** - Smooth transitions between screens
|
||||||
|
- **Welcome Screen** - Guided onboarding for first-time users
|
||||||
|
|
||||||
### Theming
|
### Theming
|
||||||
- **Material You** - Dynamic theming based on your device's wallpaper
|
- **Material You** - Dynamic theming based on your device's wallpaper
|
||||||
@@ -67,14 +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=2" alt="Connection Screen" width="150">
|
<img src="assets/screenshots/1.png?v=3" alt="Screenshot 1" width="150">
|
||||||
<img src="assets/screenshots/2.png?v=2" alt="Home Screen" width="150">
|
<img src="assets/screenshots/2.png?v=3" alt="Screenshot 2" width="150">
|
||||||
<img src="assets/screenshots/3.png?v=2" alt="Album Details" width="150">
|
<img src="assets/screenshots/3.png?v=3" alt="Screenshot 3" width="150">
|
||||||
<img src="assets/screenshots/4.png?v=2" alt="Now Playing" width="150">
|
<img src="assets/screenshots/4.png?v=3" alt="Screenshot 4" width="150">
|
||||||
<img src="assets/screenshots/5.png?v=2" alt="Queue" width="150">
|
<img src="assets/screenshots/5.png?v=3" alt="Screenshot 5" width="150">
|
||||||
<img src="assets/screenshots/6.png?v=2" alt="Settings" width="150">
|
<img src="assets/screenshots/6.png?v=3" alt="Screenshot 6" width="150">
|
||||||
<img src="assets/screenshots/7.png?v=2" alt="Audiobooks" width="150">
|
<img src="assets/screenshots/7.png?v=3" alt="Screenshot 7" width="150">
|
||||||
<img src="assets/screenshots/8.png?v=2" alt="Audiobook Player" width="150">
|
<img src="assets/screenshots/8.png?v=3" alt="Screenshot 8" width="150">
|
||||||
|
<img src="assets/screenshots/9.png?v=3" alt="Screenshot 9" width="150">
|
||||||
|
<img src="assets/screenshots/10.png?v=3" alt="Screenshot 10" width="150">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
@@ -84,9 +105,29 @@ Download the latest release from the [Releases page](https://github.com/CollotsS
|
|||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. Launch the app
|
1. Launch the app
|
||||||
2. Enter your Music Assistant server URL (e.g., `music.example.com` or `192.168.1.100`)
|
2. Enter your Music Assistant server URL
|
||||||
3. Connect to your server
|
3. Connect to your server
|
||||||
4. Start playing! Music plays on your phone by default, or tap the device icon to choose a different player.
|
4. Start playing! Music plays on your phone by default, or swipe down on the mini player to choose a different player.
|
||||||
|
|
||||||
|
### Finding Your Server URL
|
||||||
|
|
||||||
|
**Important:** You need the **Music Assistant** URL, not your Home Assistant URL.
|
||||||
|
|
||||||
|
To find the correct URL:
|
||||||
|
1. Open Music Assistant web UI
|
||||||
|
2. Go to **Settings** > **About**
|
||||||
|
3. Look for **Base URL** (e.g., `http://192.168.1.100:8095`)
|
||||||
|
|
||||||
|
### Home Assistant Add-on Users
|
||||||
|
|
||||||
|
If you run Music Assistant as a Home Assistant add-on:
|
||||||
|
- Use the IP address of your Home Assistant server
|
||||||
|
- Enter `8095` in the port field
|
||||||
|
- Do **not** use your Home Assistant URL or ingress URL
|
||||||
|
|
||||||
|
### Remote Access
|
||||||
|
|
||||||
|
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Nginx Proxy Manager, Cloudflare Tunnel).
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
@@ -103,7 +144,7 @@ Ensemble supports multiple authentication methods:
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Music Assistant server (v2.7.0 beta 20 or later)
|
- Music Assistant server (v2.7.0 beta 20 or later recommended)
|
||||||
- Network connectivity to your Music Assistant server
|
- Network connectivity to your Music Assistant server
|
||||||
- Android device (Android 5.0+)
|
- Android device (Android 5.0+)
|
||||||
- Audiobookshelf provider configured in Music Assistant (for audiobook features)
|
- Audiobookshelf provider configured in Music Assistant (for audiobook features)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
android:label="Ensemble"
|
android:label="Ensemble"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:appCategory="audio"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:networkSecurityConfig="@xml/network_security_config">
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -1,4 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="launch_background_color">#1a1a1a</color>
|
<color name="launch_background_color">#1a1a1a</color>
|
||||||
</resources>
|
<color name="ic_launcher_background">#1a1a1a</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip
|
||||||
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 649 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 502 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 498 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 478 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
@@ -29,6 +29,9 @@ class HeroTags {
|
|||||||
/// Podcast cover hero tag prefix
|
/// Podcast cover hero tag prefix
|
||||||
static const String podcastCover = 'podcast_cover_';
|
static const String podcastCover = 'podcast_cover_';
|
||||||
|
|
||||||
|
/// Podcast title hero tag prefix
|
||||||
|
static const String podcastTitle = 'podcast_title_';
|
||||||
|
|
||||||
/// Playlist cover hero tag prefix
|
/// Playlist cover hero tag prefix
|
||||||
static const String playlistCover = 'playlist_cover_';
|
static const String playlistCover = 'playlist_cover_';
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ class Timings {
|
|||||||
/// Search debounce delay
|
/// Search debounce delay
|
||||||
static const Duration searchDebounce = Duration(milliseconds: 500);
|
static const Duration searchDebounce = Duration(milliseconds: 500);
|
||||||
|
|
||||||
|
/// Navigation debounce to prevent double-tap navigation
|
||||||
|
static const Duration navigationDebounce = Duration(milliseconds: 500);
|
||||||
|
|
||||||
/// Delay after track change before updating state
|
/// Delay after track change before updating state
|
||||||
static const Duration trackChangeDelay = Duration(milliseconds: 500);
|
static const Duration trackChangeDelay = Duration(milliseconds: 500);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,6 +203,7 @@
|
|||||||
"english": "Englisch",
|
"english": "Englisch",
|
||||||
"german": "Deutsch",
|
"german": "Deutsch",
|
||||||
"spanish": "Spanisch",
|
"spanish": "Spanisch",
|
||||||
|
"french": "Französisch",
|
||||||
|
|
||||||
"noTracksInPlaylist": "Keine Songs in Wiedergabeliste",
|
"noTracksInPlaylist": "Keine Songs in Wiedergabeliste",
|
||||||
"sortAlphabetically": "Alphabetisch sortieren",
|
"sortAlphabetically": "Alphabetisch sortieren",
|
||||||
|
|||||||
@@ -261,6 +261,11 @@
|
|||||||
"description": "Home screen section title"
|
"description": "Home screen section title"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"favoritePodcasts": "Favorite Podcasts",
|
||||||
|
"@favoritePodcasts": {
|
||||||
|
"description": "Home screen section title"
|
||||||
|
},
|
||||||
|
|
||||||
"searchMusic": "Search music...",
|
"searchMusic": "Search music...",
|
||||||
"@searchMusic": {
|
"@searchMusic": {
|
||||||
"description": "Search placeholder text"
|
"description": "Search placeholder text"
|
||||||
@@ -1055,6 +1060,11 @@
|
|||||||
"description": "Spanish language"
|
"description": "Spanish language"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"french": "French",
|
||||||
|
"@french": {
|
||||||
|
"description": "French language"
|
||||||
|
},
|
||||||
|
|
||||||
"noTracksInPlaylist": "No tracks in playlist",
|
"noTracksInPlaylist": "No tracks in playlist",
|
||||||
"sortAlphabetically": "Sort alphabetically",
|
"sortAlphabetically": "Sort alphabetically",
|
||||||
"sortByYear": "Sort by year",
|
"sortByYear": "Sort by year",
|
||||||
@@ -1074,6 +1084,7 @@
|
|||||||
"showFavoriteTracks": "Show a row of your favorite tracks",
|
"showFavoriteTracks": "Show a row of your favorite tracks",
|
||||||
"showFavoritePlaylists": "Show a row of your favorite playlists",
|
"showFavoritePlaylists": "Show a row of your favorite playlists",
|
||||||
"showFavoriteRadioStations": "Show a row of your favorite radio stations",
|
"showFavoriteRadioStations": "Show a row of your favorite radio stations",
|
||||||
|
"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",
|
||||||
"addedToFavorites": "Added to favorites",
|
"addedToFavorites": "Added to favorites",
|
||||||
@@ -1390,5 +1401,35 @@
|
|||||||
"type": "String"
|
"type": "String"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"noTracks": "No tracks",
|
||||||
|
"@noTracks": {
|
||||||
|
"description": "Empty state title when no tracks exist in library"
|
||||||
|
},
|
||||||
|
|
||||||
|
"addTracksHint": "Add some music to your library to see tracks here",
|
||||||
|
"@addTracksHint": {
|
||||||
|
"description": "Hint for adding tracks to library"
|
||||||
|
},
|
||||||
|
|
||||||
|
"noFavoriteRadioStations": "No favorite radio stations",
|
||||||
|
"@noFavoriteRadioStations": {
|
||||||
|
"description": "Empty state when no favorite radio stations"
|
||||||
|
},
|
||||||
|
|
||||||
|
"longPressRadioHint": "Long-press a station and tap the heart to add it to favorites",
|
||||||
|
"@longPressRadioHint": {
|
||||||
|
"description": "Hint for favoriting radio stations"
|
||||||
|
},
|
||||||
|
|
||||||
|
"noFavoritePodcasts": "No favorite podcasts",
|
||||||
|
"@noFavoritePodcasts": {
|
||||||
|
"description": "Empty state when no favorite podcasts"
|
||||||
|
},
|
||||||
|
|
||||||
|
"longPressPodcastHint": "Long-press a podcast and tap the heart to add it to favorites",
|
||||||
|
"@longPressPodcastHint": {
|
||||||
|
"description": "Hint for favoriting podcasts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,6 +360,7 @@
|
|||||||
"english": "Inglés",
|
"english": "Inglés",
|
||||||
"german": "Alemán",
|
"german": "Alemán",
|
||||||
"spanish": "Español",
|
"spanish": "Español",
|
||||||
|
"french": "Francés",
|
||||||
"noTracksInPlaylist": "No hay canciones en la lista",
|
"noTracksInPlaylist": "No hay canciones en la lista",
|
||||||
"sortAlphabetically": "Ordenar alfabéticamente",
|
"sortAlphabetically": "Ordenar alfabéticamente",
|
||||||
"sortByYear": "Ordenar por año",
|
"sortByYear": "Ordenar por año",
|
||||||
|
|||||||
@@ -0,0 +1,983 @@
|
|||||||
|
import 'package:intl/intl.dart' as intl;
|
||||||
|
|
||||||
|
import 'app_localizations.dart';
|
||||||
|
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
|
||||||
|
/// The translations for German (`de`).
|
||||||
|
class SDe extends S {
|
||||||
|
SDe([String locale = 'de']) : super(locale);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appTitle => 'Ensemble';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connecting => 'Verbinde...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connected => 'Verbunden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disconnected => 'Getrennt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverAddress => 'Serveradresse';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverAddressHint => 'z.B. music.example.com oder 192.168.1.100';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get yourName => 'Dein Name';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get yourFirstName => 'Dein Vorname';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get portOptional => 'Port (Optional)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get portHint => 'z.B. 8095 oder leer lassen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get portDescription => 'Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get username => 'Benutzername';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get password => 'Passwort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get detectAndConnect => 'Erkennen & Verbinden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connect => 'Verbinden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disconnect => 'Trennen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authServerUrlOptional => 'Auth-Server URL (Optional)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authServerUrlHint => 'z.B. auth.example.com (wenn abweichend vom Server)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authServerUrlDescription => 'Leer lassen, wenn Authentifizierung auf demselben Server';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String detectedAuthType(String authType) {
|
||||||
|
return 'Erkannt: $authType';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverRequiresAuth => 'Dieser Server erfordert Authentifizierung';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tailscaleVpnConnection => 'Tailscale VPN-Verbindung';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unencryptedConnection => 'Unverschlusselte Verbindung';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usingHttpOverTailscale => 'HTTP uber Tailscale (verschlusselter Tunnel)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get httpsFailedUsingHttp => 'HTTPS fehlgeschlagen, verwende HTTP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get httpNotEncrypted => 'HTTP-Verbindung - Daten sind nicht verschlusselt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pleaseEnterServerAddress => 'Bitte gib deine Music Assistant Serveradresse ein';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pleaseEnterName => 'Bitte gib deinen Namen ein';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pleaseEnterValidPort => 'Bitte gib eine gultige Portnummer ein (1-65535)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pleaseEnterCredentials => 'Bitte gib Benutzername und Passwort ein';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authFailed => 'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get maLoginFailed => 'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionFailed => 'Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get detectingAuth => 'Erkenne Authentifizierung...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cannotDetermineAuth => 'Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noAuthentication => 'Keine Authentifizierung';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get httpBasicAuth => 'HTTP Basic Auth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authelia => 'Authelia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicAssistantLogin => 'Music Assistant Anmeldung';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pressBackToMinimize => 'Erneut drucken zum Minimieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentlyPlayed => 'Zuletzt gespielt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoverArtists => 'Kunstler entdecken';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoverAlbums => 'Alben entdecken';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get continueListening => 'Weiterhoren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoverAudiobooks => 'Horbücher entdecken';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoverSeries => 'Serien entdecken';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteAlbums => 'Lieblingsalben';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteArtists => 'Lieblingskünstler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteTracks => 'Lieblingssongs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritePlaylists => 'Favorite Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteRadioStations => 'Favorite Radio Stations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritePodcasts => 'Favorite Podcasts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchMusic => 'Musik suchen...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchForContent => 'Nach Künstlern, Alben oder Songs suchen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentSearches => 'Recent Searches';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearSearchHistory => 'Clear Search History';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchHistoryCleared => 'Search history cleared';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryOnly => 'Library only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get retry => 'Wiederholen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get all => 'Alle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artists => 'Künstler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albums => 'Alben';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tracks => 'Songs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playlists => 'Wiedergabelisten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audiobooks => 'Hörbücher';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get music => 'Musik';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get radio => 'Radio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stations => 'Sender';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectLibrary => 'Bibliothek auswählen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noRadioStations => 'Keine Radiosender';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addRadioStationsHint => 'Füge Radiosender in Music Assistant hinzu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchFailed => 'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queue => 'Warteschlange';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playerQueue(String playerName) {
|
||||||
|
return '$playerName Warteschlange';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noPlayerSelected => 'Kein Player ausgewählt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get undo => 'Rückgängig';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String removedItem(String itemName) {
|
||||||
|
return '$itemName entfernt';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings => 'Einstellungen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugLogs => 'Debug-Protokolle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get themeMode => 'Design';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get light => 'Hell';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dark => 'Dunkel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get system => 'System';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pullToRefresh => 'Ziehe zum Aktualisieren der Bibliothek';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get viewDebugLogs => 'Debug-Protokolle anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get viewAllPlayers => 'Alle Player anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get copyLogs => 'Protokolle kopieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearLogs => 'Protokolle löschen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get copyList => 'Liste kopieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get close => 'Schließen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String allPlayersCount(int count) {
|
||||||
|
return 'Alle Player ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String errorLoadingPlayers(String error) {
|
||||||
|
return 'Fehler beim Laden der Player: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get logsCopied => 'Protokolle in Zwischenablage kopiert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get logsCleared => 'Protokolle gelöscht';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerListCopied => 'Playerliste in Zwischenablage kopiert!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noLogsYet => 'Noch keine Protokolle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get infoPlus => 'Info+';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get warnings => 'Warnungen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errors => 'Fehler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String showingEntries(int filtered, int total) {
|
||||||
|
return 'Zeige $filtered von $total Einträgen';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get thisDevice => 'Dieses Gerät';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ghostPlayer => 'Geist-Player (Duplikat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unavailableCorrupt => 'Nicht verfügbar/Beschädigt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playerId(String id) {
|
||||||
|
return 'ID: $id';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playerInfo(String available, String provider) {
|
||||||
|
return 'Verfügbar: $available | Anbieter: $provider';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shareBugReport => 'Fehlerbericht teilen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get moreOptions => 'Weitere Optionen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToUpdateFavorite(String error) {
|
||||||
|
return 'Favorit konnte nicht aktualisiert werden: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noPlayersAvailable => 'Keine Player verfügbar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumAddedToQueue => 'Album zur Warteschlange hinzugefügt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tracksAddedToQueue => 'Songs zur Warteschlange hinzugefügt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get play => 'Abspielen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noPlayersFound => 'Keine Player gefunden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String startingRadio(String name) {
|
||||||
|
return 'Starte $name Radio';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToStartRadio(String error) {
|
||||||
|
return 'Radio konnte nicht gestartet werden: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String startingRadioOnPlayer(String name, String playerName) {
|
||||||
|
return 'Starte $name Radio auf $playerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String addedRadioToQueue(String name) {
|
||||||
|
return '$name Radio zur Warteschlange hinzugefügt';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToAddToQueue(String error) {
|
||||||
|
return 'Konnte nicht zur Warteschlange hinzufügen: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playingRadioStation(String name) {
|
||||||
|
return 'Playing $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addedToQueue => 'Added to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inLibrary => 'In Bibliothek';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noAlbumsFound => 'Keine Alben gefunden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playing(String name) {
|
||||||
|
return 'Spiele $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playingTrack => 'Spiele Song';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlaying => 'Wird gespielt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String markedAsFinished(String name) {
|
||||||
|
return '$name als beendet markiert';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToMarkFinished(String error) {
|
||||||
|
return 'Konnte nicht als beendet markieren: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String markedAsUnplayed(String name) {
|
||||||
|
return '$name als ungespielt markiert';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToMarkUnplayed(String error) {
|
||||||
|
return 'Konnte nicht als ungespielt markieren: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToPlay(String error) {
|
||||||
|
return 'Abspielen fehlgeschlagen: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get markAsFinished => 'Als beendet markieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get markAsUnplayed => 'Als ungespielt markieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String byAuthor(String author) {
|
||||||
|
return 'Von $author';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String audiobookCount(int count) {
|
||||||
|
return '$count Hörbuch/Hörbücher';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get loading => 'Laden...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String bookCount(int count) {
|
||||||
|
return '$count Buch/Bücher';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get books => 'Bücher';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoriteAudiobooks => 'Keine Lieblingshörbücher';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tapHeartAudiobook => 'Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noAudiobooks => 'Keine Hörbücher';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addAudiobooksHint => 'Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoriteArtists => 'Keine Lieblingskünstler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tapHeartArtist => 'Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoriteAlbums => 'Keine Lieblingsalben';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tapHeartAlbum => 'Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoritePlaylists => 'Keine Lieblings-Wiedergabelisten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tapHeartPlaylist => 'Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoriteTracks => 'Keine Lieblingssongs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get longPressTrackHint => 'Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get loadSeries => 'Serien laden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notConnected => 'Nicht mit Music Assistant verbunden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notConnectedTitle => 'Nicht verbunden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectHint => 'Verbinde dich mit deinem Music Assistant Server, um Musik zu hören';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get configureServer => 'Server konfigurieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noArtistsFound => 'Keine Künstler gefunden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noTracksFound => 'Keine Songs gefunden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noPlaylistsFound => 'Keine Wiedergabelisten gefunden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueIsEmpty => 'Warteschlange ist leer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noResultsFound => 'Keine Ergebnisse gefunden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get refresh => 'Aktualisieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugConsole => 'Debug-Konsole';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get copy => 'Kopieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clear => 'Löschen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noLogsToCopy => 'Keine Protokolle zum Kopieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDebugLogsYet => 'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showDebug => 'Debug anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hideDebug => 'Debug ausblenden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chapters => 'Kapitel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChapters => 'Keine Kapitel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChapterInfo => 'Dieses Hörbuch hat keine Kapitelinformationen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String errorSeeking(String error) {
|
||||||
|
return 'Fehler beim Spulen: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String error(String error) {
|
||||||
|
return 'Fehler: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get home => 'Start';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get library => 'Bibliothek';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get homeScreen => 'Startbildschirm';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataApis => 'Metadaten-APIs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get theme => 'Design';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get materialYou => 'Material You';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get adaptiveTheme => 'Adaptives Design';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get language => 'Sprache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get english => 'Englisch';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get german => 'Deutsch';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get spanish => 'Spanisch';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get french => 'Französisch';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noTracksInPlaylist => 'Keine Songs in Wiedergabeliste';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphabetically => 'Alphabetisch sortieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortByYear => 'Nach Jahr sortieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortBySeriesOrder => 'Nach Serienreihenfolge sortieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get listView => 'Listenansicht';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get gridView => 'Rasteransicht';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noBooksInSeries => 'Keine Bücher in dieser Serie gefunden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artist => 'Künstler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showRecentlyPlayedAlbums => 'Kürzlich gespielte Alben anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showRandomArtists => 'Zufällige Künstler zum Entdecken anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showRandomAlbums => 'Zufällige Alben zum Entdecken anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showRandomAudiobooks => 'Zufällige Hörbücher zum Entdecken anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showRandomSeries => 'Zufällige Hörbuchserien zum Entdecken anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoriteAlbums => 'Lieblingsalben anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoriteArtists => 'Lieblingskünstler anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoriteTracks => 'Lieblingssongs anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extractColorsFromArtwork => 'Farben aus Album- und Künstlercover extrahieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chooseHomeScreenRows => 'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addedToFavorites => 'Zu Favoriten hinzugefügt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get removedFromFavorites => 'Aus Favoriten entfernt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addedToLibrary => 'Added to library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get removedFromLibrary => 'Removed from library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addToLibrary => 'Add to library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknown => 'Unbekannt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noUpcomingTracks => 'Keine weiteren Songs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showAll => 'Alle anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoritesOnly => 'Nur Favoriten anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get changeView => 'Ansicht ändern';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authors => 'Autoren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get series => 'Serien';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shows => 'Sendungen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get podcasts => 'Podcasts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get podcastSupportComingSoon => 'Podcast-Unterstützung kommt bald';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noPodcasts => 'No podcasts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addPodcastsHint => 'Subscribe to podcasts in Music Assistant';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get episodes => 'Episodes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get episode => 'Episode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playlist => 'Wiedergabeliste';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionError => 'Verbindungsfehler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get twoColumnGrid => '2-Spalten-Raster';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get threeColumnGrid => '3-Spalten-Raster';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fromProviders => 'Von Anbietern';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get resume => 'Fortsetzen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get about => 'Über';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inProgress => 'In Bearbeitung';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String narratedBy(String narrators) {
|
||||||
|
return 'Gesprochen von $narrators';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownNarrator => 'Unbekannter Sprecher';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownAuthor => 'Unbekannter Autor';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get loadingChapters => 'Lade Kapitel...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChapterInfoAvailable => 'Keine Kapitelinformationen verfügbar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String percentComplete(int percent) {
|
||||||
|
return '$percent% abgeschlossen';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get theAudioDbApiKey => 'TheAudioDB API-Schlüssel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get theAudioDbApiKeyHint => '\"2\" für kostenlosen Zugang oder Premium-Schlüssel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audiobookLibraries => 'Hörbuch-Bibliotheken';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chooseAudiobookLibraries => 'Wähle welche Audiobookshelf-Bibliotheken einbezogen werden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownLibrary => 'Unbekannte Bibliothek';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noSeriesFound => 'Keine Serien gefunden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ensembleBugReport => 'Ensemble Fehlerbericht';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String byOwner(String owner) {
|
||||||
|
return 'Von $owner';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noSeriesAvailable => 'Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pullToLoadSeries => 'Ziehe nach unten um Serien\nvon Music Assistant zu laden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get search => 'Suche';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataApisDescription => 'Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lastFmApiKey => 'Last.fm API-Schlüssel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lastFmApiKeyHint => 'Kostenlosen Schlüssel auf last.fm/api holen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get swipeToSwitchDevice => 'Wischen um Gerät zu wechseln';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String chapterNumber(int number) {
|
||||||
|
return 'Kapitel $number';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pcmAudio => 'PCM-Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playOn => 'Abspielen auf...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addAlbumToQueueOn => 'Album zur Warteschlange hinzufügen auf...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addToQueueOn => 'Zur Warteschlange hinzufügen auf...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String startRadioOn(String name) {
|
||||||
|
return '$name Radio starten auf...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get book => 'Buch';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audiobookSingular => 'Hörbuch';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumSingular => 'Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSingular => 'Track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get podcastSingular => 'Podcast';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'Songs',
|
||||||
|
one: 'Song',
|
||||||
|
);
|
||||||
|
return '$count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get materialYouDescription => 'Systemfarben verwenden (Android 12+)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accentColor => 'Akzentfarbe';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get players => 'Geräte';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get preferLocalPlayer => 'Lokales Gerät bevorzugen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get preferLocalPlayerDescription => 'Dieses Gerät immer zuerst auswählen, wenn verfügbar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get smartSortPlayers => 'Intelligente Sortierung';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get smartSortPlayersDescription => 'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerStateUnavailable => 'Nicht verfügbar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerStateOff => 'Aus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerStateIdle => 'Inaktiv';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerStateExternalSource => 'Externe Quelle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerSelected => 'Ausgewählt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionQueuedForSync => 'Wird synchronisiert wenn online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pendingOfflineActions(int count) {
|
||||||
|
return '$count ausstehende Synchronisierung';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hintsAndTips => 'Tipps & Hinweise';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showHints => 'Hinweise anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showHintsDescription => 'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pullToSelectPlayers => 'Ziehen um Geräte auszuwählen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get holdToSync => 'Gedrückt halten zum Sync';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get swipeToAdjustVolume => 'Wischen für Lautstärke';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectPlayerHint => 'Choose a player, or dismiss by swiping down';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get skip => 'Skip';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playingAlbum(String albumName) {
|
||||||
|
return 'Playing $albumName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playingPlaylist(String playlistName) {
|
||||||
|
return 'Playing $playlistName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noTracks => 'No tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addTracksHint => 'Add some music to your library to see tracks here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoriteRadioStations => 'No favorite radio stations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoritePodcasts => 'No favorite podcasts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
|
||||||
|
}
|
||||||
@@ -0,0 +1,983 @@
|
|||||||
|
import 'package:intl/intl.dart' as intl;
|
||||||
|
|
||||||
|
import 'app_localizations.dart';
|
||||||
|
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
|
||||||
|
/// The translations for English (`en`).
|
||||||
|
class SEn extends S {
|
||||||
|
SEn([String locale = 'en']) : super(locale);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appTitle => 'Ensemble';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connecting => 'Connecting...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connected => 'Connected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disconnected => 'Disconnected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverAddress => 'Server Address';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverAddressHint => 'e.g., music.example.com or 192.168.1.100';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get yourName => 'Your Name';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get yourFirstName => 'Your first name';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get portOptional => 'Port (Optional)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get portHint => 'e.g., 8095 or leave blank';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get portDescription => 'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get username => 'Username';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get password => 'Password';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get detectAndConnect => 'Detect & Connect';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connect => 'Connect';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disconnect => 'Disconnect';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authServerUrlOptional => 'Auth Server URL (Optional)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authServerUrlHint => 'e.g., auth.example.com (if different from server)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authServerUrlDescription => 'Leave empty if authentication is on the same server';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String detectedAuthType(String authType) {
|
||||||
|
return 'Detected: $authType';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverRequiresAuth => 'This server requires authentication';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tailscaleVpnConnection => 'Tailscale VPN Connection';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unencryptedConnection => 'Unencrypted Connection';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usingHttpOverTailscale => 'Using HTTP over Tailscale (encrypted tunnel)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get httpsFailedUsingHttp => 'HTTPS failed, using HTTP fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get httpNotEncrypted => 'HTTP connection - data is not encrypted';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pleaseEnterServerAddress => 'Please enter your Music Assistant server address';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pleaseEnterName => 'Please enter your name';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pleaseEnterValidPort => 'Please enter a valid port number (1-65535)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pleaseEnterCredentials => 'Please enter username and password';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authFailed => 'Authentication failed. Please check your credentials.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get maLoginFailed => 'Music Assistant login failed. Please check your credentials.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionFailed => 'Could not connect to server. Please check the address and try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get detectingAuth => 'Detecting authentication...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cannotDetermineAuth => 'Cannot determine authentication requirements. Please check server URL.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noAuthentication => 'No Authentication';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get httpBasicAuth => 'HTTP Basic Auth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authelia => 'Authelia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicAssistantLogin => 'Music Assistant Login';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pressBackToMinimize => 'Press back again to minimize';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentlyPlayed => 'Recently Played';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoverArtists => 'Discover Artists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoverAlbums => 'Discover Albums';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get continueListening => 'Continue Listening';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoverAudiobooks => 'Discover Audiobooks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoverSeries => 'Discover Series';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteAlbums => 'Favorite Albums';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteArtists => 'Favorite Artists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteTracks => 'Favorite Tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritePlaylists => 'Favorite Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteRadioStations => 'Favorite Radio Stations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritePodcasts => 'Favorite Podcasts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchMusic => 'Search music...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchForContent => 'Search for artists, albums, or tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentSearches => 'Recent Searches';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearSearchHistory => 'Clear Search History';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchHistoryCleared => 'Search history cleared';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryOnly => 'Library only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get retry => 'Retry';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get all => 'All';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artists => 'Artists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albums => 'Albums';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tracks => 'Tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playlists => 'Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audiobooks => 'Audiobooks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get music => 'Music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get radio => 'Radio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stations => 'Stations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectLibrary => 'Select Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noRadioStations => 'No radio stations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addRadioStationsHint => 'Add radio stations in Music Assistant';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchFailed => 'Search failed. Please check your connection.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queue => 'Queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playerQueue(String playerName) {
|
||||||
|
return '$playerName Queue';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noPlayerSelected => 'No player selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get undo => 'Undo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String removedItem(String itemName) {
|
||||||
|
return 'Removed $itemName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings => 'Settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugLogs => 'Debug Logs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get themeMode => 'Theme Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get light => 'Light';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dark => 'Dark';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get system => 'System';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pullToRefresh => 'Pull to refresh the library to apply changes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get viewDebugLogs => 'View Debug Logs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get viewAllPlayers => 'View All Players';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get copyLogs => 'Copy Logs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearLogs => 'Clear Logs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get copyList => 'Copy List';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get close => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String allPlayersCount(int count) {
|
||||||
|
return 'All Players ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String errorLoadingPlayers(String error) {
|
||||||
|
return 'Error loading players: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get logsCopied => 'Logs copied to clipboard';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get logsCleared => 'Logs cleared';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerListCopied => 'Player list copied to clipboard!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noLogsYet => 'No logs yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get infoPlus => 'Info+';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get warnings => 'Warnings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errors => 'Errors';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String showingEntries(int filtered, int total) {
|
||||||
|
return 'Showing $filtered of $total entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get thisDevice => 'This device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ghostPlayer => 'Ghost player (duplicate)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unavailableCorrupt => 'Unavailable/Corrupt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playerId(String id) {
|
||||||
|
return 'ID: $id';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playerInfo(String available, String provider) {
|
||||||
|
return 'Available: $available | Provider: $provider';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shareBugReport => 'Share bug report';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get moreOptions => 'More options';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToUpdateFavorite(String error) {
|
||||||
|
return 'Failed to update favorite: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noPlayersAvailable => 'No players available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumAddedToQueue => 'Album added to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tracksAddedToQueue => 'Tracks added to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get play => 'Play';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noPlayersFound => 'No players found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String startingRadio(String name) {
|
||||||
|
return 'Starting $name radio';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToStartRadio(String error) {
|
||||||
|
return 'Failed to start radio: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String startingRadioOnPlayer(String name, String playerName) {
|
||||||
|
return 'Starting $name radio on $playerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String addedRadioToQueue(String name) {
|
||||||
|
return 'Added $name radio to queue';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToAddToQueue(String error) {
|
||||||
|
return 'Failed to add to queue: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playingRadioStation(String name) {
|
||||||
|
return 'Playing $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addedToQueue => 'Added to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inLibrary => 'In Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noAlbumsFound => 'No albums found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playing(String name) {
|
||||||
|
return 'Playing $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playingTrack => 'Playing track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlaying => 'Now playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String markedAsFinished(String name) {
|
||||||
|
return '$name marked as finished';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToMarkFinished(String error) {
|
||||||
|
return 'Failed to mark as finished: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String markedAsUnplayed(String name) {
|
||||||
|
return '$name marked as unplayed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToMarkUnplayed(String error) {
|
||||||
|
return 'Failed to mark as unplayed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToPlay(String error) {
|
||||||
|
return 'Failed to play: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get markAsFinished => 'Mark as Finished';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get markAsUnplayed => 'Mark as Unplayed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String byAuthor(String author) {
|
||||||
|
return 'By $author';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String audiobookCount(int count) {
|
||||||
|
return '$count audiobook(s)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get loading => 'Loading...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String bookCount(int count) {
|
||||||
|
return '$count book(s)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get books => 'Books';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoriteAudiobooks => 'No favorite audiobooks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tapHeartAudiobook => 'Tap the heart on an audiobook to add it to favorites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noAudiobooks => 'No audiobooks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addAudiobooksHint => 'Add audiobooks to your library to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoriteArtists => 'No favorite artists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tapHeartArtist => 'Tap the heart on an artist to add them to favorites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoriteAlbums => 'No favorite albums';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tapHeartAlbum => 'Tap the heart on an album to add it to favorites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoritePlaylists => 'No favorite playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tapHeartPlaylist => 'Tap the heart on a playlist to add it to favorites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoriteTracks => 'No favorite tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get longPressTrackHint => 'Long-press a track and tap the heart to add it to favorites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get loadSeries => 'Load Series';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notConnected => 'Not connected to Music Assistant';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notConnectedTitle => 'Not Connected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectHint => 'Connect to your Music Assistant server to start listening';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get configureServer => 'Configure Server';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noArtistsFound => 'No artists found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noTracksFound => 'No tracks found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noPlaylistsFound => 'No playlists found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueIsEmpty => 'Queue is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noResultsFound => 'No results found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get refresh => 'Refresh';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugConsole => 'Debug Console';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get copy => 'Copy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clear => 'Clear';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noLogsToCopy => 'No logs to copy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDebugLogsYet => 'No debug logs yet. Try detecting auth.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showDebug => 'Show Debug';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hideDebug => 'Hide Debug';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chapters => 'Chapters';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChapters => 'No chapters';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChapterInfo => 'This audiobook has no chapter information';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String errorSeeking(String error) {
|
||||||
|
return 'Error seeking: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String error(String error) {
|
||||||
|
return 'Error: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get home => 'Home';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get library => 'Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get homeScreen => 'Home Screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataApis => 'Metadata APIs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get theme => 'Theme';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get materialYou => 'Material You';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get adaptiveTheme => 'Adaptive Theme';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get language => 'Language';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get english => 'English';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get german => 'German';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get spanish => 'Spanish';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get french => 'French';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noTracksInPlaylist => 'No tracks in playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphabetically => 'Sort alphabetically';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortByYear => 'Sort by year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortBySeriesOrder => 'Sort by series order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get listView => 'List view';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get gridView => 'Grid view';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noBooksInSeries => 'No books found in this series';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artist => 'Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showRecentlyPlayedAlbums => 'Show recently played albums';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showRandomArtists => 'Show random artists to discover';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showRandomAlbums => 'Show random albums to discover';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showAudiobooksInProgress => 'Show audiobooks in progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showRandomAudiobooks => 'Show random audiobooks to discover';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showRandomSeries => 'Show random audiobook series to discover';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoriteAlbums => 'Show a row of your favorite albums';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoriteArtists => 'Show a row of your favorite artists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoriteTracks => 'Show a row of your favorite tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extractColorsFromArtwork => 'Extract colors from album and artist artwork';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chooseHomeScreenRows => 'Choose which rows to display on the home screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addedToFavorites => 'Added to favorites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get removedFromFavorites => 'Removed from favorites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addedToLibrary => 'Added to library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get removedFromLibrary => 'Removed from library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addToLibrary => 'Add to library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknown => 'Unknown';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noUpcomingTracks => 'No upcoming tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showAll => 'Show all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoritesOnly => 'Show favorites only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get changeView => 'Change view';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authors => 'Authors';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get series => 'Series';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shows => 'Shows';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get podcasts => 'Podcasts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get podcastSupportComingSoon => 'Podcast support coming soon';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noPodcasts => 'No podcasts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addPodcastsHint => 'Subscribe to podcasts in Music Assistant';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get episodes => 'Episodes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get episode => 'Episode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playlist => 'Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionError => 'Connection Error';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get twoColumnGrid => '2-column grid';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get threeColumnGrid => '3-column grid';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fromProviders => 'From Providers';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get resume => 'Resume';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get about => 'About';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inProgress => 'In progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String narratedBy(String narrators) {
|
||||||
|
return 'Narrated by $narrators';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownNarrator => 'Unknown Narrator';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownAuthor => 'Unknown Author';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get loadingChapters => 'Loading chapters...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChapterInfoAvailable => 'No chapter information available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String percentComplete(int percent) {
|
||||||
|
return '$percent% complete';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get theAudioDbApiKey => 'TheAudioDB API Key';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get theAudioDbApiKeyHint => 'Use \"2\" for free tier or premium key';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audiobookLibraries => 'Audiobook Libraries';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chooseAudiobookLibraries => 'Choose which Audiobookshelf libraries to include';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownLibrary => 'Unknown Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noSeriesFound => 'No Series Found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ensembleBugReport => 'Ensemble Bug Report';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String byOwner(String owner) {
|
||||||
|
return 'By $owner';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noSeriesAvailable => 'No series available from your audiobook library.\nPull to refresh.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pullToLoadSeries => 'Pull down to load series\nfrom Music Assistant';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get search => 'Search';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataApisDescription => 'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lastFmApiKey => 'Last.fm API Key';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lastFmApiKeyHint => 'Get free key at last.fm/api';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get swipeToSwitchDevice => 'Swipe to change player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String chapterNumber(int number) {
|
||||||
|
return 'Chapter $number';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pcmAudio => 'PCM Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playOn => 'Play on...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addAlbumToQueueOn => 'Add album to queue on...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addToQueueOn => 'Add to queue on...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String startRadioOn(String name) {
|
||||||
|
return 'Start $name radio on...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get book => 'book';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audiobookSingular => 'audiobook';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumSingular => 'Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSingular => 'Track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get podcastSingular => 'Podcast';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get materialYouDescription => 'Use system colors (Android 12+)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accentColor => 'Accent Color';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get players => 'Players';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get preferLocalPlayer => 'Prefer Local Player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get preferLocalPlayerDescription => 'Always select this device first when available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get smartSortPlayers => 'Smart Sort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get smartSortPlayersDescription => 'Sort by status (playing, on, off) instead of alphabetically';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerStateUnavailable => 'Unavailable';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerStateOff => 'Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerStateIdle => 'Idle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerStateExternalSource => 'External Source';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerSelected => 'Selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionQueuedForSync => 'Will sync when online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pendingOfflineActions(int count) {
|
||||||
|
return '$count pending sync';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hintsAndTips => 'Hints & Tips';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showHints => 'Show Hints';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showHintsDescription => 'Display helpful tips for discovering features';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pullToSelectPlayers => 'Pull to select players';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get holdToSync => 'Long-press to sync';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get swipeToAdjustVolume => 'Swipe to adjust volume';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectPlayerHint => 'Choose a player, or dismiss by swiping down';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get skip => 'Skip';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playingAlbum(String albumName) {
|
||||||
|
return 'Playing $albumName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playingPlaylist(String playlistName) {
|
||||||
|
return 'Playing $playlistName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noTracks => 'No tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addTracksHint => 'Add some music to your library to see tracks here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoriteRadioStations => 'No favorite radio stations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoritePodcasts => 'No favorite podcasts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
|
||||||
|
}
|
||||||
@@ -0,0 +1,983 @@
|
|||||||
|
import 'package:intl/intl.dart' as intl;
|
||||||
|
|
||||||
|
import 'app_localizations.dart';
|
||||||
|
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
|
||||||
|
/// The translations for Spanish Castilian (`es`).
|
||||||
|
class SEs extends S {
|
||||||
|
SEs([String locale = 'es']) : super(locale);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appTitle => 'Ensemble';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connecting => 'Conectando...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connected => 'Conectado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disconnected => 'Desconectado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverAddress => 'Dirección del servidor';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverAddressHint => 'ej., music.example.com o 192.168.1.100';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get yourName => 'Tu nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get yourFirstName => 'Tu nombre de pila';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get portOptional => 'Puerto (Opcional)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get portHint => 'ej., 8095 o dejar en blanco';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get portDescription => 'Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get username => 'Usuario';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get password => 'Contraseña';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get detectAndConnect => 'Detectar y conectar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connect => 'Conectar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disconnect => 'Desconectar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authServerUrlOptional => 'URL del servidor de autenticación (Opcional)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authServerUrlHint => 'ej., auth.example.com (si es diferente del servidor)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authServerUrlDescription => 'Dejar vacío si la autenticación está en el mismo servidor';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String detectedAuthType(String authType) {
|
||||||
|
return 'Detectado: $authType';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverRequiresAuth => 'Este servidor requiere autenticación';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tailscaleVpnConnection => 'Conexión VPN Tailscale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unencryptedConnection => 'Conexión sin cifrar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usingHttpOverTailscale => 'Usando HTTP sobre Tailscale (túnel cifrado)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get httpsFailedUsingHttp => 'HTTPS falló, usando HTTP como alternativa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get httpNotEncrypted => 'Conexión HTTP - los datos no están cifrados';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pleaseEnterServerAddress => 'Por favor, introduce la dirección de tu servidor Music Assistant';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pleaseEnterName => 'Por favor, introduce tu nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pleaseEnterValidPort => 'Por favor, introduce un número de puerto válido (1-65535)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pleaseEnterCredentials => 'Por favor, introduce usuario y contraseña';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authFailed => 'Autenticación fallida. Por favor, verifica tus credenciales.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get maLoginFailed => 'Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionFailed => 'No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get detectingAuth => 'Detectando autenticación...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cannotDetermineAuth => 'No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noAuthentication => 'Sin autenticación';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get httpBasicAuth => 'Autenticación HTTP Basic';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authelia => 'Authelia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicAssistantLogin => 'Inicio de sesión de Music Assistant';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pressBackToMinimize => 'Presiona atrás de nuevo para minimizar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentlyPlayed => 'Reproducido recientemente';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoverArtists => 'Descubrir artistas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoverAlbums => 'Descubrir álbumes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get continueListening => 'Continuar escuchando';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoverAudiobooks => 'Descubrir audiolibros';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoverSeries => 'Descubrir series';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteAlbums => 'Álbumes favoritos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteArtists => 'Artistas favoritos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteTracks => 'Canciones favoritas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritePlaylists => 'Favorite Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteRadioStations => 'Favorite Radio Stations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritePodcasts => 'Favorite Podcasts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchMusic => 'Buscar música...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchForContent => 'Buscar artistas, álbumes o canciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentSearches => 'Recent Searches';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearSearchHistory => 'Clear Search History';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchHistoryCleared => 'Search history cleared';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryOnly => 'Library only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get retry => 'Reintentar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get all => 'Todo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artists => 'Artistas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albums => 'Álbumes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tracks => 'Canciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playlists => 'Listas de reproducción';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audiobooks => 'Audiolibros';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get music => 'Música';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get radio => 'Radio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stations => 'Emisoras';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectLibrary => 'Seleccionar Biblioteca';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noRadioStations => 'No hay emisoras de radio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addRadioStationsHint => 'Añade emisoras de radio en Music Assistant';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchFailed => 'Búsqueda fallida. Por favor, verifica tu conexión.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queue => 'Cola';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playerQueue(String playerName) {
|
||||||
|
return 'Cola de $playerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noPlayerSelected => 'Ningún reproductor seleccionado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get undo => 'Deshacer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String removedItem(String itemName) {
|
||||||
|
return 'Eliminado $itemName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings => 'Ajustes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugLogs => 'Registros de depuración';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get themeMode => 'Modo de tema';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get light => 'Claro';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dark => 'Oscuro';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get system => 'Sistema';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pullToRefresh => 'Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get viewDebugLogs => 'Ver registros de depuración';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get viewAllPlayers => 'Ver todos los reproductores';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get copyLogs => 'Copiar registros';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearLogs => 'Borrar registros';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get copyList => 'Copiar lista';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get close => 'Cerrar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String allPlayersCount(int count) {
|
||||||
|
return 'Todos los reproductores ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String errorLoadingPlayers(String error) {
|
||||||
|
return 'Error al cargar reproductores: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get logsCopied => 'Registros copiados al portapapeles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get logsCleared => 'Registros borrados';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerListCopied => 'Lista de reproductores copiada al portapapeles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noLogsYet => 'Aún no hay registros';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get infoPlus => 'Info+';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get warnings => 'Advertencias';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errors => 'Errores';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String showingEntries(int filtered, int total) {
|
||||||
|
return 'Mostrando $filtered de $total entradas';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get thisDevice => 'Este dispositivo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ghostPlayer => 'Reproductor fantasma (duplicado)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unavailableCorrupt => 'No disponible/Corrupto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playerId(String id) {
|
||||||
|
return 'ID: $id';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playerInfo(String available, String provider) {
|
||||||
|
return 'Disponible: $available | Proveedor: $provider';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shareBugReport => 'Compartir informe de errores';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get moreOptions => 'Más opciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToUpdateFavorite(String error) {
|
||||||
|
return 'Error al actualizar favorito: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noPlayersAvailable => 'No hay reproductores disponibles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumAddedToQueue => 'Álbum añadido a la cola';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tracksAddedToQueue => 'Canciones añadidas a la cola';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get play => 'Reproducir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noPlayersFound => 'No se encontraron reproductores';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String startingRadio(String name) {
|
||||||
|
return 'Iniciando radio de $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToStartRadio(String error) {
|
||||||
|
return 'Error al iniciar radio: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String startingRadioOnPlayer(String name, String playerName) {
|
||||||
|
return 'Iniciando radio de $name en $playerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String addedRadioToQueue(String name) {
|
||||||
|
return 'Radio de $name añadida a la cola';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToAddToQueue(String error) {
|
||||||
|
return 'Error al añadir a la cola: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playingRadioStation(String name) {
|
||||||
|
return 'Playing $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addedToQueue => 'Added to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inLibrary => 'En la biblioteca';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noAlbumsFound => 'No se encontraron álbumes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playing(String name) {
|
||||||
|
return 'Reproduciendo $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playingTrack => 'Reproduciendo canción';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlaying => 'Reproduciendo ahora';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String markedAsFinished(String name) {
|
||||||
|
return '$name marcado como terminado';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToMarkFinished(String error) {
|
||||||
|
return 'Error al marcar como terminado: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String markedAsUnplayed(String name) {
|
||||||
|
return '$name marcado como no reproducido';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToMarkUnplayed(String error) {
|
||||||
|
return 'Error al marcar como no reproducido: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToPlay(String error) {
|
||||||
|
return 'Error al reproducir: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get markAsFinished => 'Marcar como terminado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get markAsUnplayed => 'Marcar como no reproducido';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String byAuthor(String author) {
|
||||||
|
return 'Por $author';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String audiobookCount(int count) {
|
||||||
|
return '$count audiolibro(s)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get loading => 'Cargando...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String bookCount(int count) {
|
||||||
|
return '$count libro(s)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get books => 'Libros';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoriteAudiobooks => 'No hay audiolibros favoritos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tapHeartAudiobook => 'Toca el corazón en un audiolibro para añadirlo a favoritos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noAudiobooks => 'No hay audiolibros';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addAudiobooksHint => 'Añade audiolibros a tu biblioteca para verlos aquí';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoriteArtists => 'No hay artistas favoritos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tapHeartArtist => 'Toca el corazón en un artista para añadirlo a favoritos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoriteAlbums => 'No hay álbumes favoritos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tapHeartAlbum => 'Toca el corazón en un álbum para añadirlo a favoritos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoritePlaylists => 'No hay listas de reproducción favoritas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tapHeartPlaylist => 'Toca el corazón en una lista para añadirla a favoritos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoriteTracks => 'No hay canciones favoritas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get longPressTrackHint => 'Mantén presionada una canción y toca el corazón para añadirla a favoritos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get loadSeries => 'Cargar series';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notConnected => 'No conectado a Music Assistant';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notConnectedTitle => 'No conectado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectHint => 'Conéctate a tu servidor Music Assistant para empezar a escuchar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get configureServer => 'Configurar servidor';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noArtistsFound => 'No se encontraron artistas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noTracksFound => 'No se encontraron canciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noPlaylistsFound => 'No se encontraron listas de reproducción';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueIsEmpty => 'La cola está vacía';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noResultsFound => 'No se encontraron resultados';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get refresh => 'Actualizar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugConsole => 'Consola de depuración';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get copy => 'Copiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clear => 'Borrar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noLogsToCopy => 'No hay registros para copiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDebugLogsYet => 'Aún no hay registros de depuración. Intenta detectar autenticación.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showDebug => 'Mostrar depuración';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hideDebug => 'Ocultar depuración';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chapters => 'Capítulos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChapters => 'Sin capítulos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChapterInfo => 'Este audiolibro no tiene información de capítulos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String errorSeeking(String error) {
|
||||||
|
return 'Error al buscar: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String error(String error) {
|
||||||
|
return 'Error: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get home => 'Inicio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get library => 'Biblioteca';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get homeScreen => 'Pantalla de inicio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataApis => 'APIs de metadatos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get theme => 'Tema';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get materialYou => 'Material You';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get adaptiveTheme => 'Tema adaptativo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get language => 'Idioma';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get english => 'Inglés';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get german => 'Alemán';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get spanish => 'Español';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get french => 'Francés';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noTracksInPlaylist => 'No hay canciones en la lista';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphabetically => 'Ordenar alfabéticamente';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortByYear => 'Ordenar por año';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortBySeriesOrder => 'Ordenar por orden de serie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get listView => 'Vista de lista';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get gridView => 'Vista de cuadrícula';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noBooksInSeries => 'No se encontraron libros en esta serie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artist => 'Artista';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showRecentlyPlayedAlbums => 'Mostrar álbumes reproducidos recientemente';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showRandomArtists => 'Mostrar artistas aleatorios para descubrir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showRandomAlbums => 'Mostrar álbumes aleatorios para descubrir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showRandomAudiobooks => 'Mostrar audiolibros aleatorios para descubrir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showRandomSeries => 'Mostrar series de audiolibros aleatorias para descubrir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoriteAlbums => 'Mostrar una fila de tus álbumes favoritos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoriteArtists => 'Mostrar una fila de tus artistas favoritos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoriteTracks => 'Mostrar una fila de tus canciones favoritas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extractColorsFromArtwork => 'Extraer colores de las portadas de álbumes y artistas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chooseHomeScreenRows => 'Elige qué filas mostrar en la pantalla de inicio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addedToFavorites => 'Añadido a favoritos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get removedFromFavorites => 'Eliminado de favoritos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addedToLibrary => 'Added to library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get removedFromLibrary => 'Removed from library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addToLibrary => 'Add to library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknown => 'Desconocido';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noUpcomingTracks => 'No hay canciones próximas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showAll => 'Mostrar todo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoritesOnly => 'Mostrar solo favoritos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get changeView => 'Cambiar vista';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authors => 'Autores';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get series => 'Series';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shows => 'Programas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get podcasts => 'Podcasts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get podcastSupportComingSoon => 'Soporte de podcasts próximamente';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noPodcasts => 'No podcasts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addPodcastsHint => 'Subscribe to podcasts in Music Assistant';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get episodes => 'Episodes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get episode => 'Episode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playlist => 'Lista de reproducción';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionError => 'Error de conexión';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get twoColumnGrid => 'Cuadrícula de 2 columnas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get threeColumnGrid => 'Cuadrícula de 3 columnas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fromProviders => 'De proveedores';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get resume => 'Continuar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get about => 'Acerca de';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inProgress => 'En progreso';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String narratedBy(String narrators) {
|
||||||
|
return 'Narrado por $narrators';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownNarrator => 'Narrador desconocido';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownAuthor => 'Autor desconocido';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get loadingChapters => 'Cargando capítulos...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChapterInfoAvailable => 'No hay información de capítulos disponible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String percentComplete(int percent) {
|
||||||
|
return '$percent% completado';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get theAudioDbApiKey => 'Clave API de TheAudioDB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get theAudioDbApiKeyHint => 'Usa \"2\" para nivel gratuito o clave premium';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audiobookLibraries => 'Bibliotecas de audiolibros';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chooseAudiobookLibraries => 'Elige qué bibliotecas de Audiobookshelf incluir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownLibrary => 'Biblioteca desconocida';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noSeriesFound => 'No se encontraron series';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ensembleBugReport => 'Informe de errores de Ensemble';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String byOwner(String owner) {
|
||||||
|
return 'Por $owner';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noSeriesAvailable => 'No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pullToLoadSeries => 'Desliza hacia abajo para cargar series\ndesde Music Assistant';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get search => 'Buscar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
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.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lastFmApiKey => 'Clave API de Last.fm';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lastFmApiKeyHint => 'Obtén una clave gratuita en last.fm/api';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get swipeToSwitchDevice => 'Desliza para cambiar dispositivo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String chapterNumber(int number) {
|
||||||
|
return 'Capítulo $number';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pcmAudio => 'Audio PCM';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playOn => 'Reproducir en...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addAlbumToQueueOn => 'Añadir álbum a la cola en...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addToQueueOn => 'Añadir a la cola en...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String startRadioOn(String name) {
|
||||||
|
return 'Iniciar radio de $name en...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get book => 'libro';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audiobookSingular => 'audiolibro';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumSingular => 'Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSingular => 'Track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get podcastSingular => 'Podcast';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'canciones',
|
||||||
|
one: 'canción',
|
||||||
|
);
|
||||||
|
return '$count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get materialYouDescription => 'Usar colores del sistema (Android 12+)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accentColor => 'Color de acento';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get players => 'Dispositivos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get preferLocalPlayer => 'Preferir dispositivo local';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get preferLocalPlayerDescription => 'Seleccionar siempre este dispositivo primero cuando esté disponible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get smartSortPlayers => 'Orden inteligente';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get smartSortPlayersDescription => 'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerStateUnavailable => 'No disponible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerStateOff => 'Apagado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerStateIdle => 'Inactivo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerStateExternalSource => 'Fuente Externa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerSelected => 'Seleccionado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionQueuedForSync => 'Se sincronizará cuando esté en línea';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pendingOfflineActions(int count) {
|
||||||
|
return '$count sincronización pendiente';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hintsAndTips => 'Consejos y Sugerencias';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showHints => 'Mostrar Sugerencias';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showHintsDescription => 'Mostrar consejos útiles para descubrir funciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pullToSelectPlayers => 'Desliza para seleccionar dispositivos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get holdToSync => 'Mantén presionado para sync';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get swipeToAdjustVolume => 'Desliza para ajustar volumen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectPlayerHint => 'Choose a player, or dismiss by swiping down';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get skip => 'Skip';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playingAlbum(String albumName) {
|
||||||
|
return 'Playing $albumName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playingPlaylist(String playlistName) {
|
||||||
|
return 'Playing $playlistName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noTracks => 'No tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addTracksHint => 'Add some music to your library to see tracks here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoriteRadioStations => 'No favorite radio stations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoritePodcasts => 'No favorite podcasts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
|
||||||
|
}
|
||||||
@@ -0,0 +1,983 @@
|
|||||||
|
import 'package:intl/intl.dart' as intl;
|
||||||
|
|
||||||
|
import 'app_localizations.dart';
|
||||||
|
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
|
||||||
|
/// The translations for French (`fr`).
|
||||||
|
class SFr extends S {
|
||||||
|
SFr([String locale = 'fr']) : super(locale);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appTitle => 'Ensemble';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connecting => 'Connexion...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connected => 'Connecté';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disconnected => 'Déconnecté';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverAddress => 'Adresse du serveur';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverAddressHint => 'ex., music.example.com ou 192.168.1.100';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get yourName => 'Votre nom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get yourFirstName => 'Votre prénom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get portOptional => 'Port (Facultatif)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get portHint => 'ex., 8095 ou laisser vide';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get portDescription => 'Laisser vide pour les proxys inverses ou les ports standards. Entrer 8095 pour une connexion directe.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get username => 'Nom d\'utilisateur';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get password => 'Mot de passe';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get detectAndConnect => 'Détecter et connecter';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connect => 'Connectér';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disconnect => 'Déconnectér';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authServerUrlOptional => 'URL du serveur d\'auth (Facultatif)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authServerUrlHint => 'ex., auth.example.com (si different du serveur)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authServerUrlDescription => 'Laisser vide si l\'authentification est sur le meme serveur';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String detectedAuthType(String authType) {
|
||||||
|
return 'Detected: $authType';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverRequiresAuth => 'Ce serveur requiert une authentification';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tailscaleVpnConnection => 'Connexion VPN Tailscale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unencryptedConnection => 'Connexion non chiffrée';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usingHttpOverTailscale => 'Utilisation de HTTP via Tailscale (tunnel chiffre)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get httpsFailedUsingHttp => 'HTTPS a échoué, utilisation de HTTP en repli';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get httpNotEncrypted => 'Connexion HTTP - les donnees ne sont pas chiffrées';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pleaseEnterServerAddress => 'Veuillez entrer l\'adresse de votre serveur Music Assistant';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pleaseEnterName => 'Veuillez entrer votre nom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pleaseEnterValidPort => 'Veuillez entrer un numero de port valide (1-65535)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pleaseEnterCredentials => 'Veuillez entrer le nom d\'utilisateur et le mot de passe';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authFailed => 'Echec de l\'authentification. Veuillez verifier vos identifiants.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get maLoginFailed => 'Echec de la connexion a Music Assistant. Veuillez verifier vos identifiants.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionFailed => 'Impossible de se connecter au serveur. Veuillez verifier l\'adresse et reessayer.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get detectingAuth => 'Detection de l\'authentification...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cannotDetermineAuth => 'Impossible de determiner les exigences d\'authentification. Veuillez verifier l\'URL du serveur.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noAuthentication => 'Pas d\'authentification';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get httpBasicAuth => 'HTTP Basic Auth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authelia => 'Authelia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicAssistantLogin => 'Connexion Music Assistant';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pressBackToMinimize => 'Appuyez a nouveau sur retour pour minimiser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentlyPlayed => 'Ecoutes recemment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoverArtists => 'Découvrir des artistes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoverAlbums => 'Découvrir des albums';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get continueListening => 'Continuer l\'écoute';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoverAudiobooks => 'Découvrir des livres audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoverSeries => 'Découvrir des séries';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteAlbums => 'Albums favoris';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteArtists => 'Artistes favoris';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteTracks => 'Pistes favorites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritePlaylists => 'Playlists favorites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoriteRadioStations => 'Stations de radio favorites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritePodcasts => 'Podcasts favoris';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchMusic => 'Rechercher de la musique...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchForContent => 'Rechercher des artistes, albums ou pistes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentSearches => 'Recherches recentes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearSearchHistory => 'Effacer l\'historique de recherche';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchHistoryCleared => 'Historique de recherche efface';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryOnly => 'Bibliothèque uniquement';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get retry => 'Reessayer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get all => 'Tout';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artists => 'Artistes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albums => 'Albums';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tracks => 'Pistes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playlists => 'Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audiobooks => 'Livres audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get music => 'Musique';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get radio => 'Radio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stations => 'Stations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectLibrary => 'Sélectionner la bibliothèque';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noRadioStations => 'Aucune station de radio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addRadioStationsHint => 'Ajouter des stations de radio dans Music Assistant';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchFailed => 'La recherche a échoué. Veuillez verifier votre connexion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queue => 'File d\'attente';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playerQueue(String playerName) {
|
||||||
|
return 'File d\'attente de $playerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noPlayerSelected => 'Aucun lecteur selectionne';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get undo => 'Annuler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String removedItem(String itemName) {
|
||||||
|
return '$itemName supprimé';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings => 'Paramètres';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugLogs => 'Journaux de debogage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get themeMode => 'Mode de theme';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get light => 'Clair';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dark => 'Sombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get system => 'Système';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pullToRefresh => 'Tirez pour rafraichir la bibliothèque et appliquer les modifications';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get viewDebugLogs => 'Voir les journaux de debogage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get viewAllPlayers => 'Voir tous les lecteurs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get copyLogs => 'Copier les journaux';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearLogs => 'Effacer les journaux';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get copyList => 'Copier la liste';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get close => 'Fermer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String allPlayersCount(int count) {
|
||||||
|
return 'Tous les lecteurs ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String errorLoadingPlayers(String error) {
|
||||||
|
return 'Erreur lors du chargement des lecteurs : $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get logsCopied => 'Journaux copies dans le presse-papiers';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get logsCleared => 'Journaux effaces';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerListCopied => 'Liste des lecteurs copiee dans le presse-papiers !';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noLogsYet => 'Pas encore de journaux';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get infoPlus => 'Info+';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get warnings => 'Avertissements';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errors => 'Erreurs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String showingEntries(int filtered, int total) {
|
||||||
|
return 'Affichage de $filtered sur $total entrées';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get thisDevice => 'Cet appareil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ghostPlayer => 'Lecteur fantome (doublon)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unavailableCorrupt => 'Indisponible/Corrompu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playerId(String id) {
|
||||||
|
return 'ID : $id';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playerInfo(String available, String provider) {
|
||||||
|
return 'Disponible : $available | Fournisseur : $provider';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shareBugReport => 'Partager le rapport de bogue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get moreOptions => 'Plus d\'options';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToUpdateFavorite(String error) {
|
||||||
|
return 'Echec de la mise a jour du favori : $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noPlayersAvailable => 'Aucun lecteur disponible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumAddedToQueue => 'Album ajouté a la file d\'attente';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tracksAddedToQueue => 'Pistes ajoutées a la file d\'attente';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get play => 'Lire';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noPlayersFound => 'Aucun lecteur trouve';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String startingRadio(String name) {
|
||||||
|
return 'Demarrage de la radio $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToStartRadio(String error) {
|
||||||
|
return 'Echec du demarrage de la radio : $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String startingRadioOnPlayer(String name, String playerName) {
|
||||||
|
return 'Demarrage de la radio $name sur $playerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String addedRadioToQueue(String name) {
|
||||||
|
return 'Radio $name ajoutée a la file d\'attente';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToAddToQueue(String error) {
|
||||||
|
return 'Echec de l\'ajout a la file d\'attente : $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playingRadioStation(String name) {
|
||||||
|
return 'Lecture de $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addedToQueue => 'Ajoute a la file d\'attente';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inLibrary => 'Dans la bibliothèque';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noAlbumsFound => 'Aucun album trouve';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playing(String name) {
|
||||||
|
return 'Lecture de $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playingTrack => 'Lecture de la piste';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlaying => 'En cours de lecture';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String markedAsFinished(String name) {
|
||||||
|
return '$name marque comme termine';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToMarkFinished(String error) {
|
||||||
|
return 'Echec du marquage comme termine : $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String markedAsUnplayed(String name) {
|
||||||
|
return '$name marque comme non lu';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToMarkUnplayed(String error) {
|
||||||
|
return 'Echec du marquage comme non lu : $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String failedToPlay(String error) {
|
||||||
|
return 'Echec de la lecture : $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get markAsFinished => 'Marquer comme termine';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get markAsUnplayed => 'Marquer comme non lu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String byAuthor(String author) {
|
||||||
|
return 'Par $author';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String audiobookCount(int count) {
|
||||||
|
return '$count livre(s) audio';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get loading => 'Chargement...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String bookCount(int count) {
|
||||||
|
return '$count livre(s)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get books => 'Livres';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoriteAudiobooks => 'Aucun livre audio favori';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tapHeartAudiobook => 'Appuyez sur le coeur d\'un livre audio pour l\'ajoutér aux favoris';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noAudiobooks => 'Aucun livre audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addAudiobooksHint => 'Ajoutez des livres audio a votre bibliothèque pour les voir ici';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoriteArtists => 'Aucun artiste favori';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tapHeartArtist => 'Appuyez sur le coeur d\'un artiste pour l\'ajoutér aux favoris';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoriteAlbums => 'Aucun album favori';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tapHeartAlbum => 'Appuyez sur le coeur d\'un album pour l\'ajoutér aux favoris';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoritePlaylists => 'Aucune playlist favorite';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tapHeartPlaylist => 'Appuyez sur le coeur d\'une playlist pour l\'ajoutér aux favoris';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoriteTracks => 'Aucune piste favorite';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get longPressTrackHint => 'Appuyez longuement sur une piste et touchez le coeur pour l\'ajoutér aux favoris';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get loadSeries => 'Charger les séries';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notConnected => 'Not connected to Music Assistant';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notConnectedTitle => 'Not Connected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectHint => 'Connectéz-vous a votre serveur Music Assistant pour commencer a écouter';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get configureServer => 'Configurer le serveur';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noArtistsFound => 'Aucun artiste trouve';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noTracksFound => 'Aucune piste trouvee';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noPlaylistsFound => 'Aucune playlist trouvee';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueIsEmpty => 'La file d\'attente est vide';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noResultsFound => 'Aucun resultat trouve';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get refresh => 'Rafraichir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugConsole => 'Console de debogage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get copy => 'Copier';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clear => 'Effacer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noLogsToCopy => 'Aucun journal a copier';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDebugLogsYet => 'Pas encore de journaux de debogage. Essayez de détectér l\'authentification.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showDebug => 'Afficher le debogage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hideDebug => 'Masquer le debogage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chapters => 'Chapitres';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChapters => 'Aucun chapitre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChapterInfo => 'Ce livre audio n\'a pas d\'informations de chapitre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String errorSeeking(String error) {
|
||||||
|
return 'Erreur de positionnement : $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String error(String error) {
|
||||||
|
return 'Erreur : $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get home => 'Accueil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get library => 'Bibliothèque';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get homeScreen => 'Ecran d\'accueil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataApis => 'API de metadonnees';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get theme => 'Theme';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get materialYou => 'Material You';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get adaptiveTheme => 'Theme adaptatif';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get language => 'Langue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get english => 'Anglais';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get german => 'Allemand';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get spanish => 'Espagnol';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get french => 'Français';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noTracksInPlaylist => 'Aucune piste dans la playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphabetically => 'Trier alphabétiquement';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortByYear => 'Trier par année';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortBySeriesOrder => 'Trier par ordre de série';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get listView => 'Vue en liste';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get gridView => 'Vue en grille';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noBooksInSeries => 'Aucun livre trouve dans cette série';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artist => 'Artiste';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showRecentlyPlayedAlbums => 'Afficher les albums écoutes recemment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showRandomArtists => 'Afficher des artistes aléatoires a découvrir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showAudiobooksInProgress => 'Afficher les livres audio en cours';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showRandomAudiobooks => 'Afficher des livres audio aléatoires a découvrir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showRandomSeries => 'Afficher des séries de livres audio aléatoires a découvrir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoriteAlbums => 'Afficher une rangee de vos albums favoris';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoriteArtists => 'Afficher une rangee de vos artistes favoris';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoriteTracks => 'Afficher une rangee de vos pistes favorites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoritePlaylists => 'Afficher une rangee de vos playlists favorites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoriteRadioStations => 'Afficher une rangee de vos stations de radio favorites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoritePodcasts => 'Afficher une rangee de vos podcasts favoris';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extractColorsFromArtwork => 'Extraire les couleurs des pochettes d\'albums et d\'artistes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chooseHomeScreenRows => 'Choisir les rangees a afficher sur l\'ecran d\'accueil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addedToFavorites => 'Ajoute aux favoris';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get removedFromFavorites => 'Retire des favoris';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addedToLibrary => 'Ajoute a la bibliothèque';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get removedFromLibrary => 'Retire de la bibliothèque';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addToLibrary => 'Ajouter a la bibliothèque';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknown => 'Inconnu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noUpcomingTracks => 'Aucune piste a venir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showAll => 'Tout afficher';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showFavoritesOnly => 'Afficher les favoris uniquement';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get changeView => 'Changer la vue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authors => 'Auteurs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get series => 'Series';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shows => 'Emissions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get podcasts => 'Podcasts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get podcastSupportComingSoon => 'Prise en charge des podcasts bientot disponible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noPodcasts => 'Aucun podcast';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addPodcastsHint => 'Abonnez-vous a des podcasts dans Music Assistant';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get episodes => 'Episodes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get episode => 'Episode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playlist => 'Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionError => 'Erreur de connexion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get twoColumnGrid => 'Grille a 2 colonnes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get threeColumnGrid => 'Grille a 3 colonnes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fromProviders => 'Des fournisseurs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get resume => 'Reprendre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get about => 'A propos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inProgress => 'En cours';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String narratedBy(String narrators) {
|
||||||
|
return 'Lu par $narrators';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownNarrator => 'Narrateur inconnu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownAuthor => 'Auteur inconnu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get loadingChapters => 'Chargement des chapitres...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noChapterInfoAvailable => 'Aucune information de chapitre disponible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String percentComplete(int percent) {
|
||||||
|
return '$percent% termine';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get theAudioDbApiKey => 'Cle API TheAudioDB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get theAudioDbApiKeyHint => 'Utilisez \"2\" pour le niveau gratuit ou une cle premium';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audiobookLibraries => 'Bibliothèques de livres audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chooseAudiobookLibraries => 'Choisir quelles bibliothèques Audiobookshelf inclure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownLibrary => 'Bibliothèque inconnue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noSeriesFound => 'Aucune série trouvee';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ensembleBugReport => 'Rapport de bogue Ensemble';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String byOwner(String owner) {
|
||||||
|
return 'Par $owner';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noSeriesAvailable => 'Aucune série disponible dans votre bibliothèque de livres audio.\nTirez pour rafraichir.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pullToLoadSeries => 'Tirez vers le bas pour charger les séries\ndepuis Music Assistant';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get search => 'Rechercher';
|
||||||
|
|
||||||
|
@override
|
||||||
|
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.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lastFmApiKey => 'Cle API Last.fm';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lastFmApiKeyHint => 'Obtenez une cle gratuite sur last.fm/api';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get swipeToSwitchDevice => 'Balayez pour changer de lecteur';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String chapterNumber(int number) {
|
||||||
|
return 'Chapitre $number';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pcmAudio => 'Audio PCM';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playOn => 'Lire sur...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addAlbumToQueueOn => 'Ajouter l\'album a la file d\'attente sur...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addToQueueOn => 'Ajouter a la file d\'attente sur...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String startRadioOn(String name) {
|
||||||
|
return 'Demarrer la radio $name sur...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get book => 'livre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audiobookSingular => 'livre audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumSingular => 'Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSingular => 'Piste';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get podcastSingular => 'Podcast';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count pistes',
|
||||||
|
one: '$count piste',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get materialYouDescription => 'Utiliser les couleurs systeme (Android 12+)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accentColor => 'Couleur d\'accentuation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get players => 'Lecteurs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get preferLocalPlayer => 'Préférer le lecteur local';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get preferLocalPlayerDescription => 'Toujours sélectionner cet appareil en premier si disponible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get smartSortPlayers => 'Tri intelligent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get smartSortPlayersDescription => 'Trier par statut (en lecture, allume, eteint) au lieu d\'alphabétiquement';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerStateUnavailable => 'Indisponible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerStateOff => 'Eteint';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerStateIdle => 'Inactif';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerStateExternalSource => 'Source externe';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerSelected => 'Selectionne';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionQueuedForSync => 'Sera synchronise une fois en ligne';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pendingOfflineActions(int count) {
|
||||||
|
return '$count en attente de synchronisation';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hintsAndTips => 'Astuces et conseils';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showHints => 'Afficher les astuces';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showHintsDescription => 'Afficher des conseils utiles pour découvrir les fonctionnalites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pullToSelectPlayers => 'Tirez pour sélectionner les lecteurs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get holdToSync => 'Appui long pour synchroniser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get swipeToAdjustVolume => 'Balayez pour ajuster le volume';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectPlayerHint => 'Choisissez un lecteur ou fermez en balayant vers le bas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get welcomeToEnsemble => 'Bienvenue dans Ensemble';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get welcomeMessage => 'Par defaut, votre telephone est le lecteur selectionne.\nTirez le mini-lecteur vers le bas pour sélectionner un autre lecteur.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get skip => 'Passer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dismissPlayerHint => 'Balayez vers le bas, touchez a l\'exterieur ou appuyez sur retour pour revenir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playingAlbum(String albumName) {
|
||||||
|
return 'Lecture de $albumName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playingPlaylist(String playlistName) {
|
||||||
|
return 'Lecture de $playlistName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noTracks => 'No tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addTracksHint => 'Add some music to your library to see tracks here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoriteRadioStations => 'No favorite radio stations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noFavoritePodcasts => 'No favorite podcasts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -33,6 +34,10 @@ final _logger = DebugLogger();
|
|||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Initialize SharedPreferences cache early for performance
|
||||||
|
await SettingsService.initialize();
|
||||||
|
_logger.log('⚡ SharedPreferences cached');
|
||||||
|
|
||||||
// Initialize local database
|
// Initialize local database
|
||||||
await DatabaseService.instance.initialize();
|
await DatabaseService.instance.initialize();
|
||||||
_logger.log('💾 Database initialized');
|
_logger.log('💾 Database initialized');
|
||||||
@@ -84,7 +89,29 @@ Future<void> main() async {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
runApp(const MusicAssistantApp());
|
// Set up Flutter error boundary to catch and log widget build errors
|
||||||
|
FlutterError.onError = (FlutterErrorDetails details) {
|
||||||
|
_logger.error('Flutter error: ${details.exceptionAsString()}', context: 'FlutterError');
|
||||||
|
_logger.error('Stack trace:\n${details.stack}', context: 'FlutterError');
|
||||||
|
// Still report to Flutter's default handler in debug mode
|
||||||
|
FlutterError.presentError(details);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Catch errors from the platform dispatcher (platform channel errors)
|
||||||
|
PlatformDispatcher.instance.onError = (error, stack) {
|
||||||
|
_logger.error('Platform error: $error', context: 'PlatformDispatcher');
|
||||||
|
_logger.error('Stack trace:\n$stack', context: 'PlatformDispatcher');
|
||||||
|
return true; // Handled
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wrap app in zone to catch async errors
|
||||||
|
runZonedGuarded(
|
||||||
|
() => runApp(const MusicAssistantApp()),
|
||||||
|
(error, stackTrace) {
|
||||||
|
_logger.error('Uncaught async error: $error', context: 'ZoneError');
|
||||||
|
_logger.error('Stack trace:\n$stackTrace', context: 'ZoneError');
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class MusicAssistantApp extends StatefulWidget {
|
class MusicAssistantApp extends StatefulWidget {
|
||||||
@@ -179,7 +206,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
@override
|
@override
|
||||||
Future<bool> didPopRoute() async {
|
Future<bool> didPopRoute() async {
|
||||||
// Intercept back button at app level - runs BEFORE Navigator processes it
|
// Intercept back button at app level - runs BEFORE Navigator processes it
|
||||||
// Priority order: device list > expanded player > normal navigation
|
// Priority order: device list > queue panel > expanded player > normal navigation
|
||||||
|
|
||||||
// Device list has highest priority - dismiss it first
|
// Device list has highest priority - dismiss it first
|
||||||
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
|
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
|
||||||
@@ -187,6 +214,14 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
return true; // We handled it, don't let Navigator process it
|
return true; // We handled it, don't let Navigator process it
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Queue panel second - close it before collapsing player
|
||||||
|
// Use target state (not animation value) to handle rapid open-close timing
|
||||||
|
// Use withHaptic: false because Android back gesture provides system haptic
|
||||||
|
if (GlobalPlayerOverlay.isQueuePanelTargetOpen) {
|
||||||
|
GlobalPlayerOverlay.closeQueuePanel(withHaptic: false);
|
||||||
|
return true; // We handled it, don't let Navigator process it
|
||||||
|
}
|
||||||
|
|
||||||
// Then check expanded player
|
// Then check expanded player
|
||||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||||
GlobalPlayerOverlay.collapsePlayer();
|
GlobalPlayerOverlay.collapsePlayer();
|
||||||
@@ -266,6 +301,29 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
],
|
],
|
||||||
supportedLocales: S.supportedLocales,
|
supportedLocales: S.supportedLocales,
|
||||||
locale: localeProvider.locale,
|
locale: localeProvider.locale,
|
||||||
|
// Fallback to English when locale is not supported
|
||||||
|
localeListResolutionCallback: (locales, supportedLocales) {
|
||||||
|
// If user has set a specific locale, try to use it
|
||||||
|
if (localeProvider.locale != null) {
|
||||||
|
for (final supported in supportedLocales) {
|
||||||
|
if (supported.languageCode == localeProvider.locale!.languageCode) {
|
||||||
|
return supported;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try to match system locales
|
||||||
|
if (locales != null) {
|
||||||
|
for (final locale in locales) {
|
||||||
|
for (final supported in supportedLocales) {
|
||||||
|
if (supported.languageCode == locale.languageCode) {
|
||||||
|
return supported;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to English (not German)
|
||||||
|
return const Locale('en');
|
||||||
|
},
|
||||||
themeMode: themeProvider.themeMode,
|
themeMode: themeProvider.themeMode,
|
||||||
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
|
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
|
||||||
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
||||||
|
|||||||
@@ -58,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,
|
||||||
@@ -71,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) {
|
||||||
@@ -83,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',
|
||||||
@@ -99,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?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +143,10 @@ 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,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) {
|
||||||
@@ -143,6 +178,10 @@ 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,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) {
|
||||||
@@ -194,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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,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) {
|
||||||
@@ -261,6 +312,10 @@ 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,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) {
|
||||||
@@ -311,6 +370,10 @@ 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import '../services/music_assistant_api.dart';
|
|
||||||
import '../services/settings_service.dart';
|
|
||||||
import '../services/database_service.dart';
|
|
||||||
import '../services/profile_service.dart';
|
|
||||||
import '../services/debug_logger.dart';
|
|
||||||
import '../services/error_handler.dart';
|
|
||||||
import '../services/auth/auth_manager.dart';
|
|
||||||
import '../services/cache_service.dart';
|
|
||||||
|
|
||||||
/// Provider for managing connection state to Music Assistant server
|
|
||||||
/// Handles WebSocket connection, authentication, and reconnection
|
|
||||||
class ConnectionProvider with ChangeNotifier {
|
|
||||||
MusicAssistantAPI? _api;
|
|
||||||
final AuthManager _authManager = AuthManager();
|
|
||||||
final DebugLogger _logger = DebugLogger();
|
|
||||||
final CacheService _cacheService = CacheService();
|
|
||||||
|
|
||||||
MAConnectionState _connectionState = MAConnectionState.disconnected;
|
|
||||||
String? _serverUrl;
|
|
||||||
String? _error;
|
|
||||||
|
|
||||||
// Callbacks for post-connection initialization
|
|
||||||
VoidCallback? onConnected;
|
|
||||||
VoidCallback? onAuthenticated;
|
|
||||||
VoidCallback? onDisconnected;
|
|
||||||
|
|
||||||
MAConnectionState get connectionState => _connectionState;
|
|
||||||
String? get serverUrl => _serverUrl;
|
|
||||||
String? get error => _error;
|
|
||||||
bool get isConnected => _connectionState == MAConnectionState.connected ||
|
|
||||||
_connectionState == MAConnectionState.authenticated;
|
|
||||||
|
|
||||||
MusicAssistantAPI? get api => _api;
|
|
||||||
AuthManager get authManager => _authManager;
|
|
||||||
CacheService get cacheService => _cacheService;
|
|
||||||
|
|
||||||
ConnectionProvider() {
|
|
||||||
_initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _initialize() async {
|
|
||||||
_serverUrl = await SettingsService.getServerUrl();
|
|
||||||
if (_serverUrl != null && _serverUrl!.isNotEmpty) {
|
|
||||||
await _restoreAuthCredentials();
|
|
||||||
await connectToServer(_serverUrl!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restore auth credentials from persistent storage
|
|
||||||
Future<void> _restoreAuthCredentials() async {
|
|
||||||
final savedCredentials = await SettingsService.getAuthCredentials();
|
|
||||||
if (savedCredentials != null) {
|
|
||||||
_logger.log('🔐 Restoring saved auth credentials...');
|
|
||||||
_authManager.deserializeCredentials(savedCredentials);
|
|
||||||
_logger.log('🔐 Auth credentials restored: ${_authManager.currentStrategy?.name ?? "none"}');
|
|
||||||
} else {
|
|
||||||
_logger.log('🔐 No saved auth credentials found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle Music Assistant native authentication after WebSocket connection
|
|
||||||
Future<bool> handleMaAuthentication() async {
|
|
||||||
if (_api == null) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// First, try stored MA token
|
|
||||||
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 _fetchAndSetUserProfileName();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
_logger.log('⚠️ Stored MA token invalid, clearing...');
|
|
||||||
await SettingsService.clearMaAuthToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
// No valid token - try stored credentials
|
|
||||||
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 _fetchAndSetUserProfileName();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.log('❌ MA authentication failed - no valid token or credentials');
|
|
||||||
return false;
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('❌ MA authentication error: $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch user profile from MA and set owner name + create/activate local profile
|
|
||||||
Future<void> _fetchAndSetUserProfileName() async {
|
|
||||||
if (_api == null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final userInfo = await _api!.getCurrentUserInfo();
|
|
||||||
if (userInfo == null) {
|
|
||||||
_logger.log('⚠️ Could not fetch user profile');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final displayName = userInfo['display_name'] as String?;
|
|
||||||
final username = userInfo['username'] as String?;
|
|
||||||
|
|
||||||
_logger.log('🔍 Profile data: display_name="$displayName", username="$username"');
|
|
||||||
|
|
||||||
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');
|
|
||||||
|
|
||||||
// Create/activate profile in local database
|
|
||||||
if (DatabaseService.instance.isInitialized && username != null) {
|
|
||||||
await ProfileService.instance.onMaAuthenticated(
|
|
||||||
username: username,
|
|
||||||
displayName: displayName,
|
|
||||||
);
|
|
||||||
_logger.log('✅ Local profile activated: $username');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_logger.log('⚠️ No valid name in profile');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('⚠️ Could not fetch user profile (non-fatal): $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> connectToServer(String serverUrl) async {
|
|
||||||
try {
|
|
||||||
_error = null;
|
|
||||||
_serverUrl = serverUrl;
|
|
||||||
await SettingsService.setServerUrl(serverUrl);
|
|
||||||
|
|
||||||
// Disconnect existing connection
|
|
||||||
await _api?.disconnect();
|
|
||||||
|
|
||||||
_api = MusicAssistantAPI(serverUrl, _authManager);
|
|
||||||
|
|
||||||
// Listen to connection state changes
|
|
||||||
_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 handleMaAuthentication();
|
|
||||||
if (!authenticated) {
|
|
||||||
_logger.log('❌ MA authentication failed - stopping connection flow');
|
|
||||||
_error = 'Authentication required. Please log in again.';
|
|
||||||
notifyListeners();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No auth required - notify connected
|
|
||||||
onConnected?.call();
|
|
||||||
} else if (state == MAConnectionState.authenticated) {
|
|
||||||
_logger.log('✅ MA authentication successful');
|
|
||||||
onAuthenticated?.call();
|
|
||||||
} else if (state == MAConnectionState.disconnected) {
|
|
||||||
// DON'T clear caches on disconnect - keep showing cached data
|
|
||||||
// This allows instant UI display when app resumes or reconnects
|
|
||||||
// Caches will be refreshed when connection is restored
|
|
||||||
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<void> disconnect() async {
|
|
||||||
await _api?.disconnect();
|
|
||||||
_connectionState = MAConnectionState.disconnected;
|
|
||||||
// DON'T clear caches on disconnect - keep showing cached data for instant reconnect
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all caches and state (for logout or server change)
|
|
||||||
void clearCachesOnLogout() {
|
|
||||||
_cacheService.clearAll();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check connection and reconnect if needed (called when app resumes)
|
|
||||||
Future<void> checkAndReconnect() async {
|
|
||||||
_logger.log('🔄 checkAndReconnect called - state: $_connectionState');
|
|
||||||
|
|
||||||
if (_serverUrl == null) {
|
|
||||||
_logger.log('🔄 No server URL saved, skipping reconnect');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_connectionState != MAConnectionState.connected &&
|
|
||||||
_connectionState != MAConnectionState.authenticated) {
|
|
||||||
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
|
|
||||||
try {
|
|
||||||
await connectToServer(_serverUrl!);
|
|
||||||
_logger.log('🔄 Reconnection successful');
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('🔄 Reconnection failed: $e');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_logger.log('🔄 Already connected, connection verified');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_api?.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import '../models/media_item.dart';
|
|
||||||
import '../services/music_assistant_api.dart';
|
|
||||||
import '../services/debug_logger.dart';
|
|
||||||
import '../services/error_handler.dart';
|
|
||||||
import '../services/cache_service.dart';
|
|
||||||
import '../services/settings_service.dart';
|
|
||||||
import '../constants/timings.dart';
|
|
||||||
|
|
||||||
/// Provider for managing library data (artists, albums, tracks, playlists)
|
|
||||||
class LibraryProvider with ChangeNotifier {
|
|
||||||
final DebugLogger _logger = DebugLogger();
|
|
||||||
final CacheService _cacheService;
|
|
||||||
|
|
||||||
MusicAssistantAPI? _api;
|
|
||||||
|
|
||||||
List<Artist> _artists = [];
|
|
||||||
List<Album> _albums = [];
|
|
||||||
List<Track> _tracks = [];
|
|
||||||
bool _isLoading = false;
|
|
||||||
String? _error;
|
|
||||||
|
|
||||||
// Search state persistence
|
|
||||||
String _lastSearchQuery = '';
|
|
||||||
Map<String, List<MediaItem>> _lastSearchResults = {
|
|
||||||
'artists': [],
|
|
||||||
'albums': [],
|
|
||||||
'tracks': [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
List<Artist> get artists => _artists;
|
|
||||||
List<Album> get albums => _albums;
|
|
||||||
List<Track> get tracks => _tracks;
|
|
||||||
bool get isLoading => _isLoading;
|
|
||||||
String? get error => _error;
|
|
||||||
String get lastSearchQuery => _lastSearchQuery;
|
|
||||||
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
|
|
||||||
|
|
||||||
LibraryProvider(this._cacheService);
|
|
||||||
|
|
||||||
/// Initialize with API reference after connection
|
|
||||||
void initialize(MusicAssistantAPI api) {
|
|
||||||
_api = api;
|
|
||||||
}
|
|
||||||
|
|
||||||
void saveSearchState(String query, Map<String, List<MediaItem>> results) {
|
|
||||||
_lastSearchQuery = query;
|
|
||||||
_lastSearchResults = results;
|
|
||||||
}
|
|
||||||
|
|
||||||
void clearSearchState() {
|
|
||||||
_lastSearchQuery = '';
|
|
||||||
_lastSearchResults = {
|
|
||||||
'artists': [],
|
|
||||||
'albums': [],
|
|
||||||
'tracks': [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// HOME SCREEN DATA
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Get recently played albums with caching
|
|
||||||
Future<List<Album>> getRecentAlbumsWithCache({bool forceRefresh = false}) async {
|
|
||||||
if (_cacheService.isRecentAlbumsCacheValid(forceRefresh: forceRefresh)) {
|
|
||||||
_logger.log('📦 Using cached recent albums');
|
|
||||||
return _cacheService.getCachedRecentAlbums()!;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_api == null) return _cacheService.getCachedRecentAlbums() ?? [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
_logger.log('🔄 Fetching fresh recent albums...');
|
|
||||||
final albums = await _api!.getRecentAlbums(limit: LibraryConstants.defaultRecentLimit);
|
|
||||||
_cacheService.setCachedRecentAlbums(albums);
|
|
||||||
return albums;
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('❌ Failed to fetch recent albums: $e');
|
|
||||||
return _cacheService.getCachedRecentAlbums() ?? [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get discover artists (random) with caching
|
|
||||||
Future<List<Artist>> getDiscoverArtistsWithCache({bool forceRefresh = false}) async {
|
|
||||||
if (_cacheService.isDiscoverArtistsCacheValid(forceRefresh: forceRefresh)) {
|
|
||||||
_logger.log('📦 Using cached discover artists');
|
|
||||||
return _cacheService.getCachedDiscoverArtists()!;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_api == null) return _cacheService.getCachedDiscoverArtists() ?? [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
_logger.log('🔄 Fetching fresh discover artists...');
|
|
||||||
final artists = await _api!.getRandomArtists(limit: LibraryConstants.defaultRecentLimit);
|
|
||||||
_cacheService.setCachedDiscoverArtists(artists);
|
|
||||||
return artists;
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('❌ Failed to fetch discover artists: $e');
|
|
||||||
return _cacheService.getCachedDiscoverArtists() ?? [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get discover albums (random) with caching
|
|
||||||
Future<List<Album>> getDiscoverAlbumsWithCache({bool forceRefresh = false}) async {
|
|
||||||
if (_cacheService.isDiscoverAlbumsCacheValid(forceRefresh: forceRefresh)) {
|
|
||||||
_logger.log('📦 Using cached discover albums');
|
|
||||||
return _cacheService.getCachedDiscoverAlbums()!;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_api == null) return _cacheService.getCachedDiscoverAlbums() ?? [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
_logger.log('🔄 Fetching fresh discover albums...');
|
|
||||||
final albums = await _api!.getRandomAlbums(limit: LibraryConstants.defaultRecentLimit);
|
|
||||||
_cacheService.setCachedDiscoverAlbums(albums);
|
|
||||||
return albums;
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('❌ Failed to fetch discover albums: $e');
|
|
||||||
return _cacheService.getCachedDiscoverAlbums() ?? [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Invalidate home screen cache (call on pull-to-refresh)
|
|
||||||
void invalidateHomeCache() {
|
|
||||||
_cacheService.invalidateHomeCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// LIBRARY DATA
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
Future<void> loadLibrary() async {
|
|
||||||
if (_api == null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
_isLoading = true;
|
|
||||||
_error = null;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
// Read artist filter setting - when ON, only fetch artists that have albums
|
|
||||||
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
|
||||||
_logger.log('🎨 loadLibrary using albumArtistsOnly: $showOnlyArtistsWithAlbums');
|
|
||||||
|
|
||||||
final results = await Future.wait([
|
|
||||||
_api!.getArtists(limit: LibraryConstants.maxLibraryItems, albumArtistsOnly: showOnlyArtistsWithAlbums),
|
|
||||||
_api!.getAlbums(limit: LibraryConstants.maxLibraryItems),
|
|
||||||
_api!.getTracks(limit: LibraryConstants.maxLibraryItems),
|
|
||||||
]);
|
|
||||||
|
|
||||||
_artists = results[0] as List<Artist>;
|
|
||||||
_albums = results[1] as List<Album>;
|
|
||||||
_tracks = results[2] as List<Track>;
|
|
||||||
|
|
||||||
_isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
final errorInfo = ErrorHandler.handleError(e, context: 'Load library');
|
|
||||||
_error = errorInfo.userMessage;
|
|
||||||
_isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> loadArtists({int? limit, int? offset, String? search}) async {
|
|
||||||
if (_api == null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
_isLoading = true;
|
|
||||||
_error = null;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
// Read artist filter setting - when ON, only fetch artists that have albums
|
|
||||||
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
|
||||||
|
|
||||||
_artists = await _api!.getArtists(
|
|
||||||
limit: limit ?? LibraryConstants.maxLibraryItems,
|
|
||||||
offset: offset,
|
|
||||||
search: search,
|
|
||||||
albumArtistsOnly: showOnlyArtistsWithAlbums,
|
|
||||||
);
|
|
||||||
|
|
||||||
_isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
|
|
||||||
_error = errorInfo.userMessage;
|
|
||||||
_isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> loadAlbums({int? limit, int? offset, String? search, String? artistId}) async {
|
|
||||||
if (_api == null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
_isLoading = true;
|
|
||||||
_error = null;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
_albums = await _api!.getAlbums(
|
|
||||||
limit: limit ?? LibraryConstants.maxLibraryItems,
|
|
||||||
offset: offset,
|
|
||||||
search: search,
|
|
||||||
artistId: artistId,
|
|
||||||
);
|
|
||||||
|
|
||||||
_isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
|
|
||||||
_error = errorInfo.userMessage;
|
|
||||||
_isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// DETAIL SCREEN DATA
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Get album tracks with caching
|
|
||||||
Future<List<Track>> getAlbumTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
|
|
||||||
final cacheKey = '${provider}_$itemId';
|
|
||||||
|
|
||||||
if (_cacheService.isAlbumTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
|
||||||
_logger.log('📦 Using cached album tracks for $cacheKey');
|
|
||||||
return _cacheService.getCachedAlbumTracks(cacheKey)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_api == null) return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
_logger.log('🔄 Fetching fresh album tracks for $cacheKey...');
|
|
||||||
final tracks = await _api!.getAlbumTracks(provider, itemId);
|
|
||||||
_cacheService.setCachedAlbumTracks(cacheKey, tracks);
|
|
||||||
return tracks;
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('❌ Failed to fetch album tracks: $e');
|
|
||||||
return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get playlist tracks with caching
|
|
||||||
Future<List<Track>> getPlaylistTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
|
|
||||||
final cacheKey = '${provider}_$itemId';
|
|
||||||
|
|
||||||
if (_cacheService.isPlaylistTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
|
||||||
_logger.log('📦 Using cached playlist tracks for $cacheKey');
|
|
||||||
return _cacheService.getCachedPlaylistTracks(cacheKey)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_api == null) return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
_logger.log('🔄 Fetching fresh playlist tracks for $cacheKey...');
|
|
||||||
final tracks = await _api!.getPlaylistTracks(provider, itemId);
|
|
||||||
_cacheService.setCachedPlaylistTracks(cacheKey, tracks);
|
|
||||||
return tracks;
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('❌ Failed to fetch playlist tracks: $e');
|
|
||||||
return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get artist albums with caching
|
|
||||||
Future<List<Album>> getArtistAlbumsWithCache(String artistName, {bool forceRefresh = false}) async {
|
|
||||||
final cacheKey = artistName.toLowerCase();
|
|
||||||
|
|
||||||
if (_cacheService.isArtistAlbumsCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
|
||||||
_logger.log('📦 Using cached artist albums for "$artistName"');
|
|
||||||
return _cacheService.getCachedArtistAlbums(cacheKey)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_api == null) return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
_logger.log('🔄 Fetching albums for artist "$artistName"...');
|
|
||||||
|
|
||||||
final libraryAlbums = await _api!.getAlbums();
|
|
||||||
final artistAlbums = libraryAlbums.where((album) {
|
|
||||||
final albumArtists = album.artists;
|
|
||||||
if (albumArtists == null || albumArtists.isEmpty) return false;
|
|
||||||
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
final searchResults = await _api!.search(artistName);
|
|
||||||
final searchAlbums = searchResults['albums'] as List<MediaItem>? ?? [];
|
|
||||||
final providerAlbums = searchAlbums.whereType<Album>().where((album) {
|
|
||||||
final albumArtists = album.artists;
|
|
||||||
if (albumArtists == null || albumArtists.isEmpty) return false;
|
|
||||||
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
final allAlbums = <Album>[];
|
|
||||||
final seenNames = <String>{};
|
|
||||||
|
|
||||||
for (final album in [...artistAlbums, ...providerAlbums]) {
|
|
||||||
final key = album.name.toLowerCase();
|
|
||||||
if (!seenNames.contains(key)) {
|
|
||||||
seenNames.add(key);
|
|
||||||
allAlbums.add(album);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_cacheService.setCachedArtistAlbums(cacheKey, allAlbums);
|
|
||||||
return allAlbums;
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('❌ Failed to fetch artist albums: $e');
|
|
||||||
return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Invalidate album tracks cache
|
|
||||||
void invalidateAlbumTracksCache(String albumId) {
|
|
||||||
_cacheService.invalidateAlbumTracksCache(albumId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Invalidate playlist tracks cache
|
|
||||||
void invalidatePlaylistTracksCache(String playlistId) {
|
|
||||||
_cacheService.invalidatePlaylistTracksCache(playlistId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SEARCH
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Search with caching
|
|
||||||
Future<Map<String, List<MediaItem>>> searchWithCache(String query, {bool forceRefresh = false}) async {
|
|
||||||
final cacheKey = query.toLowerCase().trim();
|
|
||||||
if (cacheKey.isEmpty) return {'artists': [], 'albums': [], 'tracks': []};
|
|
||||||
|
|
||||||
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
|
||||||
_logger.log('📦 Using cached search results for "$query"');
|
|
||||||
return _cacheService.getCachedSearchResults(cacheKey)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_api == null) {
|
|
||||||
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
_logger.log('🔄 Searching for "$query"...');
|
|
||||||
final results = await _api!.search(query);
|
|
||||||
|
|
||||||
final cachedResults = <String, List<MediaItem>>{
|
|
||||||
'artists': results['artists'] ?? [],
|
|
||||||
'albums': results['albums'] ?? [],
|
|
||||||
'tracks': results['tracks'] ?? [],
|
|
||||||
};
|
|
||||||
|
|
||||||
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
|
|
||||||
return cachedResults;
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('❌ Search failed: $e');
|
|
||||||
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<String, List<MediaItem>>> search(String query, {bool libraryOnly = false}) async {
|
|
||||||
if (_api == null) return {'artists': [], 'albums': [], 'tracks': []};
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await _api!.search(query, libraryOnly: libraryOnly);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Search', e);
|
|
||||||
return {'artists': [], 'albums': [], 'tracks': []};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
|
|
||||||
if (_api == null) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await _api!.getAlbumTracks(provider, itemId);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Get album tracks', e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void clearState() {
|
|
||||||
_artists = [];
|
|
||||||
_albums = [];
|
|
||||||
_tracks = [];
|
|
||||||
_isLoading = false;
|
|
||||||
_error = null;
|
|
||||||
clearSearchState();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,6 +31,10 @@ class LocaleProvider extends ChangeNotifier {
|
|||||||
return 'English';
|
return 'English';
|
||||||
case 'de':
|
case 'de':
|
||||||
return 'Deutsch';
|
return 'Deutsch';
|
||||||
|
case 'es':
|
||||||
|
return 'Español';
|
||||||
|
case 'fr':
|
||||||
|
return 'Français';
|
||||||
case null:
|
case null:
|
||||||
return 'System';
|
return 'System';
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:audio_service/audio_service.dart' as audio_service;
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
|
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
|
import '../models/music_provider.dart';
|
||||||
import '../models/player.dart';
|
import '../models/player.dart';
|
||||||
import '../services/music_assistant_api.dart';
|
import '../services/music_assistant_api.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
@@ -65,6 +66,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping
|
Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping
|
||||||
Track? _currentTrack;
|
Track? _currentTrack;
|
||||||
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
|
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
|
||||||
|
String? _currentPodcastName; // Currently playing podcast's name (set when playing episode)
|
||||||
Timer? _playerStateTimer;
|
Timer? _playerStateTimer;
|
||||||
Timer? _notificationPositionTimer; // Updates notification position every second for remote players
|
Timer? _notificationPositionTimer; // Updates notification position every second for remote players
|
||||||
|
|
||||||
@@ -105,9 +107,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// This is populated with episode covers when podcast covers are low-res
|
// This is populated with episode covers when podcast covers are low-res
|
||||||
final Map<String, String> _podcastCoverCache = {};
|
final Map<String, String> _podcastCoverCache = {};
|
||||||
|
|
||||||
// Provider filter: list of allowed provider instance IDs from MA user settings
|
// Provider filter: list of DISABLED provider instance IDs from local app settings
|
||||||
// Empty list means all providers are allowed
|
// Empty list means all providers are enabled (shown)
|
||||||
List<String> _providerFilter = [];
|
List<String> _disabledProviders = [];
|
||||||
|
|
||||||
// Player filter: list of allowed player IDs from MA user settings
|
// Player filter: list of allowed player IDs from MA user settings
|
||||||
// Empty list means all players are allowed
|
// Empty list means all players are allowed
|
||||||
@@ -124,8 +126,14 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_connectionState == MAConnectionState.authenticated;
|
_connectionState == MAConnectionState.authenticated;
|
||||||
|
|
||||||
// Library getters with provider filtering applied
|
// Library getters with provider filtering applied
|
||||||
List<Artist> get artists => filterByProvider(_artists);
|
List<Artist> get artists {
|
||||||
List<Album> get albums => filterByProvider(_albums);
|
_logger.log('🔍 artists getter: ${_artists.length} items, disabled=$_disabledProviders');
|
||||||
|
return filterByProvider(_artists);
|
||||||
|
}
|
||||||
|
List<Album> get albums {
|
||||||
|
_logger.log('🔍 albums getter: ${_albums.length} items, disabled=$_disabledProviders');
|
||||||
|
return filterByProvider(_albums);
|
||||||
|
}
|
||||||
List<Track> get tracks => filterByProvider(_tracks);
|
List<Track> get tracks => filterByProvider(_tracks);
|
||||||
List<MediaItem> get radioStations => filterByProvider(_radioStations);
|
List<MediaItem> get radioStations => filterByProvider(_radioStations);
|
||||||
List<MediaItem> get podcasts => filterByProvider(_podcasts);
|
List<MediaItem> get podcasts => filterByProvider(_podcasts);
|
||||||
@@ -137,54 +145,145 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
bool get isLoadingRadio => _isLoadingRadio;
|
bool get isLoadingRadio => _isLoadingRadio;
|
||||||
bool get isLoadingPodcasts => _isLoadingPodcasts;
|
bool get isLoadingPodcasts => _isLoadingPodcasts;
|
||||||
|
|
||||||
/// Provider filter from MA user settings (empty = all providers allowed)
|
/// Available music providers from MA server
|
||||||
List<String> get providerFilter => _providerFilter;
|
List<MusicProvider> get musicProviders => _api?.musicProviders ?? [];
|
||||||
|
|
||||||
/// Whether provider filtering is active
|
/// List of disabled provider instance IDs (from local app settings)
|
||||||
bool get hasProviderFilter => _providerFilter.isNotEmpty;
|
List<String> get disabledProviders => _disabledProviders;
|
||||||
|
|
||||||
|
/// Whether provider filtering is active (any provider disabled)
|
||||||
|
bool get hasProviderFilter => _disabledProviders.isNotEmpty;
|
||||||
|
|
||||||
|
/// Infrastructure providers that should be ignored for library filtering
|
||||||
|
static const _infrastructureProviders = {'builtin', 'radiobrowser', 'itunes_podcasts', 'audiobookshelf'};
|
||||||
|
|
||||||
|
/// Get the single selected provider for server-side filtering
|
||||||
|
/// Returns the provider instance ID if exactly one "real" music provider is enabled
|
||||||
|
/// Returns null if no filtering needed or multiple providers enabled
|
||||||
|
String? get selectedProviderForFilter {
|
||||||
|
if (_disabledProviders.isEmpty) return null;
|
||||||
|
|
||||||
|
// Get all available music providers from the server
|
||||||
|
final allProviders = musicProviders;
|
||||||
|
if (allProviders.isEmpty) return null;
|
||||||
|
|
||||||
|
// Find enabled "real" music providers (not infrastructure)
|
||||||
|
final enabledRealProviders = allProviders.where((p) {
|
||||||
|
// Skip infrastructure providers
|
||||||
|
if (_infrastructureProviders.contains(p.instanceId) ||
|
||||||
|
_infrastructureProviders.any((infra) => p.instanceId.startsWith(infra))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Check if this provider is enabled (not in disabled list)
|
||||||
|
return !_disabledProviders.contains(p.instanceId);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// If exactly one real provider is enabled, use it for server-side filtering
|
||||||
|
if (enabledRealProviders.length == 1) {
|
||||||
|
final provider = enabledRealProviders.first.instanceId;
|
||||||
|
_logger.log('🔌 Selected provider for filter: $provider');
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('🔌 No single provider selected: ${enabledRealProviders.length} enabled');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if a media item should be visible based on provider filter
|
/// Check if a media item should be visible based on provider filter
|
||||||
/// Returns true if:
|
/// Returns true if item has at least one ENABLED provider
|
||||||
/// - No filter is active (empty list = all providers allowed)
|
/// Hide only if ALL of the item's providers are disabled
|
||||||
/// - The item has at least one provider mapping in the allowed list
|
int _filterDebugCount = 0;
|
||||||
/// - The item's primary provider is in the allowed list
|
|
||||||
bool isItemAllowedByProviderFilter(MediaItem item) {
|
bool isItemAllowedByProviderFilter(MediaItem item) {
|
||||||
// No filter = show everything
|
// No disabled providers = show everything
|
||||||
if (_providerFilter.isEmpty) return true;
|
if (_disabledProviders.isEmpty) return true;
|
||||||
|
|
||||||
// Check if item's provider mappings include any allowed provider
|
|
||||||
final mappings = item.providerMappings;
|
final mappings = item.providerMappings;
|
||||||
if (mappings != null && mappings.isNotEmpty) {
|
if (mappings != null && mappings.isNotEmpty) {
|
||||||
for (final mapping in mappings) {
|
// Get real provider mappings (exclude "None" which is MA's internal library)
|
||||||
if (_providerFilter.contains(mapping.providerInstance)) {
|
final realMappings = mappings
|
||||||
return true;
|
.where((m) => m.providerInstance != 'None' && m.providerInstance.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// If no real mappings, show the item (only has internal library reference)
|
||||||
|
if (realMappings.isEmpty) {
|
||||||
|
if (_filterDebugCount < 10) {
|
||||||
|
_logger.log('🔍 ALLOWED (no real mappings): "${item.name}"');
|
||||||
|
_filterDebugCount++;
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show if ANY provider is enabled (not in disabled list)
|
||||||
|
final enabledMappings = realMappings.where((m) => !_disabledProviders.contains(m.providerInstance)).toList();
|
||||||
|
final allowed = enabledMappings.isNotEmpty;
|
||||||
|
|
||||||
|
if (_filterDebugCount < 10) {
|
||||||
|
final allProviders = realMappings.map((m) => m.providerInstance).toList();
|
||||||
|
final enabledProviders = enabledMappings.map((m) => m.providerInstance).toList();
|
||||||
|
_logger.log('🔍 ${allowed ? "ALLOWED" : "FILTERED"}: "${item.name}" providers=$allProviders enabled=$enabledProviders');
|
||||||
|
_filterDebugCount++;
|
||||||
|
}
|
||||||
|
return allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check primary provider field (for items without full mappings)
|
// Check primary provider field (for items without full mappings)
|
||||||
if (_providerFilter.contains(item.provider)) {
|
final allowed = !_disabledProviders.contains(item.provider);
|
||||||
return true;
|
if (_filterDebugCount < 10) {
|
||||||
|
_logger.log('🔍 ${allowed ? "ALLOWED" : "FILTERED"} (no mappings): "${item.name}" provider=${item.provider}');
|
||||||
|
_filterDebugCount++;
|
||||||
}
|
}
|
||||||
|
return allowed;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filter a list of media items based on provider filter
|
/// Filter a list of media items based on provider filter
|
||||||
List<T> filterByProvider<T extends MediaItem>(List<T> items) {
|
List<T> filterByProvider<T extends MediaItem>(List<T> items) {
|
||||||
if (_providerFilter.isEmpty) return items;
|
if (_disabledProviders.isEmpty) return items;
|
||||||
return items.where(isItemAllowedByProviderFilter).toList();
|
_filterDebugCount = 0; // Reset for fresh logging
|
||||||
|
final filtered = items.where(isItemAllowedByProviderFilter).toList();
|
||||||
|
_logger.log('🔌 filterByProvider: ${items.length} → ${filtered.length} (disabled=$_disabledProviders)');
|
||||||
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filter search results map based on provider filter
|
/// Filter search results map based on provider filter
|
||||||
Map<String, List<MediaItem>> filterSearchResults(Map<String, List<MediaItem>> results) {
|
Map<String, List<MediaItem>> filterSearchResults(Map<String, List<MediaItem>> results) {
|
||||||
if (_providerFilter.isEmpty) return results;
|
if (_disabledProviders.isEmpty) return results;
|
||||||
return {
|
return {
|
||||||
for (final entry in results.entries)
|
for (final entry in results.entries)
|
||||||
entry.key: entry.value.where(isItemAllowedByProviderFilter).toList(),
|
entry.key: entry.value.where(isItemAllowedByProviderFilter).toList(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load disabled providers from local settings
|
||||||
|
Future<void> loadDisabledProviders() async {
|
||||||
|
_disabledProviders = await SettingsService.getDisabledMusicProviders();
|
||||||
|
_logger.log('🔌 Loaded disabled providers: $_disabledProviders');
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle a provider's enabled state and persist
|
||||||
|
/// Clears caches and forces a fresh sync with the new filter
|
||||||
|
Future<void> toggleProviderEnabled(String instanceId, bool enabled) async {
|
||||||
|
_logger.log('🔌 Toggle provider $instanceId enabled=$enabled');
|
||||||
|
await SettingsService.toggleProviderEnabled(instanceId, enabled);
|
||||||
|
await loadDisabledProviders();
|
||||||
|
|
||||||
|
// Clear all caches so fresh filtered data is fetched
|
||||||
|
_cacheService.invalidateHomeCache();
|
||||||
|
_cacheService.invalidateSearchCache();
|
||||||
|
await SyncService.instance.clearCache();
|
||||||
|
|
||||||
|
_logger.log('🔌 After toggle, disabled=$_disabledProviders, selectedFilter=${selectedProviderForFilter}');
|
||||||
|
|
||||||
|
// Force a fresh sync with the new provider filter
|
||||||
|
if (_api != null) {
|
||||||
|
_logger.log('🔌 Forcing library re-sync with provider filter...');
|
||||||
|
await SyncService.instance.forceSync(_api!, provider: selectedProviderForFilter);
|
||||||
|
_albums = SyncService.instance.cachedAlbums;
|
||||||
|
_artists = SyncService.instance.cachedArtists;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Player filter from MA user settings (empty = all players allowed)
|
/// Player filter from MA user settings (empty = all players allowed)
|
||||||
List<String> get playerFilter => _playerFilter;
|
List<String> get playerFilter => _playerFilter;
|
||||||
|
|
||||||
@@ -333,14 +432,18 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get the podcast name when playing a podcast episode
|
/// Get the podcast name when playing a podcast episode
|
||||||
/// Returns the podcast name from metadata, album name, or artist name
|
/// Returns the podcast name from stored context, metadata, or fallbacks
|
||||||
String? get currentPodcastName {
|
String? get currentPodcastName {
|
||||||
if (!isPlayingPodcast || _currentTrack == null) return null;
|
if (!isPlayingPodcast || _currentTrack == null) return null;
|
||||||
|
|
||||||
// Try metadata first (if episode has parent podcast info)
|
// Primary source: stored podcast name from when episode was played
|
||||||
|
if (_currentPodcastName != null && _currentPodcastName!.isNotEmpty) {
|
||||||
|
return _currentPodcastName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try metadata (if episode has parent podcast info from API)
|
||||||
final metadata = _currentTrack!.metadata;
|
final metadata = _currentTrack!.metadata;
|
||||||
if (metadata != null) {
|
if (metadata != null) {
|
||||||
// Check for podcast_name or podcast.name in metadata
|
|
||||||
if (metadata['podcast_name'] != null) {
|
if (metadata['podcast_name'] != null) {
|
||||||
return metadata['podcast_name'] as String;
|
return metadata['podcast_name'] as String;
|
||||||
}
|
}
|
||||||
@@ -351,20 +454,26 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fall back to album name (often contains podcast name)
|
|
||||||
if (_currentTrack!.album != null && _currentTrack!.album!.name.isNotEmpty) {
|
// Fallbacks removed - album/artist contain episode name, not podcast name
|
||||||
return _currentTrack!.album!.name;
|
// Return null to let UI show generic "Podcasts" label
|
||||||
}
|
|
||||||
// Fall back to artist (sometimes the podcast name is set as artist)
|
|
||||||
if (_currentTrack!.artists != null && _currentTrack!.artists!.isNotEmpty) {
|
|
||||||
final artistName = _currentTrack!.artists!.first.name;
|
|
||||||
if (artistName != 'Unknown Artist' && artistName.isNotEmpty) {
|
|
||||||
return artistName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the current podcast context (call when playing a podcast episode)
|
||||||
|
void setCurrentPodcastName(String? podcastName) {
|
||||||
|
_currentPodcastName = podcastName;
|
||||||
|
_logger.log('🎙️ Set current podcast name: $podcastName');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the podcast context
|
||||||
|
void clearCurrentPodcastName() {
|
||||||
|
if (_currentPodcastName != null) {
|
||||||
|
_logger.log('🎙️ Cleared podcast context');
|
||||||
|
_currentPodcastName = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String get lastSearchQuery => _lastSearchQuery;
|
String get lastSearchQuery => _lastSearchQuery;
|
||||||
Map<String, List<MediaItem>> get lastSearchResults => filterSearchResults(_lastSearchResults);
|
Map<String, List<MediaItem>> get lastSearchResults => filterSearchResults(_lastSearchResults);
|
||||||
|
|
||||||
@@ -490,6 +599,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
return getImageUrl(track, size: size);
|
return getImageUrl(track, size: size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear cached track for a player (e.g., after queue transfer)
|
||||||
|
void clearPlayerTrackCache(String playerId) {
|
||||||
|
_cacheService.clearCachedTrackForPlayer(playerId);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Number of pending offline actions
|
/// Number of pending offline actions
|
||||||
int get pendingOfflineActionsCount => OfflineActionQueue.instance.pendingCount;
|
int get pendingOfflineActionsCount => OfflineActionQueue.instance.pendingCount;
|
||||||
|
|
||||||
@@ -899,22 +1014,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_logger.log('✅ Set owner name from MA profile: $profileName');
|
_logger.log('✅ Set owner name from MA profile: $profileName');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture provider filter (empty list = all providers allowed)
|
// Load provider filter from LOCAL app settings (not MA user profile)
|
||||||
final providerFilterRaw = userInfo['provider_filter'];
|
// MA user profile provider_filter is ignored as it causes issues with recents
|
||||||
if (providerFilterRaw is List) {
|
await loadDisabledProviders();
|
||||||
_providerFilter = providerFilterRaw.cast<String>().toList();
|
|
||||||
if (_providerFilter.isNotEmpty) {
|
|
||||||
_logger.log('🔒 Provider filter active: ${_providerFilter.length} providers allowed');
|
|
||||||
_logger.log(' Allowed: ${_providerFilter.join(", ")}');
|
|
||||||
} else {
|
|
||||||
_logger.log('🔓 No provider filter - all providers visible');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_providerFilter = [];
|
|
||||||
_logger.log('🔓 No provider filter in user settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture player filter (empty list = all players allowed)
|
// Capture player filter from MA user settings (empty list = all players allowed)
|
||||||
final playerFilterRaw = userInfo['player_filter'];
|
final playerFilterRaw = userInfo['player_filter'];
|
||||||
if (playerFilterRaw is List) {
|
if (playerFilterRaw is List) {
|
||||||
_playerFilter = playerFilterRaw.cast<String>().toList();
|
_playerFilter = playerFilterRaw.cast<String>().toList();
|
||||||
@@ -2350,6 +2454,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
clearCurrentAudiobook();
|
clearCurrentAudiobook();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear podcast context when switching to non-podcast media
|
||||||
|
if (mediaType != 'podcast_episode' && _currentPodcastName != null) {
|
||||||
|
_logger.log('🎙️ Media type changed to $mediaType - clearing podcast context');
|
||||||
|
clearCurrentPodcastName();
|
||||||
|
}
|
||||||
|
|
||||||
if (mediaType != 'flow_stream') {
|
if (mediaType != 'flow_stream') {
|
||||||
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
|
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
|
||||||
final albumName = currentMedia['album'] as String?;
|
final albumName = currentMedia['album'] as String?;
|
||||||
@@ -2697,38 +2807,46 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
Future<List<Artist>> getDiscoverArtistsWithCache({bool forceRefresh = false}) async {
|
Future<List<Artist>> getDiscoverArtistsWithCache({bool forceRefresh = false}) async {
|
||||||
if (_cacheService.isDiscoverArtistsCacheValid(forceRefresh: forceRefresh)) {
|
if (_cacheService.isDiscoverArtistsCacheValid(forceRefresh: forceRefresh)) {
|
||||||
_logger.log('📦 Using cached discover artists');
|
_logger.log('📦 Using cached discover artists');
|
||||||
return _cacheService.getCachedDiscoverArtists()!;
|
return filterByProvider(_cacheService.getCachedDiscoverArtists()!);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_api == null) return _cacheService.getCachedDiscoverArtists() ?? [];
|
if (_api == null) {
|
||||||
|
final cached = _cacheService.getCachedDiscoverArtists();
|
||||||
|
return cached != null ? filterByProvider(cached) : [];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_logger.log('🔄 Fetching fresh discover artists...');
|
_logger.log('🔄 Fetching fresh discover artists...');
|
||||||
final artists = await _api!.getRandomArtists(limit: LibraryConstants.defaultRecentLimit);
|
final artists = await _api!.getRandomArtists(limit: LibraryConstants.defaultRecentLimit);
|
||||||
_cacheService.setCachedDiscoverArtists(artists);
|
_cacheService.setCachedDiscoverArtists(artists);
|
||||||
return artists;
|
return filterByProvider(artists);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('❌ Failed to fetch discover artists: $e');
|
_logger.log('❌ Failed to fetch discover artists: $e');
|
||||||
return _cacheService.getCachedDiscoverArtists() ?? [];
|
final cached = _cacheService.getCachedDiscoverArtists();
|
||||||
|
return cached != null ? filterByProvider(cached) : [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Album>> getDiscoverAlbumsWithCache({bool forceRefresh = false}) async {
|
Future<List<Album>> getDiscoverAlbumsWithCache({bool forceRefresh = false}) async {
|
||||||
if (_cacheService.isDiscoverAlbumsCacheValid(forceRefresh: forceRefresh)) {
|
if (_cacheService.isDiscoverAlbumsCacheValid(forceRefresh: forceRefresh)) {
|
||||||
_logger.log('📦 Using cached discover albums');
|
_logger.log('📦 Using cached discover albums');
|
||||||
return _cacheService.getCachedDiscoverAlbums()!;
|
return filterByProvider(_cacheService.getCachedDiscoverAlbums()!);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_api == null) return _cacheService.getCachedDiscoverAlbums() ?? [];
|
if (_api == null) {
|
||||||
|
final cached = _cacheService.getCachedDiscoverAlbums();
|
||||||
|
return cached != null ? filterByProvider(cached) : [];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_logger.log('🔄 Fetching fresh discover albums...');
|
_logger.log('🔄 Fetching fresh discover albums...');
|
||||||
final albums = await _api!.getRandomAlbums(limit: LibraryConstants.defaultRecentLimit);
|
final albums = await _api!.getRandomAlbums(limit: LibraryConstants.defaultRecentLimit);
|
||||||
_cacheService.setCachedDiscoverAlbums(albums);
|
_cacheService.setCachedDiscoverAlbums(albums);
|
||||||
return albums;
|
return filterByProvider(albums);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('❌ Failed to fetch discover albums: $e');
|
_logger.log('❌ Failed to fetch discover albums: $e');
|
||||||
return _cacheService.getCachedDiscoverAlbums() ?? [];
|
final cached = _cacheService.getCachedDiscoverAlbums();
|
||||||
|
return cached != null ? filterByProvider(cached) : [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2736,21 +2854,31 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_cacheService.invalidateHomeCache();
|
_cacheService.invalidateHomeCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get cached recent albums synchronously (for instant display)
|
/// Get cached recent albums synchronously (for instant display, with filtering)
|
||||||
List<Album>? getCachedRecentAlbums() => _cacheService.getCachedRecentAlbums();
|
List<Album>? getCachedRecentAlbums() {
|
||||||
|
final cached = _cacheService.getCachedRecentAlbums();
|
||||||
|
return cached != null ? filterByProvider(cached) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Get cached discover artists synchronously (for instant display)
|
/// Get cached discover artists synchronously (for instant display, with filtering)
|
||||||
List<Artist>? getCachedDiscoverArtists() => _cacheService.getCachedDiscoverArtists();
|
List<Artist>? getCachedDiscoverArtists() {
|
||||||
|
final cached = _cacheService.getCachedDiscoverArtists();
|
||||||
|
return cached != null ? filterByProvider(cached) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Get cached discover albums synchronously (for instant display)
|
/// Get cached discover albums synchronously (for instant display, with filtering)
|
||||||
List<Album>? getCachedDiscoverAlbums() => _cacheService.getCachedDiscoverAlbums();
|
List<Album>? getCachedDiscoverAlbums() {
|
||||||
|
final cached = _cacheService.getCachedDiscoverAlbums();
|
||||||
|
return cached != null ? filterByProvider(cached) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Force a full library sync (for pull-to-refresh)
|
/// Force a full library sync (for pull-to-refresh)
|
||||||
Future<void> forceLibrarySync() async {
|
Future<void> forceLibrarySync() async {
|
||||||
if (_api == null) return;
|
if (_api == null) return;
|
||||||
|
|
||||||
_logger.log('🔄 Forcing full library sync...');
|
final providerFilter = selectedProviderForFilter;
|
||||||
await SyncService.instance.forceSync(_api!);
|
_logger.log('🔄 Forcing full library sync (provider=$providerFilter)...');
|
||||||
|
await SyncService.instance.forceSync(_api!, provider: providerFilter);
|
||||||
|
|
||||||
// Update local lists from sync result
|
// Update local lists from sync result
|
||||||
_albums = SyncService.instance.cachedAlbums;
|
_albums = SyncService.instance.cachedAlbums;
|
||||||
@@ -2771,32 +2899,36 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// FAVORITES FOR HOME SCREEN
|
// FAVORITES FOR HOME SCREEN
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// Get favorite albums from the library
|
/// Get favorite albums from the library (with provider filtering)
|
||||||
Future<List<Album>> getFavoriteAlbums() async {
|
Future<List<Album>> getFavoriteAlbums() async {
|
||||||
// Filter from loaded library - favorites are already loaded
|
// Filter from loaded library - favorites are already loaded
|
||||||
return _albums.where((a) => a.favorite == true).toList();
|
// Use filtered albums to respect provider settings
|
||||||
|
return albums.where((a) => a.favorite == true).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get favorite artists from the library
|
/// Get favorite artists from the library (with provider filtering)
|
||||||
Future<List<Artist>> getFavoriteArtists() async {
|
Future<List<Artist>> getFavoriteArtists() async {
|
||||||
// Filter from loaded library - favorites are already loaded
|
// Filter from loaded library - favorites are already loaded
|
||||||
return _artists.where((a) => a.favorite == true).toList();
|
// Use filtered artists to respect provider settings
|
||||||
|
return artists.where((a) => a.favorite == true).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get favorite tracks from the library
|
/// Get favorite tracks from the library (with provider filtering)
|
||||||
Future<List<Track>> getFavoriteTracks() async {
|
Future<List<Track>> getFavoriteTracks() async {
|
||||||
// If full tracks list is loaded, filter from it
|
// If full tracks list is loaded, filter from it
|
||||||
|
// Use filtered tracks to respect provider settings
|
||||||
if (_tracks.isNotEmpty) {
|
if (_tracks.isNotEmpty) {
|
||||||
final favTracks = _tracks.where((t) => t.favorite == true).toList();
|
final favTracks = tracks.where((t) => t.favorite == true).toList();
|
||||||
// Update cache if we have new data
|
// Update cache if we have new data (using unfiltered for persistence)
|
||||||
if (favTracks.isNotEmpty && favTracks.length != _cachedFavoriteTracks.length) {
|
final unfilteredFavTracks = _tracks.where((t) => t.favorite == true).toList();
|
||||||
_cachedFavoriteTracks = favTracks;
|
if (unfilteredFavTracks.isNotEmpty && unfilteredFavTracks.length != _cachedFavoriteTracks.length) {
|
||||||
_persistFavoriteTracks(favTracks);
|
_cachedFavoriteTracks = unfilteredFavTracks;
|
||||||
|
_persistFavoriteTracks(unfilteredFavTracks);
|
||||||
}
|
}
|
||||||
return favTracks;
|
return favTracks;
|
||||||
}
|
}
|
||||||
// Otherwise return cached favorites (for instant display before full library loads)
|
// Otherwise return cached favorites (for instant display before full library loads)
|
||||||
return _cachedFavoriteTracks;
|
return filterByProvider(_cachedFavoriteTracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get favorite playlists from the library
|
/// Get favorite playlists from the library
|
||||||
@@ -2822,13 +2954,36 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get favorite podcasts from the library
|
||||||
|
Future<List<MediaItem>> getFavoritePodcasts() async {
|
||||||
|
if (_api == null) return [];
|
||||||
|
try {
|
||||||
|
final podcasts = await _api!.getPodcasts(favoriteOnly: true);
|
||||||
|
return filterByProvider(podcasts);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Failed to fetch favorite podcasts: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// AUDIOBOOK HOME SCREEN ROWS
|
// AUDIOBOOK HOME SCREEN ROWS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// Get audiobooks that have progress (continue listening)
|
/// Get audiobooks that have progress (continue listening) with caching
|
||||||
Future<List<Audiobook>> getInProgressAudiobooks() async {
|
Future<List<Audiobook>> getInProgressAudiobooksWithCache({bool forceRefresh = false}) async {
|
||||||
if (_api == null) return [];
|
// Check cache first
|
||||||
|
if (!forceRefresh && _cacheService.isInProgressAudiobooksCacheValid()) {
|
||||||
|
_logger.log('📦 Using cached in-progress audiobooks');
|
||||||
|
return filterByProvider(_cacheService.getCachedInProgressAudiobooks()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_api == null) {
|
||||||
|
// Fallback to cache when offline
|
||||||
|
final cached = _cacheService.getCachedInProgressAudiobooks();
|
||||||
|
if (cached != null && cached.isNotEmpty) return filterByProvider(cached);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_logger.log('📚 Fetching in-progress audiobooks...');
|
_logger.log('📚 Fetching in-progress audiobooks...');
|
||||||
@@ -2838,48 +2993,100 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
.where((a) => a.progress > 0 && a.progress < 1.0) // Has progress but not complete
|
.where((a) => a.progress > 0 && a.progress < 1.0) // Has progress but not complete
|
||||||
.toList()
|
.toList()
|
||||||
..sort((a, b) => b.progress.compareTo(a.progress)); // Sort by progress descending
|
..sort((a, b) => b.progress.compareTo(a.progress)); // Sort by progress descending
|
||||||
_logger.log('📚 Found ${inProgress.length} in-progress audiobooks');
|
final result = inProgress.take(20).toList(); // Limit to 20 for home row
|
||||||
return inProgress.take(20).toList(); // Limit to 20 for home row
|
_logger.log('📚 Found ${result.length} in-progress audiobooks');
|
||||||
|
_cacheService.setCachedInProgressAudiobooks(result);
|
||||||
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('❌ Failed to fetch in-progress audiobooks: $e');
|
_logger.log('❌ Failed to fetch in-progress audiobooks: $e');
|
||||||
|
// Fallback to cache on error
|
||||||
|
final cached = _cacheService.getCachedInProgressAudiobooks();
|
||||||
|
if (cached != null && cached.isNotEmpty) return filterByProvider(cached);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get random audiobooks for discovery
|
/// Get cached in-progress audiobooks synchronously (for instant display, with filtering)
|
||||||
Future<List<Audiobook>> getDiscoverAudiobooks() async {
|
List<Audiobook>? getCachedInProgressAudiobooks() {
|
||||||
if (_api == null) return [];
|
final cached = _cacheService.getCachedInProgressAudiobooks();
|
||||||
|
return cached != null ? filterByProvider(cached) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get random audiobooks for discovery with caching
|
||||||
|
Future<List<Audiobook>> getDiscoverAudiobooksWithCache({bool forceRefresh = false}) async {
|
||||||
|
// Check cache first
|
||||||
|
if (!forceRefresh && _cacheService.isDiscoverAudiobooksCacheValid()) {
|
||||||
|
_logger.log('📦 Using cached discover audiobooks');
|
||||||
|
return filterByProvider(_cacheService.getCachedDiscoverAudiobooks()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_api == null) {
|
||||||
|
// Fallback to cache when offline
|
||||||
|
final cached = _cacheService.getCachedDiscoverAudiobooks();
|
||||||
|
if (cached != null && cached.isNotEmpty) return filterByProvider(cached);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_logger.log('📚 Fetching discover audiobooks...');
|
_logger.log('📚 Fetching discover audiobooks...');
|
||||||
final allAudiobooks = filterByProvider(await _api!.getAudiobooks());
|
final allAudiobooks = filterByProvider(await _api!.getAudiobooks());
|
||||||
// Shuffle and take a subset
|
// Shuffle and take a subset
|
||||||
final shuffled = List<Audiobook>.from(allAudiobooks)..shuffle();
|
final shuffled = List<Audiobook>.from(allAudiobooks)..shuffle();
|
||||||
|
final result = shuffled.take(20).toList();
|
||||||
_logger.log('📚 Found ${allAudiobooks.length} total audiobooks, returning random selection');
|
_logger.log('📚 Found ${allAudiobooks.length} total audiobooks, returning random selection');
|
||||||
return shuffled.take(20).toList();
|
_cacheService.setCachedDiscoverAudiobooks(result);
|
||||||
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('❌ Failed to fetch discover audiobooks: $e');
|
_logger.log('❌ Failed to fetch discover audiobooks: $e');
|
||||||
|
// Fallback to cache on error
|
||||||
|
final cached = _cacheService.getCachedDiscoverAudiobooks();
|
||||||
|
if (cached != null && cached.isNotEmpty) return filterByProvider(cached);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get random series for discovery
|
/// Get cached discover audiobooks synchronously (for instant display, with filtering)
|
||||||
Future<List<AudiobookSeries>> getDiscoverSeries() async {
|
List<Audiobook>? getCachedDiscoverAudiobooks() {
|
||||||
if (_api == null) return [];
|
final cached = _cacheService.getCachedDiscoverAudiobooks();
|
||||||
|
return cached != null ? filterByProvider(cached) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get random series for discovery with caching
|
||||||
|
Future<List<AudiobookSeries>> getDiscoverSeriesWithCache({bool forceRefresh = false}) async {
|
||||||
|
// Check cache first
|
||||||
|
if (!forceRefresh && _cacheService.isDiscoverSeriesCacheValid()) {
|
||||||
|
_logger.log('📦 Using cached discover series');
|
||||||
|
return _cacheService.getCachedDiscoverSeries()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_api == null) {
|
||||||
|
// Fallback to cache when offline
|
||||||
|
final cached = _cacheService.getCachedDiscoverSeries();
|
||||||
|
if (cached != null && cached.isNotEmpty) return cached;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_logger.log('📚 Fetching discover series...');
|
_logger.log('📚 Fetching discover series...');
|
||||||
final allSeries = await _api!.getAudiobookSeries();
|
final allSeries = await _api!.getAudiobookSeries();
|
||||||
// Shuffle and take a subset
|
// Shuffle and take a subset
|
||||||
final shuffled = List<AudiobookSeries>.from(allSeries)..shuffle();
|
final shuffled = List<AudiobookSeries>.from(allSeries)..shuffle();
|
||||||
|
final result = shuffled.take(20).toList();
|
||||||
_logger.log('📚 Found ${allSeries.length} total series, returning random selection');
|
_logger.log('📚 Found ${allSeries.length} total series, returning random selection');
|
||||||
return shuffled.take(20).toList();
|
_cacheService.setCachedDiscoverSeries(result);
|
||||||
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('❌ Failed to fetch discover series: $e');
|
_logger.log('❌ Failed to fetch discover series: $e');
|
||||||
|
// Fallback to cache on error
|
||||||
|
final cached = _cacheService.getCachedDiscoverSeries();
|
||||||
|
if (cached != null && cached.isNotEmpty) return cached;
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get cached discover series synchronously (for instant display)
|
||||||
|
List<AudiobookSeries>? getCachedDiscoverSeries() => _cacheService.getCachedDiscoverSeries();
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// DETAIL SCREEN CACHING
|
// DETAIL SCREEN CACHING
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -3990,6 +4197,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_currentAudiobook = null;
|
_currentAudiobook = null;
|
||||||
stateChanged = true;
|
stateChanged = true;
|
||||||
}
|
}
|
||||||
|
if (_currentPodcastName != null) {
|
||||||
|
_logger.log('🎙️ External source active, clearing podcast context');
|
||||||
|
_currentPodcastName = null;
|
||||||
|
stateChanged = true;
|
||||||
|
}
|
||||||
// Clear notification for external source
|
// Clear notification for external source
|
||||||
audioHandler.clearRemotePlaybackState();
|
audioHandler.clearRemotePlaybackState();
|
||||||
if (stateChanged) {
|
if (stateChanged) {
|
||||||
@@ -4031,6 +4243,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_currentAudiobook = null;
|
_currentAudiobook = null;
|
||||||
stateChanged = true;
|
stateChanged = true;
|
||||||
}
|
}
|
||||||
|
// Clear podcast context when playback stops
|
||||||
|
if (_currentPodcastName != null) {
|
||||||
|
_logger.log('🎙️ Playback stopped, clearing podcast context');
|
||||||
|
_currentPodcastName = null;
|
||||||
|
stateChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (stateChanged) {
|
if (stateChanged) {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -4299,10 +4517,15 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch tracks from API (not cached - too many items)
|
// Fetch tracks from API (not cached - too many items)
|
||||||
|
// Apply provider filter if a single provider is selected
|
||||||
if (_api != null) {
|
if (_api != null) {
|
||||||
try {
|
try {
|
||||||
_tracks = await _api!.getTracks(limit: LibraryConstants.maxLibraryItems);
|
final providerFilter = selectedProviderForFilter;
|
||||||
_logger.log('📥 Fetched ${_tracks.length} tracks from MA');
|
_tracks = await _api!.getTracks(
|
||||||
|
limit: LibraryConstants.maxLibraryItems,
|
||||||
|
provider: providerFilter,
|
||||||
|
);
|
||||||
|
_logger.log('📥 Fetched ${_tracks.length} tracks from MA (provider=$providerFilter)');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('⚠️ Failed to fetch tracks: $e');
|
_logger.log('⚠️ Failed to fetch tracks: $e');
|
||||||
}
|
}
|
||||||
@@ -4328,6 +4551,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
if (_api == null) return;
|
if (_api == null) return;
|
||||||
|
|
||||||
final syncService = SyncService.instance;
|
final syncService = SyncService.instance;
|
||||||
|
final providerFilter = selectedProviderForFilter;
|
||||||
|
_logger.log('🔄 _syncLibraryInBackground starting with providerFilter=$providerFilter');
|
||||||
|
|
||||||
// Listen for sync completion to update our lists
|
// Listen for sync completion to update our lists
|
||||||
void onSyncComplete() {
|
void onSyncComplete() {
|
||||||
@@ -4341,18 +4566,18 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
syncService.addListener(onSyncComplete);
|
syncService.addListener(onSyncComplete);
|
||||||
await syncService.syncFromApi(_api!);
|
await syncService.syncFromApi(_api!, provider: providerFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load radio stations from the library
|
/// Load radio stations from the library
|
||||||
Future<void> loadRadioStations() async {
|
Future<void> loadRadioStations({String? orderBy}) async {
|
||||||
if (!isConnected || _api == null) return;
|
if (!isConnected || _api == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_isLoadingRadio = true;
|
_isLoadingRadio = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
_radioStations = await _api!.getRadioStations(limit: 100);
|
_radioStations = await _api!.getRadioStations(limit: 100, orderBy: orderBy);
|
||||||
_isLoadingRadio = false;
|
_isLoadingRadio = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -4362,14 +4587,14 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadPodcasts() async {
|
Future<void> loadPodcasts({String? orderBy}) async {
|
||||||
if (!isConnected || _api == null) return;
|
if (!isConnected || _api == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_isLoadingPodcasts = true;
|
_isLoadingPodcasts = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
_podcasts = await _api!.getPodcasts(limit: 100);
|
_podcasts = await _api!.getPodcasts(limit: 100, orderBy: orderBy);
|
||||||
|
|
||||||
_logger.log('🎙️ Loaded ${_podcasts.length} podcasts');
|
_logger.log('🎙️ Loaded ${_podcasts.length} podcasts');
|
||||||
_isLoadingPodcasts = false;
|
_isLoadingPodcasts = false;
|
||||||
@@ -4412,7 +4637,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadArtists({int? limit, int? offset, String? search}) async {
|
Future<void> loadArtists({int? limit, int? offset, String? search, String? orderBy}) async {
|
||||||
if (!isConnected) return;
|
if (!isConnected) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -4425,6 +4650,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
offset: offset,
|
offset: offset,
|
||||||
search: search,
|
search: search,
|
||||||
albumArtistsOnly: false, // Show ALL library artists, not just those with albums
|
albumArtistsOnly: false, // Show ALL library artists, not just those with albums
|
||||||
|
orderBy: orderBy,
|
||||||
);
|
);
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -4442,6 +4668,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
int? offset,
|
int? offset,
|
||||||
String? search,
|
String? search,
|
||||||
String? artistId,
|
String? artistId,
|
||||||
|
String? orderBy,
|
||||||
}) async {
|
}) async {
|
||||||
if (!isConnected) return;
|
if (!isConnected) return;
|
||||||
|
|
||||||
@@ -4455,6 +4682,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
offset: offset,
|
offset: offset,
|
||||||
search: search,
|
search: search,
|
||||||
artistId: artistId,
|
artistId: artistId,
|
||||||
|
orderBy: orderBy,
|
||||||
);
|
);
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -4493,10 +4721,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get playlists with provider filtering applied
|
/// Get playlists with provider filtering applied
|
||||||
Future<List<Playlist>> getPlaylists({int? limit, bool? favoriteOnly}) async {
|
Future<List<Playlist>> getPlaylists({int? limit, bool? favoriteOnly, String? orderBy}) async {
|
||||||
if (_api == null) return [];
|
if (_api == null) return [];
|
||||||
try {
|
try {
|
||||||
final playlists = await _api!.getPlaylists(limit: limit, favoriteOnly: favoriteOnly);
|
final playlists = await _api!.getPlaylists(limit: limit, favoriteOnly: favoriteOnly, orderBy: orderBy);
|
||||||
return filterByProvider(playlists);
|
return filterByProvider(playlists);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('❌ Failed to fetch playlists: $e');
|
_logger.log('❌ Failed to fetch playlists: $e');
|
||||||
|
|||||||
@@ -1,964 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/painting.dart';
|
|
||||||
import '../models/media_item.dart';
|
|
||||||
import '../models/player.dart';
|
|
||||||
import '../services/music_assistant_api.dart';
|
|
||||||
import '../services/settings_service.dart';
|
|
||||||
import '../services/debug_logger.dart';
|
|
||||||
import '../services/error_handler.dart';
|
|
||||||
import '../services/local_player_service.dart';
|
|
||||||
import '../services/auth/auth_manager.dart';
|
|
||||||
import '../services/device_id_service.dart';
|
|
||||||
import '../services/cache_service.dart';
|
|
||||||
import '../constants/timings.dart';
|
|
||||||
import '../main.dart' show audioHandler;
|
|
||||||
|
|
||||||
/// Provider for managing player state, selection, and controls
|
|
||||||
class PlayerProvider with ChangeNotifier {
|
|
||||||
final DebugLogger _logger = DebugLogger();
|
|
||||||
late final LocalPlayerService _localPlayer;
|
|
||||||
final CacheService _cacheService;
|
|
||||||
|
|
||||||
// Dependencies injected from parent provider
|
|
||||||
MusicAssistantAPI? _api;
|
|
||||||
AuthManager? _authManager;
|
|
||||||
String? _serverUrl;
|
|
||||||
|
|
||||||
// Player selection
|
|
||||||
Player? _selectedPlayer;
|
|
||||||
List<Player> _availablePlayers = [];
|
|
||||||
Track? _currentTrack;
|
|
||||||
Timer? _playerStateTimer;
|
|
||||||
Timer? _adjacentPlayerCacheTimer;
|
|
||||||
|
|
||||||
// Local Playback
|
|
||||||
bool _isLocalPlayerPowered = true;
|
|
||||||
StreamSubscription? _localPlayerEventSubscription;
|
|
||||||
StreamSubscription? _playerUpdatedEventSubscription;
|
|
||||||
Timer? _localPlayerStateReportTimer;
|
|
||||||
|
|
||||||
// Pending metadata from player_updated events
|
|
||||||
TrackMetadata? _pendingTrackMetadata;
|
|
||||||
TrackMetadata? _currentNotificationMetadata;
|
|
||||||
|
|
||||||
// Registration guard
|
|
||||||
Completer<void>? _registrationInProgress;
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
Player? get selectedPlayer => _selectedPlayer;
|
|
||||||
List<Player> get availablePlayers => _availablePlayers;
|
|
||||||
Track? get currentTrack => _currentTrack;
|
|
||||||
bool get isLocalPlayerPowered => _isLocalPlayerPowered;
|
|
||||||
|
|
||||||
/// Get cached track for a player (used for smooth swipe transitions)
|
|
||||||
Track? getCachedTrackForPlayer(String playerId) => _cacheService.getCachedTrackForPlayer(playerId);
|
|
||||||
|
|
||||||
PlayerProvider(this._cacheService, AuthManager authManager) {
|
|
||||||
_authManager = authManager;
|
|
||||||
_localPlayer = LocalPlayerService(authManager);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initialize with API reference after connection
|
|
||||||
void initialize(MusicAssistantAPI api, String serverUrl) {
|
|
||||||
_api = api;
|
|
||||||
_serverUrl = serverUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set up event listeners after connection
|
|
||||||
Future<void> setupEventListeners() async {
|
|
||||||
if (_api == null) return;
|
|
||||||
|
|
||||||
// Listen to built-in player events
|
|
||||||
_localPlayerEventSubscription?.cancel();
|
|
||||||
_localPlayerEventSubscription = _api!.builtinPlayerEvents.listen(
|
|
||||||
_handleLocalPlayerEvent,
|
|
||||||
onError: (error) {
|
|
||||||
_logger.log('Builtin player event stream error: $error');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Listen to player_updated events
|
|
||||||
_playerUpdatedEventSubscription?.cancel();
|
|
||||||
_playerUpdatedEventSubscription = _api!.playerUpdatedEvents.listen(
|
|
||||||
_handlePlayerUpdatedEvent,
|
|
||||||
onError: (error) {
|
|
||||||
_logger.log('Player updated event stream error: $error');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initialize local playback
|
|
||||||
Future<void> initializeLocalPlayback() async {
|
|
||||||
await _localPlayer.initialize();
|
|
||||||
_isLocalPlayerPowered = true;
|
|
||||||
|
|
||||||
// Wire up skip button callbacks
|
|
||||||
audioHandler.onSkipToNext = () {
|
|
||||||
_logger.log('🎵 Notification: Skip to next pressed');
|
|
||||||
nextTrackSelectedPlayer();
|
|
||||||
};
|
|
||||||
audioHandler.onSkipToPrevious = () {
|
|
||||||
_logger.log('🎵 Notification: Skip to previous pressed');
|
|
||||||
previousTrackSelectedPlayer();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (_api != null) {
|
|
||||||
await registerLocalPlayer();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to adopt an existing ghost player
|
|
||||||
Future<bool> tryAdoptGhostPlayer() async {
|
|
||||||
if (_api == null) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final isFresh = await DeviceIdService.isFreshInstallation();
|
|
||||||
if (!isFresh) {
|
|
||||||
_logger.log('👻 Not a fresh install, skipping ghost adoption');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
final ownerName = await SettingsService.getOwnerName();
|
|
||||||
if (ownerName == null || ownerName.isEmpty) {
|
|
||||||
_logger.log('👻 No owner name set, cannot adopt ghost player');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.log('👻 Fresh install detected, searching for adoptable ghost for "$ownerName"...');
|
|
||||||
final adoptableId = await _api!.findAdoptableGhostPlayer(ownerName);
|
|
||||||
if (adoptableId == null) {
|
|
||||||
_logger.log('👻 No matching ghost player found - will generate new ID');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.log('👻 Found adoptable ghost: $adoptableId');
|
|
||||||
await DeviceIdService.adoptPlayerId(adoptableId);
|
|
||||||
|
|
||||||
_logger.log('✅ Successfully adopted ghost player');
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('⚠️ Ghost adoption failed (non-fatal): $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register local player with MA server
|
|
||||||
Future<void> registerLocalPlayer() async {
|
|
||||||
if (_api == null) return;
|
|
||||||
|
|
||||||
if (_registrationInProgress != null) {
|
|
||||||
_logger.log('⏳ Registration already in progress, waiting...');
|
|
||||||
return _registrationInProgress!.future;
|
|
||||||
}
|
|
||||||
|
|
||||||
_registrationInProgress = Completer<void>();
|
|
||||||
|
|
||||||
try {
|
|
||||||
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
|
|
||||||
_logger.log('🆔 Using player ID: $playerId');
|
|
||||||
|
|
||||||
await SettingsService.setBuiltinPlayerId(playerId);
|
|
||||||
final name = await SettingsService.getLocalPlayerName();
|
|
||||||
|
|
||||||
final existingPlayers = await _api!.getPlayers();
|
|
||||||
final existingPlayer = existingPlayers.where((p) => p.playerId == playerId).firstOrNull;
|
|
||||||
|
|
||||||
if (existingPlayer != null && existingPlayer.available) {
|
|
||||||
_logger.log('✅ Player already registered and available: $playerId');
|
|
||||||
_startReportingLocalPlayerState();
|
|
||||||
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
|
|
||||||
_registrationInProgress!.complete();
|
|
||||||
}
|
|
||||||
_registrationInProgress = null;
|
|
||||||
return;
|
|
||||||
} else if (existingPlayer != null && !existingPlayer.available) {
|
|
||||||
_logger.log('⚠️ Player exists but unavailable (stale), re-registering: $playerId');
|
|
||||||
} else {
|
|
||||||
_logger.log('🆔 Player not found in MA, registering as new');
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.log('🎵 Registering player with MA: id=$playerId, name=$name');
|
|
||||||
await _api!.registerBuiltinPlayer(playerId, name);
|
|
||||||
|
|
||||||
_logger.log('✅ Player registration complete');
|
|
||||||
_startReportingLocalPlayerState();
|
|
||||||
|
|
||||||
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
|
|
||||||
_registrationInProgress!.complete();
|
|
||||||
}
|
|
||||||
_registrationInProgress = null;
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('❌ CRITICAL: Player registration failed: $e');
|
|
||||||
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
|
|
||||||
_registrationInProgress!.completeError(e);
|
|
||||||
}
|
|
||||||
_registrationInProgress = null;
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _startReportingLocalPlayerState() {
|
|
||||||
_localPlayerStateReportTimer?.cancel();
|
|
||||||
_localPlayerStateReportTimer = Timer.periodic(Timings.localPlayerReportInterval, (_) async {
|
|
||||||
try {
|
|
||||||
await _reportLocalPlayerState();
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Error reporting local player state (will retry): $e');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _reportLocalPlayerState() async {
|
|
||||||
if (_api == null) return;
|
|
||||||
|
|
||||||
// Don't try to report state if not authenticated - avoids spamming errors
|
|
||||||
if (_api!.currentConnectionState != MAConnectionState.authenticated) return;
|
|
||||||
|
|
||||||
final playerId = await SettingsService.getBuiltinPlayerId();
|
|
||||||
if (playerId == null) return;
|
|
||||||
|
|
||||||
final isPlaying = _localPlayer.isPlaying;
|
|
||||||
final position = _localPlayer.position.inSeconds;
|
|
||||||
final volume = (_localPlayer.volume * 100).round();
|
|
||||||
final isPaused = !isPlaying && position > 0;
|
|
||||||
|
|
||||||
await _api!.updateBuiltinPlayerState(
|
|
||||||
playerId,
|
|
||||||
powered: _isLocalPlayerPowered,
|
|
||||||
playing: isPlaying,
|
|
||||||
paused: isPaused,
|
|
||||||
position: position,
|
|
||||||
volume: volume,
|
|
||||||
muted: _localPlayer.volume == 0.0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleLocalPlayerEvent(Map<String, dynamic> event) async {
|
|
||||||
_logger.log('📥 Local player event received: ${event['type'] ?? event['command']}');
|
|
||||||
|
|
||||||
try {
|
|
||||||
final eventPlayerId = event['player_id'] as String?;
|
|
||||||
final myPlayerId = await SettingsService.getBuiltinPlayerId();
|
|
||||||
|
|
||||||
if (eventPlayerId != null && myPlayerId != null && eventPlayerId != myPlayerId) {
|
|
||||||
_logger.log('🚫 Ignoring event for different player: $eventPlayerId');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final command = (event['type'] as String?) ?? (event['command'] as String?);
|
|
||||||
|
|
||||||
switch (command) {
|
|
||||||
case 'play_media':
|
|
||||||
final urlPath = event['media_url'] as String? ?? event['url'] as String?;
|
|
||||||
|
|
||||||
if (urlPath != null && _serverUrl != null) {
|
|
||||||
String fullUrl;
|
|
||||||
if (urlPath.startsWith('http')) {
|
|
||||||
fullUrl = urlPath;
|
|
||||||
} else {
|
|
||||||
var baseUrl = _serverUrl!;
|
|
||||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
|
||||||
baseUrl = 'https://$baseUrl';
|
|
||||||
}
|
|
||||||
baseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
|
|
||||||
final path = urlPath.startsWith('/') ? urlPath : '/$urlPath';
|
|
||||||
fullUrl = '$baseUrl$path';
|
|
||||||
}
|
|
||||||
|
|
||||||
TrackMetadata metadata;
|
|
||||||
if (_pendingTrackMetadata != null) {
|
|
||||||
metadata = _pendingTrackMetadata!;
|
|
||||||
} else {
|
|
||||||
final trackName = event['track_name'] as String? ?? event['name'] as String? ?? 'Unknown Track';
|
|
||||||
final artistName = event['artist_name'] as String? ?? event['artist'] as String? ?? 'Unknown Artist';
|
|
||||||
final albumName = event['album_name'] as String? ?? event['album'] as String?;
|
|
||||||
var artworkUrl = event['image_url'] as String? ?? event['artwork_url'] as String?;
|
|
||||||
final durationSecs = event['duration'] as int?;
|
|
||||||
|
|
||||||
if (artworkUrl != null && artworkUrl.startsWith('http://')) {
|
|
||||||
artworkUrl = artworkUrl.replaceFirst('http://', 'https://');
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata = TrackMetadata(
|
|
||||||
title: trackName,
|
|
||||||
artist: artistName,
|
|
||||||
album: albumName,
|
|
||||||
artworkUrl: artworkUrl,
|
|
||||||
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
_localPlayer.setCurrentTrackMetadata(metadata);
|
|
||||||
_currentNotificationMetadata = metadata;
|
|
||||||
await _localPlayer.playUrl(fullUrl);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'stop':
|
|
||||||
await _localPlayer.stop();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'pause':
|
|
||||||
await _localPlayer.pause();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'play':
|
|
||||||
await _localPlayer.play();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'seek':
|
|
||||||
final position = event['position'] as int?;
|
|
||||||
if (position != null) {
|
|
||||||
await _localPlayer.seek(Duration(seconds: position));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'volume_set':
|
|
||||||
final volume = event['volume_level'] as int?;
|
|
||||||
if (volume != null) {
|
|
||||||
await _localPlayer.setVolume(volume / 100.0);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'power_on':
|
|
||||||
case 'power_off':
|
|
||||||
case 'power':
|
|
||||||
bool? newPowerState;
|
|
||||||
if (command == 'power_on') {
|
|
||||||
newPowerState = true;
|
|
||||||
} else if (command == 'power_off') {
|
|
||||||
newPowerState = false;
|
|
||||||
} else {
|
|
||||||
newPowerState = event['powered'] as bool?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newPowerState != null) {
|
|
||||||
_isLocalPlayerPowered = newPowerState;
|
|
||||||
if (!_isLocalPlayerPowered) {
|
|
||||||
await _localPlayer.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _reportLocalPlayerState();
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Error handling local player event: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handlePlayerUpdatedEvent(Map<String, dynamic> event) async {
|
|
||||||
try {
|
|
||||||
final playerId = event['player_id'] as String?;
|
|
||||||
if (playerId == null) return;
|
|
||||||
|
|
||||||
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
|
|
||||||
_updatePlayerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
final currentMedia = event['current_media'] as Map<String, dynamic>?;
|
|
||||||
final playerName = event['name'] as String? ?? playerId;
|
|
||||||
|
|
||||||
if (currentMedia != null) {
|
|
||||||
final mediaType = currentMedia['media_type'] as String?;
|
|
||||||
if (mediaType != 'flow_stream') {
|
|
||||||
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
|
|
||||||
final albumName = currentMedia['album'] as String?;
|
|
||||||
final imageUrl = currentMedia['image_url'] as String?;
|
|
||||||
|
|
||||||
Map<String, dynamic>? metadata;
|
|
||||||
if (imageUrl != null && _serverUrl != null) {
|
|
||||||
var finalImageUrl = imageUrl;
|
|
||||||
try {
|
|
||||||
final imgUri = Uri.parse(imageUrl);
|
|
||||||
final queryString = imgUri.query;
|
|
||||||
var baseUrl = _serverUrl!;
|
|
||||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
|
||||||
baseUrl = 'https://$baseUrl';
|
|
||||||
}
|
|
||||||
if (baseUrl.endsWith('/')) {
|
|
||||||
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
|
|
||||||
}
|
|
||||||
finalImageUrl = '$baseUrl/imageproxy?$queryString';
|
|
||||||
} catch (e) {
|
|
||||||
// Use original URL
|
|
||||||
}
|
|
||||||
metadata = {
|
|
||||||
'images': [{'path': finalImageUrl, 'provider': 'direct'}]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
final trackFromEvent = Track(
|
|
||||||
itemId: currentMedia['queue_item_id'] as String? ?? '',
|
|
||||||
provider: 'library',
|
|
||||||
name: currentMedia['title'] as String? ?? 'Unknown Track',
|
|
||||||
uri: currentMedia['uri'] as String?,
|
|
||||||
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
|
||||||
artists: [Artist(itemId: '', provider: 'library', name: currentMedia['artist'] as String? ?? 'Unknown Artist')],
|
|
||||||
album: albumName != null ? Album(itemId: '', provider: 'library', name: albumName) : null,
|
|
||||||
metadata: metadata,
|
|
||||||
);
|
|
||||||
|
|
||||||
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
|
|
||||||
|
|
||||||
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
|
|
||||||
_currentTrack = trackFromEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.log('📋 Cached track for $playerName: ${trackFromEvent.name}');
|
|
||||||
|
|
||||||
if (imageUrl != null) {
|
|
||||||
final images = metadata?['images'] as List?;
|
|
||||||
if (images != null && images.isNotEmpty) {
|
|
||||||
final imagePath = images[0]['path'] as String?;
|
|
||||||
if (imagePath != null) {
|
|
||||||
_precacheImage(imagePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle notification metadata for local player
|
|
||||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
|
||||||
if (builtinPlayerId == null || playerId != builtinPlayerId) return;
|
|
||||||
|
|
||||||
if (currentMedia == null) return;
|
|
||||||
|
|
||||||
final title = currentMedia['title'] as String? ?? 'Unknown Track';
|
|
||||||
final artist = currentMedia['artist'] as String? ?? 'Unknown Artist';
|
|
||||||
final album = currentMedia['album'] as String?;
|
|
||||||
var imageUrl = currentMedia['image_url'] as String?;
|
|
||||||
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
|
|
||||||
|
|
||||||
if (imageUrl != null && _serverUrl != null) {
|
|
||||||
try {
|
|
||||||
final imgUri = Uri.parse(imageUrl);
|
|
||||||
final queryString = imgUri.query;
|
|
||||||
var baseUrl = _serverUrl!;
|
|
||||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
|
||||||
baseUrl = 'https://$baseUrl';
|
|
||||||
}
|
|
||||||
if (baseUrl.endsWith('/')) {
|
|
||||||
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
|
|
||||||
}
|
|
||||||
imageUrl = '$baseUrl/imageproxy?$queryString';
|
|
||||||
} catch (e) {
|
|
||||||
if (imageUrl != null && imageUrl.startsWith('http://')) {
|
|
||||||
imageUrl = imageUrl.replaceFirst('http://', 'https://');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final newMetadata = TrackMetadata(
|
|
||||||
title: title,
|
|
||||||
artist: artist,
|
|
||||||
album: album,
|
|
||||||
artworkUrl: imageUrl,
|
|
||||||
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
|
||||||
);
|
|
||||||
|
|
||||||
final mediaType = currentMedia['media_type'] as String?;
|
|
||||||
if (mediaType == 'flow_stream') return;
|
|
||||||
|
|
||||||
_pendingTrackMetadata = newMetadata;
|
|
||||||
|
|
||||||
final notificationNeedsUpdate = _currentNotificationMetadata != null &&
|
|
||||||
(_currentNotificationMetadata!.title != title ||
|
|
||||||
_currentNotificationMetadata!.artist != artist);
|
|
||||||
|
|
||||||
if (_localPlayer.isPlaying && notificationNeedsUpdate) {
|
|
||||||
await _localPlayer.updateNotificationWhilePlaying(newMetadata);
|
|
||||||
_currentNotificationMetadata = newMetadata;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Error handling player_updated event: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// PLAYER SELECTION
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
Future<void> loadAndSelectPlayers({bool forceRefresh = false}) async {
|
|
||||||
if (_api == null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!forceRefresh && _cacheService.isPlayersCacheValid() && _availablePlayers.isNotEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final allPlayers = await _api!.getPlayers();
|
|
||||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
|
||||||
|
|
||||||
_logger.log('🎛️ getPlayers returned ${allPlayers.length} players');
|
|
||||||
|
|
||||||
int filteredCount = 0;
|
|
||||||
|
|
||||||
_availablePlayers = allPlayers.where((player) {
|
|
||||||
final nameLower = player.name.toLowerCase();
|
|
||||||
|
|
||||||
if (nameLower.contains('music assistant mobile')) {
|
|
||||||
filteredCount++;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out MA Web UI's built-in player (provider is 'builtin_player' and starts with 'ma_')
|
|
||||||
// Note: We check BOTH conditions to avoid filtering snapcast/other players that may have 'ma_' prefix
|
|
||||||
if (player.provider == 'builtin_player' && player.playerId.startsWith('ma_')) {
|
|
||||||
filteredCount++;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also filter "This Device" named players without proper provider
|
|
||||||
if (nameLower == 'this device') {
|
|
||||||
filteredCount++;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (player.playerId.startsWith('ensemble_')) {
|
|
||||||
if (builtinPlayerId == null || player.playerId != builtinPlayerId) {
|
|
||||||
filteredCount++;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!player.available) {
|
|
||||||
if (builtinPlayerId != null && player.playerId == builtinPlayerId) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
filteredCount++;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
_availablePlayers.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
|
||||||
_cacheService.updatePlayersLastFetched();
|
|
||||||
|
|
||||||
_logger.log('🎛️ After filtering: ${_availablePlayers.length} players available');
|
|
||||||
|
|
||||||
if (_availablePlayers.isNotEmpty) {
|
|
||||||
Player? playerToSelect;
|
|
||||||
|
|
||||||
if (_selectedPlayer != null) {
|
|
||||||
final stillAvailable = _availablePlayers.any(
|
|
||||||
(p) => p.playerId == _selectedPlayer!.playerId && p.available,
|
|
||||||
);
|
|
||||||
if (stillAvailable) {
|
|
||||||
playerToSelect = _availablePlayers.firstWhere(
|
|
||||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playerToSelect == null) {
|
|
||||||
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
|
||||||
|
|
||||||
// Priority 1: Last selected player
|
|
||||||
if (lastSelectedPlayerId != null) {
|
|
||||||
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
|
||||||
(p) => p!.playerId == lastSelectedPlayerId && p.available,
|
|
||||||
orElse: () => null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 2: Built-in (local) player
|
|
||||||
if (playerToSelect == null && builtinPlayerId != null) {
|
|
||||||
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
|
||||||
(p) => p!.playerId == builtinPlayerId && p.available,
|
|
||||||
orElse: () => null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 3: Currently playing player (exclude external sources - not playing MA content)
|
|
||||||
if (playerToSelect == null) {
|
|
||||||
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
|
||||||
(p) => p!.state == 'playing' && p.available && !p.isExternalSource,
|
|
||||||
orElse: () => null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playerToSelect == null) {
|
|
||||||
playerToSelect = _availablePlayers.firstWhere(
|
|
||||||
(p) => p.available,
|
|
||||||
orElse: () => _availablePlayers.first,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
selectPlayer(playerToSelect);
|
|
||||||
}
|
|
||||||
|
|
||||||
_preloadAdjacentPlayers(preloadAll: true);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Load and select players', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void selectPlayer(Player player, {bool skipNotify = false}) {
|
|
||||||
_selectedPlayer = player;
|
|
||||||
SettingsService.setLastSelectedPlayerId(player.playerId);
|
|
||||||
_startPlayerStatePolling();
|
|
||||||
_preloadAdjacentPlayers();
|
|
||||||
|
|
||||||
if (!skipNotify) {
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _preloadAdjacentPlayers({bool preloadAll = false}) async {
|
|
||||||
if (_api == null) return;
|
|
||||||
|
|
||||||
final players = _availablePlayers.where((p) => p.available).toList();
|
|
||||||
if (players.isEmpty) return;
|
|
||||||
|
|
||||||
if (preloadAll) {
|
|
||||||
_logger.log('🖼️ Preloading track info for all ${players.length} players...');
|
|
||||||
await Future.wait(players.map((player) => _preloadPlayerTrack(player)));
|
|
||||||
_logger.log('🖼️ Preloading complete');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_selectedPlayer == null || players.length <= 1) return;
|
|
||||||
|
|
||||||
final currentIndex = players.indexWhere((p) => p.playerId == _selectedPlayer!.playerId);
|
|
||||||
if (currentIndex == -1) return;
|
|
||||||
|
|
||||||
final prevIndex = currentIndex <= 0 ? players.length - 1 : currentIndex - 1;
|
|
||||||
final nextIndex = currentIndex >= players.length - 1 ? 0 : currentIndex + 1;
|
|
||||||
|
|
||||||
final playersToPreload = <Player>{};
|
|
||||||
if (prevIndex != currentIndex) playersToPreload.add(players[prevIndex]);
|
|
||||||
if (nextIndex != currentIndex) playersToPreload.add(players[nextIndex]);
|
|
||||||
|
|
||||||
for (final player in playersToPreload) {
|
|
||||||
_preloadPlayerTrack(player);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _preloadPlayerTrack(Player player) async {
|
|
||||||
if (_api == null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!player.available || !player.powered) {
|
|
||||||
_cacheService.setCachedTrackForPlayer(player.playerId, null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final queue = await _api!.getQueue(player.playerId);
|
|
||||||
|
|
||||||
if (queue != null && queue.currentItem != null) {
|
|
||||||
final track = queue.currentItem!.track;
|
|
||||||
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
|
|
||||||
final existingHasImage = existingTrack?.metadata?['images'] != null;
|
|
||||||
final newHasImage = track.metadata?['images'] != null;
|
|
||||||
|
|
||||||
if (!existingHasImage || newHasImage) {
|
|
||||||
_cacheService.setCachedTrackForPlayer(player.playerId, track);
|
|
||||||
}
|
|
||||||
|
|
||||||
final artworkUrl512 = _api!.getImageUrl(existingHasImage ? existingTrack! : track, size: 512);
|
|
||||||
if (artworkUrl512 != null) {
|
|
||||||
await _precacheImage(artworkUrl512);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
|
|
||||||
if (existingTrack?.metadata?['images'] == null) {
|
|
||||||
_cacheService.setCachedTrackForPlayer(player.playerId, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Error preloading player track for ${player.name}: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _precacheImage(String url) async {
|
|
||||||
try {
|
|
||||||
final imageProvider = NetworkImage(url);
|
|
||||||
final imageStream = imageProvider.resolve(const ImageConfiguration());
|
|
||||||
final completer = Completer<void>();
|
|
||||||
late ImageStreamListener listener;
|
|
||||||
|
|
||||||
listener = ImageStreamListener(
|
|
||||||
(ImageInfo info, bool synchronousCall) {
|
|
||||||
if (!completer.isCompleted) completer.complete();
|
|
||||||
imageStream.removeListener(listener);
|
|
||||||
},
|
|
||||||
onError: (exception, stackTrace) {
|
|
||||||
if (!completer.isCompleted) completer.completeError(exception);
|
|
||||||
imageStream.removeListener(listener);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
imageStream.addListener(listener);
|
|
||||||
await completer.future.timeout(const Duration(seconds: 10), onTimeout: () {
|
|
||||||
imageStream.removeListener(listener);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Image precache failed for $url: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> preloadAllPlayerTracks() async {
|
|
||||||
if (_api == null) return;
|
|
||||||
final players = _availablePlayers.where((p) => p.available).toList();
|
|
||||||
await Future.wait(players.map((player) => _preloadPlayerTrack(player)));
|
|
||||||
}
|
|
||||||
|
|
||||||
void _startPlayerStatePolling() {
|
|
||||||
_playerStateTimer?.cancel();
|
|
||||||
if (_selectedPlayer == null) return;
|
|
||||||
|
|
||||||
_playerStateTimer = Timer.periodic(Timings.playerPollingInterval, (_) async {
|
|
||||||
try {
|
|
||||||
await _updatePlayerState();
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Error updating player state (will retry): $e');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
_updatePlayerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _updatePlayerState() async {
|
|
||||||
if (_selectedPlayer == null || _api == null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
bool stateChanged = false;
|
|
||||||
|
|
||||||
final allPlayers = await _api!.getPlayers();
|
|
||||||
final updatedPlayer = allPlayers.firstWhere(
|
|
||||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
|
||||||
orElse: () => _selectedPlayer!,
|
|
||||||
);
|
|
||||||
|
|
||||||
_selectedPlayer = updatedPlayer;
|
|
||||||
stateChanged = true;
|
|
||||||
|
|
||||||
final isPlayingOrPaused = _selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused';
|
|
||||||
final isIdleWithContent = _selectedPlayer!.state == 'idle' && _selectedPlayer!.powered;
|
|
||||||
final shouldShowTrack = _selectedPlayer!.available && (isPlayingOrPaused || isIdleWithContent);
|
|
||||||
|
|
||||||
if (!shouldShowTrack) {
|
|
||||||
if (_currentTrack != null) {
|
|
||||||
_currentTrack = null;
|
|
||||||
stateChanged = true;
|
|
||||||
}
|
|
||||||
if (stateChanged) notifyListeners();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final queue = await _api!.getQueue(_selectedPlayer!.playerId);
|
|
||||||
|
|
||||||
if (queue != null && queue.currentItem != null) {
|
|
||||||
if (_currentTrack == null ||
|
|
||||||
_currentTrack!.uri != queue.currentItem!.track.uri ||
|
|
||||||
_currentTrack!.name != queue.currentItem!.track.name) {
|
|
||||||
_currentTrack = queue.currentItem!.track;
|
|
||||||
stateChanged = true;
|
|
||||||
|
|
||||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
|
||||||
if (builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId) {
|
|
||||||
final track = _currentTrack!;
|
|
||||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
|
||||||
_localPlayer.updateNotification(
|
|
||||||
id: track.uri ?? track.itemId,
|
|
||||||
title: track.name,
|
|
||||||
artist: track.artistsString,
|
|
||||||
album: track.album?.name,
|
|
||||||
artworkUrl: artworkUrl,
|
|
||||||
duration: track.duration,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (_currentTrack != null) {
|
|
||||||
_currentTrack = null;
|
|
||||||
stateChanged = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stateChanged) notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('❌ Error updating player state: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// PLAYER CONTROLS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
Future<void> playPauseSelectedPlayer() async {
|
|
||||||
if (_selectedPlayer == null || _api == null) return;
|
|
||||||
|
|
||||||
if (_selectedPlayer!.isPlaying) {
|
|
||||||
await _api!.pausePlayer(_selectedPlayer!.playerId);
|
|
||||||
} else {
|
|
||||||
await _api!.resumePlayer(_selectedPlayer!.playerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshPlayers();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> nextTrackSelectedPlayer() async {
|
|
||||||
if (_selectedPlayer == null || _api == null) return;
|
|
||||||
await _api!.nextTrack(_selectedPlayer!.playerId);
|
|
||||||
await Future.delayed(Timings.trackChangeDelay);
|
|
||||||
await _updatePlayerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> previousTrackSelectedPlayer() async {
|
|
||||||
if (_selectedPlayer == null || _api == null) return;
|
|
||||||
await _api!.previousTrack(_selectedPlayer!.playerId);
|
|
||||||
await Future.delayed(Timings.trackChangeDelay);
|
|
||||||
await _updatePlayerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> refreshPlayers() async {
|
|
||||||
final previousState = _selectedPlayer?.state;
|
|
||||||
final previousVolume = _selectedPlayer?.volumeLevel;
|
|
||||||
|
|
||||||
await loadAndSelectPlayers(forceRefresh: true);
|
|
||||||
|
|
||||||
bool stateChanged = false;
|
|
||||||
if (_selectedPlayer != null && _availablePlayers.isNotEmpty) {
|
|
||||||
try {
|
|
||||||
final updatedPlayer = _availablePlayers.firstWhere(
|
|
||||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (updatedPlayer.state != previousState || updatedPlayer.volumeLevel != previousVolume) {
|
|
||||||
stateChanged = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
_selectedPlayer = updatedPlayer;
|
|
||||||
} catch (e) {
|
|
||||||
stateChanged = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stateChanged) notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> togglePower(String playerId) async {
|
|
||||||
if (_api == null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final localPlayerId = await SettingsService.getBuiltinPlayerId();
|
|
||||||
final isLocalPlayer = localPlayerId != null && playerId == localPlayerId;
|
|
||||||
|
|
||||||
if (isLocalPlayer) {
|
|
||||||
_isLocalPlayerPowered = !_isLocalPlayerPowered;
|
|
||||||
if (!_isLocalPlayerPowered) {
|
|
||||||
await _localPlayer.stop();
|
|
||||||
}
|
|
||||||
await _reportLocalPlayerState();
|
|
||||||
await refreshPlayers();
|
|
||||||
} else {
|
|
||||||
final player = _availablePlayers.firstWhere(
|
|
||||||
(p) => p.playerId == playerId,
|
|
||||||
orElse: () => _selectedPlayer != null && _selectedPlayer!.playerId == playerId
|
|
||||||
? _selectedPlayer!
|
|
||||||
: throw Exception("Player not found"),
|
|
||||||
);
|
|
||||||
|
|
||||||
await _api!.setPower(playerId, !player.powered);
|
|
||||||
await refreshPlayers();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Toggle power', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setVolume(String playerId, int volumeLevel) async {
|
|
||||||
if (_api == null) return;
|
|
||||||
try {
|
|
||||||
await _api!.setVolume(playerId, volumeLevel);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Set volume', e);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setMute(String playerId, bool muted) async {
|
|
||||||
if (_api == null) return;
|
|
||||||
try {
|
|
||||||
await _api!.setMute(playerId, muted);
|
|
||||||
await refreshPlayers();
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Set mute', e);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> seek(String playerId, int position) async {
|
|
||||||
if (_api == null) return;
|
|
||||||
try {
|
|
||||||
await _api!.seek(playerId, position);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Seek', e);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> toggleShuffle(String queueId, bool shuffleEnabled) async {
|
|
||||||
if (_api == null) return;
|
|
||||||
try {
|
|
||||||
await _api!.toggleShuffle(queueId, shuffleEnabled);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Toggle shuffle', e);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setRepeatMode(String queueId, String mode) async {
|
|
||||||
if (_api == null) return;
|
|
||||||
try {
|
|
||||||
await _api!.setRepeatMode(queueId, mode);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Set repeat mode', e);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> cycleRepeatMode(String queueId, String? currentMode) async {
|
|
||||||
String nextMode;
|
|
||||||
switch (currentMode) {
|
|
||||||
case 'off':
|
|
||||||
case null:
|
|
||||||
nextMode = 'all';
|
|
||||||
break;
|
|
||||||
case 'all':
|
|
||||||
nextMode = 'one';
|
|
||||||
break;
|
|
||||||
case 'one':
|
|
||||||
nextMode = 'off';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
nextMode = 'off';
|
|
||||||
}
|
|
||||||
await setRepeatMode(queueId, nextMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
void clearState() {
|
|
||||||
_playerStateTimer?.cancel();
|
|
||||||
_playerStateTimer = null;
|
|
||||||
_localPlayerStateReportTimer?.cancel();
|
|
||||||
_localPlayerEventSubscription?.cancel();
|
|
||||||
_playerUpdatedEventSubscription?.cancel();
|
|
||||||
_availablePlayers = [];
|
|
||||||
_selectedPlayer = null;
|
|
||||||
_currentTrack = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
clearState();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,6 +12,7 @@ import '../services/recently_played_service.dart';
|
|||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
import '../widgets/player_picker_sheet.dart';
|
import '../widgets/player_picker_sheet.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../theme/design_tokens.dart';
|
||||||
import 'artist_details_screen.dart';
|
import 'artist_details_screen.dart';
|
||||||
|
|
||||||
class AlbumDetailsScreen extends StatefulWidget {
|
class AlbumDetailsScreen extends StatefulWidget {
|
||||||
@@ -529,12 +530,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
Spacing.vGap16,
|
||||||
Text(
|
Text(
|
||||||
S.of(context)!.addAlbumToQueueOn,
|
S.of(context)!.addAlbumToQueueOn,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
Spacing.vGap16,
|
||||||
if (players.isEmpty)
|
if (players.isEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(32.0),
|
padding: const EdgeInsets.all(32.0),
|
||||||
@@ -609,12 +610,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
Spacing.vGap16,
|
||||||
Text(
|
Text(
|
||||||
S.of(context)!.addToQueueOn,
|
S.of(context)!.addToQueueOn,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
Spacing.vGap16,
|
||||||
if (players.isEmpty)
|
if (players.isEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(32.0),
|
padding: const EdgeInsets.all(32.0),
|
||||||
@@ -902,7 +903,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
Spacing.vGap8,
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
||||||
child: Material(
|
child: Material(
|
||||||
@@ -922,7 +923,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
Spacing.vGap16,
|
||||||
// Album Description
|
// Album Description
|
||||||
if (_albumDescription != null && _albumDescription!.isNotEmpty) ...[
|
if (_albumDescription != null && _albumDescription!.isNotEmpty) ...[
|
||||||
InkWell(
|
InkWell(
|
||||||
@@ -944,7 +945,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
Spacing.vGap8,
|
||||||
],
|
],
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -1015,7 +1016,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(25), // Circular
|
borderRadius: BorderRadius.circular(Radii.xxl), // Circular
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -1193,7 +1194,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(22),
|
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -1216,7 +1217,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
childCount: _tracks.length,
|
childCount: _tracks.length,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player
|
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../theme/design_tokens.dart';
|
||||||
|
|
||||||
class ArtistDetailsScreen extends StatefulWidget {
|
class ArtistDetailsScreen extends StatefulWidget {
|
||||||
final Artist artist;
|
final Artist artist;
|
||||||
@@ -417,12 +418,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
Spacing.vGap16,
|
||||||
Text(
|
Text(
|
||||||
S.of(context)!.startRadioOn(widget.artist.name),
|
S.of(context)!.startRadioOn(widget.artist.name),
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
Spacing.vGap16,
|
||||||
if (players.isEmpty)
|
if (players.isEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(32.0),
|
padding: const EdgeInsets.all(32.0),
|
||||||
@@ -497,12 +498,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
Spacing.vGap16,
|
||||||
Text(
|
Text(
|
||||||
S.of(context)!.addToQueueOn,
|
S.of(context)!.addToQueueOn,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
Spacing.vGap16,
|
||||||
if (players.isEmpty)
|
if (players.isEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(32.0),
|
padding: const EdgeInsets.all(32.0),
|
||||||
@@ -786,7 +787,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
Spacing.vGap16,
|
||||||
if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[
|
if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -807,7 +808,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
Spacing.vGap8,
|
||||||
],
|
],
|
||||||
// Action Buttons Row
|
// Action Buttons Row
|
||||||
Row(
|
Row(
|
||||||
@@ -861,7 +862,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(25),
|
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -897,7 +898,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
Spacing.vGap16,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -947,7 +948,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
onPressed: _toggleSortOrder,
|
onPressed: _toggleSortOrder,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||||
),
|
),
|
||||||
// View mode toggle
|
// View mode toggle
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -968,7 +969,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
onPressed: _cycleViewMode,
|
onPressed: _cycleViewMode,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1004,7 +1005,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
onPressed: _toggleSortOrder,
|
onPressed: _toggleSortOrder,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
@@ -1024,7 +1025,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
onPressed: _cycleViewMode,
|
onPressed: _cycleViewMode,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -1032,7 +1033,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
_buildAlbumSliver(_providerAlbums),
|
_buildAlbumSliver(_providerAlbums),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 140)), // Space for bottom nav + mini player
|
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -1104,7 +1105,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
Spacing.vGap8,
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.albumTitle + (album.uri ?? album.itemId) + '_$heroSuffix',
|
tag: HeroTags.albumTitle + (album.uri ?? album.itemId) + '_$heroSuffix',
|
||||||
child: Material(
|
child: Material(
|
||||||
|
|||||||
@@ -150,10 +150,16 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
|||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
body: CustomScrollView(
|
body: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// Responsive cover size: 70% of screen width, clamped between 160-280 (same as artist detail)
|
||||||
|
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
|
||||||
|
final expandedHeight = coverSize + 100;
|
||||||
|
|
||||||
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 200,
|
expandedHeight: expandedHeight,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
@@ -172,37 +178,41 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
|||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.authorImage + widget.authorName + _heroTagSuffix,
|
tag: HeroTags.authorImage + widget.authorName + _heroTagSuffix,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 200,
|
width: coverSize,
|
||||||
height: 200,
|
height: coverSize,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.primaryContainer,
|
color: colorScheme.primaryContainer,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: ClipOval(
|
child: Stack(
|
||||||
child: _authorImageUrl != null
|
alignment: Alignment.center,
|
||||||
? CachedNetworkImage(
|
children: [
|
||||||
imageUrl: _authorImageUrl!,
|
// Icon always present underneath
|
||||||
fit: BoxFit.cover,
|
Icon(
|
||||||
width: 200,
|
MdiIcons.accountOutline,
|
||||||
height: 200,
|
size: coverSize * 0.5,
|
||||||
fadeInDuration: Duration.zero,
|
color: colorScheme.onPrimaryContainer,
|
||||||
fadeOutDuration: Duration.zero,
|
),
|
||||||
placeholder: (_, __) => Icon(
|
// Image covers icon when loaded
|
||||||
MdiIcons.accountOutline,
|
if (_authorImageUrl != null)
|
||||||
size: 100,
|
SizedBox(
|
||||||
color: colorScheme.onPrimaryContainer,
|
width: coverSize,
|
||||||
|
height: coverSize,
|
||||||
|
child: ClipOval(
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: _authorImageUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: coverSize,
|
||||||
|
height: coverSize,
|
||||||
|
memCacheWidth: 256,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => const SizedBox.shrink(),
|
||||||
|
errorWidget: (_, __, ___) => const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
errorWidget: (_, __, ___) => Icon(
|
|
||||||
MdiIcons.accountOutline,
|
|
||||||
size: 100,
|
|
||||||
color: colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
MdiIcons.accountOutline,
|
|
||||||
size: 100,
|
|
||||||
color: colorScheme.onPrimaryContainer,
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -287,8 +297,10 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
_buildAudiobookSliver(),
|
_buildAudiobookSliver(),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 140)),
|
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../services/debug_logger.dart';
|
|||||||
import '../services/recently_played_service.dart';
|
import '../services/recently_played_service.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';
|
||||||
|
|
||||||
class AudiobookDetailScreen extends StatefulWidget {
|
class AudiobookDetailScreen extends StatefulWidget {
|
||||||
final Audiobook audiobook;
|
final Audiobook audiobook;
|
||||||
@@ -566,7 +567,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
Spacing.vGap8,
|
||||||
|
|
||||||
// Author
|
// Author
|
||||||
Text(
|
Text(
|
||||||
@@ -588,7 +589,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
],
|
],
|
||||||
|
|
||||||
// Duration & Progress
|
// Duration & Progress
|
||||||
const SizedBox(height: 16),
|
Spacing.vGap16,
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (book.duration != null) ...[
|
if (book.duration != null) ...[
|
||||||
@@ -712,7 +713,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(25),
|
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -736,7 +737,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
Spacing.vGap8,
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -767,7 +768,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
Spacing.vGap8,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -777,7 +778,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
// Use MA chapters if available, otherwise use ABS chapters
|
// Use MA chapters if available, otherwise use ABS chapters
|
||||||
..._buildChapterSection(book, colorScheme, textTheme),
|
..._buildChapterSection(book, colorScheme, textTheme),
|
||||||
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 140)),
|
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import 'package:cached_network_image/cached_network_image.dart';
|
|||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../widgets/common/empty_state.dart';
|
import '../widgets/common/empty_state.dart';
|
||||||
|
import '../constants/hero_tags.dart';
|
||||||
|
import '../theme/theme_provider.dart';
|
||||||
|
import '../utils/page_transitions.dart';
|
||||||
import 'playlist_details_screen.dart';
|
import 'playlist_details_screen.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../theme/design_tokens.dart';
|
||||||
|
|
||||||
class LibraryPlaylistsScreen extends StatefulWidget {
|
class LibraryPlaylistsScreen extends StatefulWidget {
|
||||||
const LibraryPlaylistsScreen({super.key});
|
const LibraryPlaylistsScreen({super.key});
|
||||||
@@ -104,34 +108,55 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
// Unique suffix for this context to avoid hero tag conflicts
|
||||||
|
const heroSuffix = '_playlists';
|
||||||
|
|
||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
key: ValueKey(playlist.itemId),
|
key: ValueKey(playlist.itemId),
|
||||||
leading: Container(
|
leading: Hero(
|
||||||
width: 48,
|
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
|
||||||
height: 48,
|
child: ClipRRect(
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceVariant,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
image: imageUrl != null
|
child: Container(
|
||||||
? DecorationImage(
|
width: 48,
|
||||||
image: CachedNetworkImageProvider(imageUrl),
|
height: 48,
|
||||||
fit: BoxFit.cover,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
)
|
child: imageUrl != null
|
||||||
: null,
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 96,
|
||||||
|
memCacheHeight: 96,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => const SizedBox(),
|
||||||
|
errorWidget: (_, __, ___) => Icon(
|
||||||
|
Icons.playlist_play_rounded,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Icons.playlist_play_rounded,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: imageUrl == null
|
|
||||||
? Icon(Icons.playlist_play_rounded, color: colorScheme.onSurfaceVariant)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Hero(
|
||||||
playlist.name,
|
tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + heroSuffix,
|
||||||
style: textTheme.titleMedium?.copyWith(
|
child: Material(
|
||||||
color: colorScheme.onSurface,
|
color: Colors.transparent,
|
||||||
fontWeight: FontWeight.w500,
|
child: Text(
|
||||||
|
playlist.name,
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
playlist.trackCount != null
|
playlist.trackCount != null
|
||||||
@@ -144,16 +169,17 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
trailing: playlist.favorite == true
|
trailing: playlist.favorite == true
|
||||||
? const Icon(Icons.favorite, color: Colors.red, size: 20)
|
? const Icon(Icons.favorite, color: StatusColors.favorite, size: 20)
|
||||||
: null,
|
: null,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
FadeSlidePageRoute(
|
||||||
builder: (context) => PlaylistDetailsScreen(
|
child: PlaylistDetailsScreen(
|
||||||
playlist: playlist,
|
playlist: playlist,
|
||||||
provider: playlist.provider,
|
heroTagSuffix: 'playlists',
|
||||||
itemId: playlist.itemId,
|
initialImageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import '../widgets/audiobook_row.dart';
|
|||||||
import '../widgets/series_row.dart';
|
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/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';
|
||||||
@@ -39,6 +40,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
bool _showFavoriteTracks = false;
|
bool _showFavoriteTracks = false;
|
||||||
bool _showFavoritePlaylists = false;
|
bool _showFavoritePlaylists = false;
|
||||||
bool _showFavoriteRadioStations = false;
|
bool _showFavoriteRadioStations = false;
|
||||||
|
bool _showFavoritePodcasts = false;
|
||||||
// Audiobook rows (default off)
|
// Audiobook rows (default off)
|
||||||
bool _showContinueListeningAudiobooks = false;
|
bool _showContinueListeningAudiobooks = false;
|
||||||
bool _showDiscoverAudiobooks = false;
|
bool _showDiscoverAudiobooks = false;
|
||||||
@@ -49,19 +51,89 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => true;
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
|
// Track if we had empty favorites on first load (to know when to refresh)
|
||||||
|
bool _hadEmptyFavoritesOnLoad = false;
|
||||||
|
SyncStatus? _lastSyncStatus;
|
||||||
|
int _lastArtistCount = 0;
|
||||||
|
MusicAssistantProvider? _providerListeningTo;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
|
// Listen to sync completion to refresh favorite rows when data becomes available
|
||||||
|
SyncService.instance.addListener(_onSyncChanged);
|
||||||
|
_lastSyncStatus = SyncService.instance.status;
|
||||||
|
_checkInitialFavoriteState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
SyncService.instance.removeListener(_onSyncChanged);
|
||||||
|
// Clean up provider listener if attached
|
||||||
|
_providerListeningTo?.removeListener(_onProviderChanged);
|
||||||
|
_providerListeningTo = null;
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if favorites are empty on initial load (so we know to refresh after sync)
|
||||||
|
void _checkInitialFavoriteState() {
|
||||||
|
// Schedule after first frame to ensure provider is accessible
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
_lastArtistCount = provider.artists.length;
|
||||||
|
// Check if artists list is empty - favorites will also be empty
|
||||||
|
if (_lastArtistCount == 0) {
|
||||||
|
_hadEmptyFavoritesOnLoad = true;
|
||||||
|
_logger.log('📋 Home: favorites empty on load, will refresh after sync');
|
||||||
|
// Also listen to provider for when cache loads (before sync completes)
|
||||||
|
_providerListeningTo = provider;
|
||||||
|
provider.addListener(_onProviderChanged);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when MusicAssistantProvider changes (for cache load detection)
|
||||||
|
void _onProviderChanged() {
|
||||||
|
if (!mounted || !_hadEmptyFavoritesOnLoad || _providerListeningTo == null) return;
|
||||||
|
final currentCount = _providerListeningTo!.artists.length;
|
||||||
|
// If artists went from 0 to non-zero, refresh
|
||||||
|
if (_lastArtistCount == 0 && currentCount > 0) {
|
||||||
|
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
|
||||||
|
_hadEmptyFavoritesOnLoad = false;
|
||||||
|
_providerListeningTo!.removeListener(_onProviderChanged);
|
||||||
|
_providerListeningTo = null;
|
||||||
|
setState(() {
|
||||||
|
_refreshKey = UniqueKey();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_lastArtistCount = currentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when SyncService status changes
|
||||||
|
void _onSyncChanged() {
|
||||||
|
final newStatus = SyncService.instance.status;
|
||||||
|
// When sync completes and we had empty favorites, refresh to show new data
|
||||||
|
if (_lastSyncStatus == SyncStatus.syncing &&
|
||||||
|
newStatus == SyncStatus.completed &&
|
||||||
|
_hadEmptyFavoritesOnLoad) {
|
||||||
|
_logger.log('🔄 Home: sync completed, refreshing favorite rows');
|
||||||
|
_hadEmptyFavoritesOnLoad = false;
|
||||||
|
// Clean up provider listener if still attached
|
||||||
|
_providerListeningTo?.removeListener(_onProviderChanged);
|
||||||
|
_providerListeningTo = null;
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_refreshKey = UniqueKey();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_lastSyncStatus = newStatus;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
// Reload settings when app resumes (coming back from settings)
|
// Reload settings when app resumes (coming back from settings)
|
||||||
@@ -79,6 +151,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||||
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
|
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
|
||||||
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
|
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
|
||||||
|
final showFavPodcasts = await SettingsService.getShowFavoritePodcasts();
|
||||||
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
||||||
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
|
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
|
||||||
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
|
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
|
||||||
@@ -93,6 +166,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
_showFavoriteTracks = showFavTracks;
|
_showFavoriteTracks = showFavTracks;
|
||||||
_showFavoritePlaylists = showFavPlaylists;
|
_showFavoritePlaylists = showFavPlaylists;
|
||||||
_showFavoriteRadioStations = showFavRadio;
|
_showFavoriteRadioStations = showFavRadio;
|
||||||
|
_showFavoritePodcasts = showFavPodcasts;
|
||||||
_showContinueListeningAudiobooks = showContAudiobooks;
|
_showContinueListeningAudiobooks = showContAudiobooks;
|
||||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||||
_showDiscoverSeries = showDiscSeries;
|
_showDiscoverSeries = showDiscSeries;
|
||||||
@@ -246,15 +320,50 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Count how many rows are currently enabled
|
||||||
|
int _countEnabledRows() {
|
||||||
|
int count = 0;
|
||||||
|
for (final rowId in _homeRowOrder) {
|
||||||
|
if (_isRowEnabled(rowId)) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a specific row is enabled
|
||||||
|
bool _isRowEnabled(String rowId) {
|
||||||
|
switch (rowId) {
|
||||||
|
case 'recent-albums': return _showRecentAlbums;
|
||||||
|
case 'discover-artists': return _showDiscoverArtists;
|
||||||
|
case 'discover-albums': return _showDiscoverAlbums;
|
||||||
|
case 'continue-listening': return _showContinueListeningAudiobooks;
|
||||||
|
case 'discover-audiobooks': return _showDiscoverAudiobooks;
|
||||||
|
case 'discover-series': return _showDiscoverSeries;
|
||||||
|
case 'favorite-albums': return _showFavoriteAlbums;
|
||||||
|
case 'favorite-artists': return _showFavoriteArtists;
|
||||||
|
case 'favorite-tracks': return _showFavoriteTracks;
|
||||||
|
case 'favorite-playlists': return _showFavoritePlaylists;
|
||||||
|
case 'favorite-radio-stations': return _showFavoriteRadioStations;
|
||||||
|
case 'favorite-podcasts': return _showFavoritePodcasts;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
// Simple calculation: available height divided by 3 rows
|
// Each row is always 1/3 of screen height
|
||||||
// Each row includes its title, artwork, and info
|
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll
|
||||||
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
|
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
|
||||||
final rowHeight = (availableHeight + 8) / 3;
|
|
||||||
|
// Account for margins between rows (2px each)
|
||||||
|
// Only adjust for margins when ≤3 rows (so they fit exactly without scroll)
|
||||||
|
// For 4+ rows, use original calculation so 4th row stays hidden (scrollable)
|
||||||
|
const marginSize = 2.0;
|
||||||
|
final enabledRows = _countEnabledRows();
|
||||||
|
final marginsInView = enabledRows > 1 && enabledRows <= 3 ? (enabledRows - 1) * marginSize : 0.0;
|
||||||
|
final rowHeight = (availableHeight - marginsInView) / 3;
|
||||||
|
|
||||||
// Use Android 12+ stretch overscroll effect
|
// Use Android 12+ stretch overscroll effect
|
||||||
return NotificationListener<ScrollNotification>(
|
return NotificationListener<ScrollNotification>(
|
||||||
@@ -296,6 +405,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
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) {
|
||||||
|
// Add spacing between rows (not before first row)
|
||||||
|
if (rows.isNotEmpty) {
|
||||||
|
rows.add(const SizedBox(height: 2.0));
|
||||||
|
}
|
||||||
rows.add(widget);
|
rows.add(widget);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,7 +451,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
return AudiobookRow(
|
return AudiobookRow(
|
||||||
key: const ValueKey('continue-listening'),
|
key: const ValueKey('continue-listening'),
|
||||||
title: S.of(context)!.continueListening,
|
title: S.of(context)!.continueListening,
|
||||||
loadAudiobooks: () => provider.getInProgressAudiobooks(),
|
loadAudiobooks: () => provider.getInProgressAudiobooksWithCache(),
|
||||||
|
getCachedAudiobooks: () => provider.getCachedInProgressAudiobooks(),
|
||||||
rowHeight: rowHeight,
|
rowHeight: rowHeight,
|
||||||
);
|
);
|
||||||
case 'discover-audiobooks':
|
case 'discover-audiobooks':
|
||||||
@@ -346,7 +460,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
return AudiobookRow(
|
return AudiobookRow(
|
||||||
key: const ValueKey('discover-audiobooks'),
|
key: const ValueKey('discover-audiobooks'),
|
||||||
title: S.of(context)!.discoverAudiobooks,
|
title: S.of(context)!.discoverAudiobooks,
|
||||||
loadAudiobooks: () => provider.getDiscoverAudiobooks(),
|
loadAudiobooks: () => provider.getDiscoverAudiobooksWithCache(),
|
||||||
|
getCachedAudiobooks: () => provider.getCachedDiscoverAudiobooks(),
|
||||||
rowHeight: rowHeight,
|
rowHeight: rowHeight,
|
||||||
);
|
);
|
||||||
case 'discover-series':
|
case 'discover-series':
|
||||||
@@ -354,7 +469,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
return SeriesRow(
|
return SeriesRow(
|
||||||
key: const ValueKey('discover-series'),
|
key: const ValueKey('discover-series'),
|
||||||
title: S.of(context)!.discoverSeries,
|
title: S.of(context)!.discoverSeries,
|
||||||
loadSeries: () => provider.getDiscoverSeries(),
|
loadSeries: () => provider.getDiscoverSeriesWithCache(),
|
||||||
|
getCachedSeries: () => provider.getCachedDiscoverSeries(),
|
||||||
rowHeight: rowHeight,
|
rowHeight: rowHeight,
|
||||||
);
|
);
|
||||||
case 'favorite-albums':
|
case 'favorite-albums':
|
||||||
@@ -399,6 +515,15 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
heroTagSuffix: 'home',
|
heroTagSuffix: 'home',
|
||||||
rowHeight: rowHeight,
|
rowHeight: rowHeight,
|
||||||
);
|
);
|
||||||
|
case 'favorite-podcasts':
|
||||||
|
if (!_showFavoritePodcasts) return null;
|
||||||
|
return PodcastRow(
|
||||||
|
key: const ValueKey('favorite-podcasts'),
|
||||||
|
title: S.of(context)!.favoritePodcasts,
|
||||||
|
loadPodcasts: () => provider.getFavoritePodcasts(),
|
||||||
|
heroTagSuffix: 'home',
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,6 +225,8 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
|||||||
|
|
||||||
if (selectedPlayer != null) {
|
if (selectedPlayer != null) {
|
||||||
try {
|
try {
|
||||||
|
// Set podcast context before playing so player UI shows correct podcast name
|
||||||
|
maProvider.setCurrentPodcastName(widget.podcast.name);
|
||||||
await maProvider.api?.playPodcastEpisode(selectedPlayer.playerId, episode);
|
await maProvider.api?.playPodcastEpisode(selectedPlayer.playerId, episode);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -259,6 +261,8 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
|||||||
selectedPlayer: maProvider.selectedPlayer,
|
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
|
||||||
|
maProvider.setCurrentPodcastName(widget.podcast.name);
|
||||||
await maProvider.api?.playPodcastEpisode(player.playerId, episode);
|
await maProvider.api?.playPodcastEpisode(player.playerId, episode);
|
||||||
},
|
},
|
||||||
).whenComplete(() {
|
).whenComplete(() {
|
||||||
@@ -856,8 +860,8 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Bottom padding for mini player
|
// Bottom padding for mini player
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SizedBox(height: 100),
|
child: SizedBox(height: BottomSpacing.withMiniPlayer),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '../providers/music_assistant_provider.dart';
|
|||||||
import '../models/player.dart';
|
import '../models/player.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
import '../widgets/common/empty_state.dart';
|
import '../widgets/common/empty_state.dart';
|
||||||
|
import '../widgets/player_picker_sheet.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
class QueueScreen extends StatefulWidget {
|
class QueueScreen extends StatefulWidget {
|
||||||
@@ -67,6 +68,68 @@ class _QueueScreenState extends State<QueueScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleTransferQueue(MusicAssistantProvider maProvider) async {
|
||||||
|
final player = maProvider.selectedPlayer;
|
||||||
|
if (player == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get available players, excluding the current source player
|
||||||
|
final allPlayers = maProvider.availablePlayers;
|
||||||
|
final targetPlayers = allPlayers
|
||||||
|
.where((p) => p.playerId != player.playerId && p.available)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (targetPlayers.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('No other players available to transfer to'),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show player picker sheet
|
||||||
|
await showPlayerPickerSheet(
|
||||||
|
context: context,
|
||||||
|
title: 'Transfer Queue To',
|
||||||
|
players: targetPlayers,
|
||||||
|
onPlayerSelected: (targetPlayer) async {
|
||||||
|
try {
|
||||||
|
await maProvider.api?.transferQueue(
|
||||||
|
sourceQueueId: player.playerId,
|
||||||
|
targetQueueId: targetPlayer.playerId,
|
||||||
|
autoPlay: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Queue transferred to ${targetPlayer.name}'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error transferring queue: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to transfer queue: $e'),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.watch<MusicAssistantProvider>();
|
final maProvider = context.watch<MusicAssistantProvider>();
|
||||||
@@ -87,6 +150,11 @@ class _QueueScreenState extends State<QueueScreen> {
|
|||||||
),
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.swap_horiz_rounded),
|
||||||
|
tooltip: 'Transfer queue',
|
||||||
|
onPressed: () => _handleTransferQueue(maProvider),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
onPressed: _loadQueue,
|
onPressed: _loadQueue,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import 'playlist_details_screen.dart';
|
|||||||
import 'audiobook_detail_screen.dart';
|
import 'audiobook_detail_screen.dart';
|
||||||
import 'podcast_detail_screen.dart';
|
import 'podcast_detail_screen.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../theme/design_tokens.dart';
|
||||||
|
|
||||||
class SearchScreen extends StatefulWidget {
|
class SearchScreen extends StatefulWidget {
|
||||||
const SearchScreen({super.key});
|
const SearchScreen({super.key});
|
||||||
@@ -137,6 +138,16 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
double _lastVerticalScrollOffset = 0;
|
double _lastVerticalScrollOffset = 0;
|
||||||
static const double _scrollThreshold = 10.0;
|
static const double _scrollThreshold = 10.0;
|
||||||
|
|
||||||
|
// Filter chip position tracking for animated sliding highlight
|
||||||
|
final Map<String, GlobalKey> _filterKeys = {};
|
||||||
|
final Map<String, double> _filterWidths = {};
|
||||||
|
final Map<String, double> _filterPositions = {};
|
||||||
|
double _highlightLeft = 0;
|
||||||
|
double _highlightWidth = 80; // Default width until measured
|
||||||
|
|
||||||
|
// Track provider filter to detect changes
|
||||||
|
List<String>? _lastDisabledProviders;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -145,18 +156,48 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
_loadRecentSearches();
|
_loadRecentSearches();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
// Check if provider filter changed and clear caches if so
|
||||||
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
final currentDisabled = provider.disabledProviders;
|
||||||
|
if (_lastDisabledProviders != null &&
|
||||||
|
!_listEquals(_lastDisabledProviders!, currentDisabled)) {
|
||||||
|
_cachedAvailableFilters = null;
|
||||||
|
_cachedListItems.clear();
|
||||||
|
}
|
||||||
|
_lastDisabledProviders = List.from(currentDisabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _listEquals(List<String> a, List<String> b) {
|
||||||
|
if (a.length != b.length) return false;
|
||||||
|
for (int i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] != b[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get filtered search results based on provider filter settings
|
||||||
|
Map<String, List<MediaItem>> _getFilteredResults(MusicAssistantProvider provider) {
|
||||||
|
return provider.filterSearchResults(_searchResults);
|
||||||
|
}
|
||||||
|
|
||||||
/// Get list of available filters based on current results (cached)
|
/// Get list of available filters based on current results (cached)
|
||||||
|
/// Uses provider filter to exclude disabled providers from results
|
||||||
List<String> _getAvailableFilters() {
|
List<String> _getAvailableFilters() {
|
||||||
if (_cachedAvailableFilters != null) return _cachedAvailableFilters!;
|
if (_cachedAvailableFilters != null) return _cachedAvailableFilters!;
|
||||||
|
|
||||||
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
final filtered = _getFilteredResults(provider);
|
||||||
final filters = <String>['all'];
|
final filters = <String>['all'];
|
||||||
if (_searchResults['artists']?.isNotEmpty == true) filters.add('artists');
|
if (filtered['artists']?.isNotEmpty == true) filters.add('artists');
|
||||||
if (_searchResults['albums']?.isNotEmpty == true) filters.add('albums');
|
if (filtered['albums']?.isNotEmpty == true) filters.add('albums');
|
||||||
if (_searchResults['tracks']?.isNotEmpty == true) filters.add('tracks');
|
if (filtered['tracks']?.isNotEmpty == true) filters.add('tracks');
|
||||||
if (_searchResults['playlists']?.isNotEmpty == true) filters.add('playlists');
|
if (filtered['playlists']?.isNotEmpty == true) filters.add('playlists');
|
||||||
if (_searchResults['audiobooks']?.isNotEmpty == true) filters.add('audiobooks');
|
if (filtered['audiobooks']?.isNotEmpty == true) filters.add('audiobooks');
|
||||||
if (_searchResults['radios']?.isNotEmpty == true) filters.add('radios');
|
if (filtered['radios']?.isNotEmpty == true) filters.add('radios');
|
||||||
if (_searchResults['podcasts']?.isNotEmpty == true) filters.add('podcasts');
|
if (filtered['podcasts']?.isNotEmpty == true) filters.add('podcasts');
|
||||||
_cachedAvailableFilters = filters;
|
_cachedAvailableFilters = filters;
|
||||||
return filters;
|
return filters;
|
||||||
}
|
}
|
||||||
@@ -185,6 +226,39 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Measure filter chip positions and update highlight
|
||||||
|
void _measureFilterPositions() {
|
||||||
|
final filters = _getAvailableFilters();
|
||||||
|
double left = 0;
|
||||||
|
|
||||||
|
for (final filter in filters) {
|
||||||
|
final key = _filterKeys[filter];
|
||||||
|
if (key?.currentContext != null) {
|
||||||
|
final box = key!.currentContext!.findRenderObject() as RenderBox?;
|
||||||
|
if (box != null && box.hasSize) {
|
||||||
|
_filterWidths[filter] = box.size.width;
|
||||||
|
_filterPositions[filter] = left;
|
||||||
|
left += box.size.width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update highlight position for the active filter
|
||||||
|
void _updateHighlightPosition(String activeFilter) {
|
||||||
|
_measureFilterPositions();
|
||||||
|
|
||||||
|
final position = _filterPositions[activeFilter];
|
||||||
|
final width = _filterWidths[activeFilter];
|
||||||
|
|
||||||
|
if (position != null && width != null) {
|
||||||
|
setState(() {
|
||||||
|
_highlightLeft = position;
|
||||||
|
_highlightWidth = width;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Scroll filter bar to keep active filter visible
|
/// Scroll filter bar to keep active filter visible
|
||||||
/// Only scrolls when the filter would be obscured (off-screen)
|
/// Only scrolls when the filter would be obscured (off-screen)
|
||||||
/// Uses post-frame callback to avoid interfering with PageView animation
|
/// Uses post-frame callback to avoid interfering with PageView animation
|
||||||
@@ -200,30 +274,35 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
// If all filters fit on screen, don't scroll at all
|
// If all filters fit on screen, don't scroll at all
|
||||||
if (maxScroll <= 0) return;
|
if (maxScroll <= 0) return;
|
||||||
|
|
||||||
// Approximate width per filter chip (padding + text)
|
final filters = _getAvailableFilters();
|
||||||
const chipWidth = 80.0;
|
if (filterIndex < 0 || filterIndex >= filters.length) return;
|
||||||
const horizontalPadding = 16.0;
|
|
||||||
|
final filter = filters[filterIndex];
|
||||||
|
|
||||||
|
// Update highlight position
|
||||||
|
_updateHighlightPosition(filter);
|
||||||
|
|
||||||
|
// Use measured positions if available, fallback to estimate
|
||||||
|
final chipLeft = _filterPositions[filter] ?? (filterIndex * 80.0);
|
||||||
|
final chipWidth = _filterWidths[filter] ?? 80.0;
|
||||||
|
final chipRight = chipLeft + chipWidth;
|
||||||
|
|
||||||
final currentScroll = _filterScrollController.offset;
|
final currentScroll = _filterScrollController.offset;
|
||||||
final viewportWidth = _filterScrollController.position.viewportDimension;
|
final viewportWidth = _filterScrollController.position.viewportDimension;
|
||||||
|
|
||||||
// Calculate the left and right edges of the active filter chip
|
|
||||||
final chipLeft = (filterIndex * chipWidth);
|
|
||||||
final chipRight = chipLeft + chipWidth;
|
|
||||||
|
|
||||||
// Calculate what's currently visible in the viewport
|
// Calculate what's currently visible in the viewport
|
||||||
final visibleLeft = currentScroll;
|
final visibleLeft = currentScroll;
|
||||||
final visibleRight = currentScroll + viewportWidth - (horizontalPadding * 2);
|
final visibleRight = currentScroll + viewportWidth;
|
||||||
|
|
||||||
// Only scroll if the chip is actually obscured
|
// Only scroll if the chip is actually obscured
|
||||||
double? targetOffset;
|
double? targetOffset;
|
||||||
|
|
||||||
if (chipRight > visibleRight) {
|
if (chipRight > visibleRight) {
|
||||||
// Chip is cut off on the right - scroll right just enough to show it
|
// Chip is cut off on the right - scroll right to show it with padding
|
||||||
targetOffset = chipRight - viewportWidth + (horizontalPadding * 2);
|
targetOffset = chipRight - viewportWidth + 8;
|
||||||
} else if (chipLeft < visibleLeft) {
|
} else if (chipLeft < visibleLeft) {
|
||||||
// Chip is cut off on the left - scroll left just enough to show it
|
// Chip is cut off on the left - scroll left to show it with padding
|
||||||
targetOffset = chipLeft;
|
targetOffset = chipLeft - 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only animate if we need to scroll
|
// Only animate if we need to scroll
|
||||||
@@ -441,81 +520,97 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
decoration: const BoxDecoration(),
|
decoration: const BoxDecoration(),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
child: Container(
|
child: Row(
|
||||||
decoration: BoxDecoration(
|
children: [
|
||||||
color: colorScheme.surfaceVariant.withOpacity(0.5),
|
// Search field container
|
||||||
borderRadius: BorderRadius.circular(12),
|
Expanded(
|
||||||
),
|
child: Container(
|
||||||
child: Row(
|
decoration: BoxDecoration(
|
||||||
children: [
|
color: colorScheme.surfaceVariant.withOpacity(0.5),
|
||||||
// Search field
|
borderRadius: BorderRadius.circular(12),
|
||||||
Expanded(
|
),
|
||||||
child: Padding(
|
child: TextField(
|
||||||
padding: const EdgeInsets.only(left: 16),
|
controller: _searchController,
|
||||||
child: TextField(
|
focusNode: _focusNode,
|
||||||
controller: _searchController,
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
focusNode: _focusNode,
|
cursorColor: colorScheme.primary,
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
textInputAction: TextInputAction.search,
|
||||||
cursorColor: colorScheme.primary,
|
textAlignVertical: TextAlignVertical.center,
|
||||||
textInputAction: TextInputAction.search,
|
decoration: InputDecoration(
|
||||||
textAlignVertical: TextAlignVertical.center,
|
isDense: true,
|
||||||
decoration: InputDecoration(
|
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
isDense: true,
|
hintText: S.of(context)!.searchMusic,
|
||||||
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
hintText: S.of(context)!.searchMusic,
|
border: InputBorder.none,
|
||||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
prefixIcon: Icon(
|
||||||
border: InputBorder.none,
|
Icons.search_rounded,
|
||||||
suffixIcon: _hasSearchText
|
color: colorScheme.onSurface.withOpacity(0.5),
|
||||||
? IconButton(
|
|
||||||
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
|
|
||||||
onPressed: () {
|
|
||||||
_searchController.clear();
|
|
||||||
setState(() {
|
|
||||||
_hasSearchText = false;
|
|
||||||
_searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'podcasts': []};
|
|
||||||
_hasSearched = false;
|
|
||||||
_searchError = null;
|
|
||||||
_cachedListItems.clear();
|
|
||||||
_cachedAvailableFilters = null;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
onChanged: (value) {
|
suffixIcon: _hasSearchText
|
||||||
// PERF: Only rebuild when clear button visibility changes
|
? IconButton(
|
||||||
final hasText = value.isNotEmpty;
|
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
if (hasText != _hasSearchText) {
|
onPressed: () {
|
||||||
setState(() {
|
_searchController.clear();
|
||||||
_hasSearchText = hasText;
|
setState(() {
|
||||||
});
|
_hasSearchText = false;
|
||||||
}
|
_searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'podcasts': []};
|
||||||
_onSearchChanged(value);
|
_hasSearched = false;
|
||||||
},
|
_searchError = null;
|
||||||
onSubmitted: (query) => _performSearch(query),
|
_cachedListItems.clear();
|
||||||
|
_cachedAvailableFilters = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
onChanged: (value) {
|
||||||
),
|
// PERF: Only rebuild when clear button visibility changes
|
||||||
// Library-only toggle
|
final hasText = value.isNotEmpty;
|
||||||
Tooltip(
|
if (hasText != _hasSearchText) {
|
||||||
message: S.of(context)!.libraryOnly,
|
setState(() {
|
||||||
child: IconButton(
|
_hasSearchText = hasText;
|
||||||
icon: Icon(
|
});
|
||||||
_libraryOnly ? Icons.library_music : Icons.library_music_outlined,
|
|
||||||
color: _libraryOnly ? colorScheme.primary : colorScheme.onSurface.withOpacity(0.5),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
_libraryOnly = !_libraryOnly;
|
|
||||||
});
|
|
||||||
if (_searchController.text.isNotEmpty) {
|
|
||||||
_performSearch(_searchController.text);
|
|
||||||
}
|
}
|
||||||
|
_onSearchChanged(value);
|
||||||
},
|
},
|
||||||
|
onSubmitted: (query) => _performSearch(query),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
|
// Library-only toggle with circle background
|
||||||
|
Tooltip(
|
||||||
|
message: S.of(context)!.libraryOnly,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: Material(
|
||||||
|
color: _libraryOnly
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.surfaceVariant.withOpacity(0.6),
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_libraryOnly = !_libraryOnly;
|
||||||
|
});
|
||||||
|
if (_searchController.text.isNotEmpty) {
|
||||||
|
_performSearch(_searchController.text);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
child: Icon(
|
||||||
|
_libraryOnly ? Icons.library_music : Icons.library_music_outlined,
|
||||||
|
size: 22,
|
||||||
|
color: _libraryOnly
|
||||||
|
? colorScheme.onPrimary
|
||||||
|
: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -557,7 +652,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
size: 80,
|
size: 80,
|
||||||
color: colorScheme.onBackground.withOpacity(0.3),
|
color: colorScheme.onBackground.withOpacity(0.3),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
Spacing.vGap16,
|
||||||
Text(
|
Text(
|
||||||
S.of(context)!.searchForContent,
|
S.of(context)!.searchForContent,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -610,7 +705,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
size: 64,
|
size: 64,
|
||||||
color: colorScheme.error.withOpacity(0.7),
|
color: colorScheme.error.withOpacity(0.7),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
Spacing.vGap16,
|
||||||
Text(
|
Text(
|
||||||
_searchError!,
|
_searchError!,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -619,7 +714,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
Spacing.vGap16,
|
||||||
FilledButton.tonal(
|
FilledButton.tonal(
|
||||||
onPressed: () => _performSearch(_searchController.text),
|
onPressed: () => _performSearch(_searchController.text),
|
||||||
child: Text(S.of(context)!.retry),
|
child: Text(S.of(context)!.retry),
|
||||||
@@ -629,13 +724,16 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final artists = _searchResults['artists'] as List<MediaItem>? ?? [];
|
// Apply provider filter to search results
|
||||||
final albums = _searchResults['albums'] as List<MediaItem>? ?? [];
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
final tracks = _searchResults['tracks'] as List<MediaItem>? ?? [];
|
final filtered = _getFilteredResults(provider);
|
||||||
final playlists = _searchResults['playlists'] as List<MediaItem>? ?? [];
|
final artists = filtered['artists'] as List<MediaItem>? ?? [];
|
||||||
final audiobooks = _searchResults['audiobooks'] as List<MediaItem>? ?? [];
|
final albums = filtered['albums'] as List<MediaItem>? ?? [];
|
||||||
final radios = _searchResults['radios'] as List<MediaItem>? ?? [];
|
final tracks = filtered['tracks'] as List<MediaItem>? ?? [];
|
||||||
final podcasts = _searchResults['podcasts'] as List<MediaItem>? ?? [];
|
final playlists = filtered['playlists'] as List<MediaItem>? ?? [];
|
||||||
|
final audiobooks = filtered['audiobooks'] as List<MediaItem>? ?? [];
|
||||||
|
final radios = filtered['radios'] as List<MediaItem>? ?? [];
|
||||||
|
final podcasts = filtered['podcasts'] as List<MediaItem>? ?? [];
|
||||||
|
|
||||||
final hasResults = artists.isNotEmpty || albums.isNotEmpty || tracks.isNotEmpty ||
|
final hasResults = artists.isNotEmpty || albums.isNotEmpty || tracks.isNotEmpty ||
|
||||||
playlists.isNotEmpty || audiobooks.isNotEmpty || radios.isNotEmpty ||
|
playlists.isNotEmpty || audiobooks.isNotEmpty || radios.isNotEmpty ||
|
||||||
@@ -740,7 +838,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: [
|
||||||
colorScheme.background,
|
colorScheme.background,
|
||||||
colorScheme.surface.withOpacity(0),
|
colorScheme.background.withOpacity(0),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -934,7 +1032,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build joined segmented filter selector (like library media type selector)
|
/// Build joined segmented filter selector with animated sliding highlight
|
||||||
Widget _buildFilterSelector(ColorScheme colorScheme) {
|
Widget _buildFilterSelector(ColorScheme colorScheme) {
|
||||||
final filters = _getAvailableFilters();
|
final filters = _getAvailableFilters();
|
||||||
final l10n = S.of(context)!;
|
final l10n = S.of(context)!;
|
||||||
@@ -953,42 +1051,93 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No ClipRRect here - parent container handles clipping with rounded corners
|
// Ensure GlobalKeys exist for all filters
|
||||||
|
for (final filter in filters) {
|
||||||
|
_filterKeys.putIfAbsent(filter, () => GlobalKey());
|
||||||
|
}
|
||||||
|
|
||||||
// Wrap in ValueListenableBuilder for efficient rebuilds on filter change
|
// Wrap in ValueListenableBuilder for efficient rebuilds on filter change
|
||||||
return ValueListenableBuilder<String>(
|
return ValueListenableBuilder<String>(
|
||||||
valueListenable: _activeFilterNotifier,
|
valueListenable: _activeFilterNotifier,
|
||||||
builder: (context, activeFilter, _) {
|
builder: (context, activeFilter, _) {
|
||||||
return Row(
|
// Measure positions after build
|
||||||
mainAxisSize: MainAxisSize.min,
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
children: filters.map((filter) {
|
if (mounted) {
|
||||||
final isSelected = activeFilter == filter;
|
_measureFilterPositions();
|
||||||
return Material(
|
// Update highlight on first build or when filters change
|
||||||
// Use theme-aware colors for light/dark mode support
|
final position = _filterPositions[activeFilter];
|
||||||
color: isSelected
|
final width = _filterWidths[activeFilter];
|
||||||
? colorScheme.primaryContainer
|
if (position != null && width != null &&
|
||||||
: colorScheme.surfaceVariant.withOpacity(0.6),
|
(_highlightLeft != position || _highlightWidth != width)) {
|
||||||
child: InkWell(
|
setState(() {
|
||||||
onTap: () {
|
_highlightLeft = position;
|
||||||
// Only update ValueNotifier - no setState needed
|
_highlightWidth = width;
|
||||||
_activeFilterNotifier.value = filter;
|
});
|
||||||
_animateToFilter(filter);
|
}
|
||||||
},
|
}
|
||||||
child: Padding(
|
});
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
||||||
child: Text(
|
final selectedIndex = filters.indexOf(activeFilter);
|
||||||
getLabel(filter),
|
final isFirstTab = selectedIndex == 0;
|
||||||
style: TextStyle(
|
final isLastTab = selectedIndex == filters.length - 1;
|
||||||
color: isSelected
|
|
||||||
? colorScheme.onPrimaryContainer
|
return Container(
|
||||||
: colorScheme.onSurfaceVariant.withOpacity(0.8),
|
decoration: BoxDecoration(
|
||||||
fontSize: 14,
|
color: colorScheme.surfaceContainerHighest.withOpacity(0.5),
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: IntrinsicHeight(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// Animated sliding highlight
|
||||||
|
AnimatedPositioned(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
left: _highlightLeft + (isFirstTab ? 0 : 2),
|
||||||
|
width: _highlightWidth - (isFirstTab ? 0 : 2) - (isLastTab ? 0 : 2),
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
// Filter buttons row (transparent, on top of highlight)
|
||||||
);
|
Row(
|
||||||
}).toList(),
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: filters.map((filter) {
|
||||||
|
final isSelected = activeFilter == filter;
|
||||||
|
return GestureDetector(
|
||||||
|
key: _filterKeys[filter],
|
||||||
|
onTap: () {
|
||||||
|
_activeFilterNotifier.value = filter;
|
||||||
|
_animateToFilter(filter);
|
||||||
|
},
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||||
|
child: Text(
|
||||||
|
getLabel(filter),
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.onPrimaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant.withOpacity(0.8),
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1126,7 +1275,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(22),
|
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -1342,7 +1491,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(22),
|
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -1536,7 +1685,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(22),
|
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -1566,37 +1715,58 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
final isExpanded = _expandedPlaylistId == playlistId;
|
final isExpanded = _expandedPlaylistId == playlistId;
|
||||||
final isInLib = _isInLibrary(playlist);
|
final isInLib = _isInLibrary(playlist);
|
||||||
|
|
||||||
|
// Unique suffix for search context
|
||||||
|
const heroSuffix = '_search';
|
||||||
|
|
||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
key: ValueKey(playlistId),
|
key: ValueKey(playlistId),
|
||||||
leading: Container(
|
leading: Hero(
|
||||||
width: 48,
|
tag: HeroTags.playlistCover + playlistId + heroSuffix,
|
||||||
height: 48,
|
child: ClipRRect(
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceVariant,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
image: imageUrl != null
|
child: Container(
|
||||||
? DecorationImage(
|
width: 48,
|
||||||
image: CachedNetworkImageProvider(imageUrl),
|
height: 48,
|
||||||
fit: BoxFit.cover,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
)
|
child: imageUrl != null
|
||||||
: null,
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 96,
|
||||||
|
memCacheHeight: 96,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => const SizedBox(),
|
||||||
|
errorWidget: (_, __, ___) => Icon(
|
||||||
|
Icons.queue_music_rounded,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Icons.queue_music_rounded,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: imageUrl == null
|
|
||||||
? Icon(Icons.queue_music_rounded, color: colorScheme.onSurfaceVariant)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Hero(
|
||||||
playlist.name,
|
tag: HeroTags.playlistTitle + playlistId + heroSuffix,
|
||||||
style: TextStyle(
|
child: Material(
|
||||||
color: colorScheme.onSurface,
|
color: Colors.transparent,
|
||||||
fontWeight: FontWeight.w500,
|
child: Text(
|
||||||
|
playlist.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
subtitle: showType
|
subtitle: showType
|
||||||
? Row(
|
? Row(
|
||||||
@@ -1631,13 +1801,14 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
setState(() => _expandedPlaylistId = null);
|
setState(() => _expandedPlaylistId = null);
|
||||||
} else {
|
} else {
|
||||||
|
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
FadeSlidePageRoute(
|
||||||
builder: (context) => PlaylistDetailsScreen(
|
child: PlaylistDetailsScreen(
|
||||||
playlist: playlist,
|
playlist: playlist,
|
||||||
provider: playlist.provider,
|
heroTagSuffix: 'search',
|
||||||
itemId: playlist.itemId,
|
initialImageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1879,7 +2050,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(22),
|
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
bool _showFavoriteTracks = false;
|
bool _showFavoriteTracks = false;
|
||||||
bool _showFavoritePlaylists = false;
|
bool _showFavoritePlaylists = false;
|
||||||
bool _showFavoriteRadioStations = false;
|
bool _showFavoriteRadioStations = false;
|
||||||
|
bool _showFavoritePodcasts = false;
|
||||||
// Audiobook home rows (default off)
|
// Audiobook home rows (default off)
|
||||||
bool _showContinueListeningAudiobooks = false;
|
bool _showContinueListeningAudiobooks = false;
|
||||||
bool _showDiscoverAudiobooks = false;
|
bool _showDiscoverAudiobooks = false;
|
||||||
@@ -48,6 +49,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
bool _showHints = true;
|
bool _showHints = true;
|
||||||
// Library settings
|
// Library settings
|
||||||
bool _showOnlyArtistsWithAlbums = false;
|
bool _showOnlyArtistsWithAlbums = false;
|
||||||
|
// Music provider filter settings
|
||||||
|
List<String> _disabledProviders = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -75,6 +78,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||||
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
|
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
|
||||||
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
|
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
|
||||||
|
final showFavPodcasts = await SettingsService.getShowFavoritePodcasts();
|
||||||
|
|
||||||
// Load audiobook home row settings
|
// Load audiobook home row settings
|
||||||
final showContinueAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
final showContinueAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
||||||
@@ -105,6 +109,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
// Load library settings
|
// Load library settings
|
||||||
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||||
|
|
||||||
|
// Load music provider filter settings
|
||||||
|
final disabledProviders = await SettingsService.getDisabledMusicProviders();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_showRecentAlbums = showRecent;
|
_showRecentAlbums = showRecent;
|
||||||
@@ -115,6 +122,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_showFavoriteTracks = showFavTracks;
|
_showFavoriteTracks = showFavTracks;
|
||||||
_showFavoritePlaylists = showFavPlaylists;
|
_showFavoritePlaylists = showFavPlaylists;
|
||||||
_showFavoriteRadioStations = showFavRadio;
|
_showFavoriteRadioStations = showFavRadio;
|
||||||
|
_showFavoritePodcasts = showFavPodcasts;
|
||||||
_showContinueListeningAudiobooks = showContinueAudiobooks;
|
_showContinueListeningAudiobooks = showContinueAudiobooks;
|
||||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||||
_showDiscoverSeries = showDiscSeries;
|
_showDiscoverSeries = showDiscSeries;
|
||||||
@@ -126,6 +134,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_volumePrecisionMode = volumePrecision;
|
_volumePrecisionMode = volumePrecision;
|
||||||
_showHints = showHints;
|
_showHints = showHints;
|
||||||
_showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums;
|
_showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums;
|
||||||
|
_disabledProviders = disabledProviders;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,6 +172,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
return {'title': s.favoritePlaylists, 'subtitle': s.showFavoritePlaylists};
|
return {'title': s.favoritePlaylists, 'subtitle': s.showFavoritePlaylists};
|
||||||
case 'favorite-radio-stations':
|
case 'favorite-radio-stations':
|
||||||
return {'title': s.favoriteRadioStations, 'subtitle': s.showFavoriteRadioStations};
|
return {'title': s.favoriteRadioStations, 'subtitle': s.showFavoriteRadioStations};
|
||||||
|
case 'favorite-podcasts':
|
||||||
|
return {'title': s.favoritePodcasts, 'subtitle': s.showFavoritePodcasts};
|
||||||
default:
|
default:
|
||||||
return {'title': rowId, 'subtitle': ''};
|
return {'title': rowId, 'subtitle': ''};
|
||||||
}
|
}
|
||||||
@@ -193,6 +204,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
return _showFavoritePlaylists;
|
return _showFavoritePlaylists;
|
||||||
case 'favorite-radio-stations':
|
case 'favorite-radio-stations':
|
||||||
return _showFavoriteRadioStations;
|
return _showFavoriteRadioStations;
|
||||||
|
case 'favorite-podcasts':
|
||||||
|
return _showFavoritePodcasts;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -246,6 +259,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_showFavoriteRadioStations = value;
|
_showFavoriteRadioStations = value;
|
||||||
SettingsService.setShowFavoriteRadioStations(value);
|
SettingsService.setShowFavoriteRadioStations(value);
|
||||||
break;
|
break;
|
||||||
|
case 'favorite-podcasts':
|
||||||
|
_showFavoritePodcasts = value;
|
||||||
|
SettingsService.setShowFavoritePodcasts(value);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -673,6 +690,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
Navigator.pop(dialogContext);
|
Navigator.pop(dialogContext);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
RadioListTile<String?>(
|
||||||
|
title: const Text('Français'),
|
||||||
|
value: 'fr',
|
||||||
|
groupValue: localeProvider.locale?.languageCode,
|
||||||
|
onChanged: (value) {
|
||||||
|
localeProvider.setLocale(const Locale('fr'));
|
||||||
|
Navigator.pop(dialogContext);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -685,6 +711,66 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Music Providers section
|
||||||
|
if (provider.musicProviders.isNotEmpty) ...[
|
||||||
|
Text(
|
||||||
|
'Music Providers',
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Filter content shown in Home, Library, and Search',
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onBackground.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: provider.musicProviders.map((musicProvider) {
|
||||||
|
final isEnabled = !_disabledProviders.contains(musicProvider.instanceId);
|
||||||
|
return SwitchListTile(
|
||||||
|
title: Text(
|
||||||
|
musicProvider.name,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
subtitle: musicProvider.isStreamingProvider
|
||||||
|
? Text(
|
||||||
|
'Streaming',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
value: isEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await provider.toggleProviderEnabled(musicProvider.instanceId, value);
|
||||||
|
setState(() {
|
||||||
|
if (value) {
|
||||||
|
_disabledProviders.remove(musicProvider.instanceId);
|
||||||
|
} else {
|
||||||
|
_disabledProviders.add(musicProvider.instanceId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
|
||||||
// Player section
|
// Player section
|
||||||
Text(
|
Text(
|
||||||
S.of(context)!.players,
|
S.of(context)!.players,
|
||||||
|
|||||||
@@ -20,9 +20,15 @@ class CacheService {
|
|||||||
List<Album>? _cachedRecentAlbums;
|
List<Album>? _cachedRecentAlbums;
|
||||||
List<Artist>? _cachedDiscoverArtists;
|
List<Artist>? _cachedDiscoverArtists;
|
||||||
List<Album>? _cachedDiscoverAlbums;
|
List<Album>? _cachedDiscoverAlbums;
|
||||||
|
List<Audiobook>? _cachedInProgressAudiobooks;
|
||||||
|
List<Audiobook>? _cachedDiscoverAudiobooks;
|
||||||
|
List<AudiobookSeries>? _cachedDiscoverSeries;
|
||||||
DateTime? _recentAlbumsLastFetched;
|
DateTime? _recentAlbumsLastFetched;
|
||||||
DateTime? _discoverArtistsLastFetched;
|
DateTime? _discoverArtistsLastFetched;
|
||||||
DateTime? _discoverAlbumsLastFetched;
|
DateTime? _discoverAlbumsLastFetched;
|
||||||
|
DateTime? _inProgressAudiobooksLastFetched;
|
||||||
|
DateTime? _discoverAudiobooksLastFetched;
|
||||||
|
DateTime? _discoverSeriesLastFetched;
|
||||||
|
|
||||||
// Detail screen caching
|
// Detail screen caching
|
||||||
final Map<String, List<Track>> _albumTracksCache = {};
|
final Map<String, List<Track>> _albumTracksCache = {};
|
||||||
@@ -112,11 +118,82 @@ class CacheService {
|
|||||||
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
|
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if in-progress audiobooks cache is valid
|
||||||
|
bool isInProgressAudiobooksCacheValid({bool forceRefresh = false}) {
|
||||||
|
if (forceRefresh) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
return _cachedInProgressAudiobooks != null &&
|
||||||
|
_inProgressAudiobooksLastFetched != null &&
|
||||||
|
now.difference(_inProgressAudiobooksLastFetched!) < Timings.homeRowCacheDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached in-progress audiobooks
|
||||||
|
List<Audiobook>? getCachedInProgressAudiobooks() => _cachedInProgressAudiobooks;
|
||||||
|
|
||||||
|
/// Set cached in-progress audiobooks
|
||||||
|
void setCachedInProgressAudiobooks(List<Audiobook> audiobooks) {
|
||||||
|
_cachedInProgressAudiobooks = audiobooks;
|
||||||
|
_inProgressAudiobooksLastFetched = DateTime.now();
|
||||||
|
_logger.log('✅ Cached ${audiobooks.length} in-progress audiobooks');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if discover audiobooks cache is valid
|
||||||
|
bool isDiscoverAudiobooksCacheValid({bool forceRefresh = false}) {
|
||||||
|
if (forceRefresh) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
return _cachedDiscoverAudiobooks != null &&
|
||||||
|
_discoverAudiobooksLastFetched != null &&
|
||||||
|
now.difference(_discoverAudiobooksLastFetched!) < Timings.homeRowCacheDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached discover audiobooks
|
||||||
|
List<Audiobook>? getCachedDiscoverAudiobooks() => _cachedDiscoverAudiobooks;
|
||||||
|
|
||||||
|
/// Set cached discover audiobooks
|
||||||
|
void setCachedDiscoverAudiobooks(List<Audiobook> audiobooks) {
|
||||||
|
_cachedDiscoverAudiobooks = audiobooks;
|
||||||
|
_discoverAudiobooksLastFetched = DateTime.now();
|
||||||
|
_logger.log('✅ Cached ${audiobooks.length} discover audiobooks');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if discover series cache is valid
|
||||||
|
bool isDiscoverSeriesCacheValid({bool forceRefresh = false}) {
|
||||||
|
if (forceRefresh) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
return _cachedDiscoverSeries != null &&
|
||||||
|
_discoverSeriesLastFetched != null &&
|
||||||
|
now.difference(_discoverSeriesLastFetched!) < Timings.homeRowCacheDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached discover series
|
||||||
|
List<AudiobookSeries>? getCachedDiscoverSeries() => _cachedDiscoverSeries;
|
||||||
|
|
||||||
|
/// Set cached discover series
|
||||||
|
void setCachedDiscoverSeries(List<AudiobookSeries> series) {
|
||||||
|
_cachedDiscoverSeries = series;
|
||||||
|
_discoverSeriesLastFetched = DateTime.now();
|
||||||
|
_logger.log('✅ Cached ${series.length} discover series');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate audiobook caches
|
||||||
|
void invalidateAudiobookCaches() {
|
||||||
|
_cachedInProgressAudiobooks = null;
|
||||||
|
_inProgressAudiobooksLastFetched = null;
|
||||||
|
_cachedDiscoverAudiobooks = null;
|
||||||
|
_discoverAudiobooksLastFetched = null;
|
||||||
|
_cachedDiscoverSeries = null;
|
||||||
|
_discoverSeriesLastFetched = null;
|
||||||
|
_logger.log('🗑️ Audiobook caches invalidated');
|
||||||
|
}
|
||||||
|
|
||||||
/// Invalidate home screen cache (call on pull-to-refresh)
|
/// Invalidate home screen cache (call on pull-to-refresh)
|
||||||
void invalidateHomeCache() {
|
void invalidateHomeCache() {
|
||||||
_recentAlbumsLastFetched = null;
|
_recentAlbumsLastFetched = null;
|
||||||
_discoverArtistsLastFetched = null;
|
_discoverArtistsLastFetched = null;
|
||||||
_discoverAlbumsLastFetched = null;
|
_discoverAlbumsLastFetched = null;
|
||||||
|
_inProgressAudiobooksLastFetched = null;
|
||||||
|
_discoverAudiobooksLastFetched = null;
|
||||||
|
_discoverSeriesLastFetched = null;
|
||||||
_logger.log('🗑️ Home screen cache invalidated');
|
_logger.log('🗑️ Home screen cache invalidated');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -326,120 +326,301 @@ class MetadataService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches author image URL from multiple sources
|
/// Generate name variations for fuzzy matching
|
||||||
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia
|
/// Returns a list of name variations to try, in priority order
|
||||||
/// Returns the image URL if found, null otherwise
|
static List<String> _generateNameVariations(String authorName) {
|
||||||
static Future<String?> getAuthorImageUrl(String authorName) async {
|
final variations = <String>[];
|
||||||
// Check cache first
|
final original = authorName.trim();
|
||||||
final cacheKey = 'authorImage:$authorName';
|
|
||||||
if (_authorImageCache.containsKey(cacheKey)) {
|
// Always try original first
|
||||||
return _authorImageCache[cacheKey];
|
variations.add(original);
|
||||||
|
|
||||||
|
// Handle "Lastname, Firstname" format
|
||||||
|
if (original.contains(',')) {
|
||||||
|
final parts = original.split(',').map((p) => p.trim()).toList();
|
||||||
|
if (parts.length == 2) {
|
||||||
|
variations.add('${parts[1]} ${parts[0]}'); // "Firstname Lastname"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try Audnexus (specifically for audiobook authors, uses Audible data)
|
// Normalize and expand initials for better matching
|
||||||
|
// Handles messy formats like "J. R.R Tolkien", "J.R.R. Tolkien", etc.
|
||||||
|
String normalized = original;
|
||||||
|
|
||||||
|
// First, normalize spacing around periods in initials: "R.R" -> "R. R", "J. R" -> "J. R"
|
||||||
|
// Match single letter followed by period and optional space, then another letter
|
||||||
|
normalized = normalized.replaceAllMapped(
|
||||||
|
RegExp(r'([A-Z])\.(\s?)([A-Z])'),
|
||||||
|
(m) => '${m.group(1)}. ${m.group(3)}',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also handle single letters without periods followed by period-letter: "R.R" in "J. R.R"
|
||||||
|
// This catches the case where we have "R.R" as a unit
|
||||||
|
normalized = normalized.replaceAllMapped(
|
||||||
|
RegExp(r'(\s)([A-Z])\.([A-Z])'),
|
||||||
|
(m) => '${m.group(1)}${m.group(2)}. ${m.group(3)}.',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add periods to standalone single uppercase letters (initials without periods)
|
||||||
|
// "J. R. R Tolkien" -> "J. R. R. Tolkien"
|
||||||
|
normalized = normalized.replaceAllMapped(
|
||||||
|
RegExp(r'(\s)([A-Z])(\s)'),
|
||||||
|
(m) => '${m.group(1)}${m.group(2)}.${m.group(3)}',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (normalized != original && !variations.contains(normalized)) {
|
||||||
|
variations.add(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove common titles
|
||||||
|
final titles = ['Dr.', 'Dr', 'Mr.', 'Mr', 'Mrs.', 'Mrs', 'Ms.', 'Ms',
|
||||||
|
'Prof.', 'Prof', 'Professor', 'Sir', 'Dame', 'Lord', 'Lady'];
|
||||||
|
String withoutTitle = original;
|
||||||
|
for (final title in titles) {
|
||||||
|
if (withoutTitle.toLowerCase().startsWith(title.toLowerCase() + ' ')) {
|
||||||
|
withoutTitle = withoutTitle.substring(title.length).trim();
|
||||||
|
if (withoutTitle != original) {
|
||||||
|
variations.add(withoutTitle);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove suffixes
|
||||||
|
final suffixes = [', Jr.', ', Jr', ' Jr.', ' Jr', ', Sr.', ', Sr', ' Sr.', ' Sr',
|
||||||
|
', III', ' III', ', II', ' II', ', IV', ' IV', ', PhD', ', Ph.D.'];
|
||||||
|
String withoutSuffix = withoutTitle;
|
||||||
|
for (final suffix in suffixes) {
|
||||||
|
if (withoutSuffix.toLowerCase().endsWith(suffix.toLowerCase())) {
|
||||||
|
withoutSuffix = withoutSuffix.substring(0, withoutSuffix.length - suffix.length).trim();
|
||||||
|
if (withoutSuffix != withoutTitle && !variations.contains(withoutSuffix)) {
|
||||||
|
variations.add(withoutSuffix);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split into parts for further variations (use normalized name for better splitting)
|
||||||
|
final nameToSplit = normalized != original ? normalized : withoutSuffix;
|
||||||
|
final parts = nameToSplit.split(RegExp(r'\s+')).where((p) => p.isNotEmpty).toList();
|
||||||
|
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
// Try first + last only (skip middle names/initials)
|
||||||
|
final firstLast = '${parts.first} ${parts.last}';
|
||||||
|
if (!variations.contains(firstLast)) {
|
||||||
|
variations.add(firstLast);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if name starts with initials (single letter or letter with period)
|
||||||
|
bool isInitial(String s) => s.length == 1 || (s.length == 2 && s.endsWith('.'));
|
||||||
|
|
||||||
|
// If first part looks like an initial, try just the last name
|
||||||
|
if (isInitial(parts.first)) {
|
||||||
|
if (!variations.contains(parts.last)) {
|
||||||
|
variations.add(parts.last);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle multiple initials like "J. K. Rowling" or "J. R. R. Tolkien"
|
||||||
|
// Find where initials end and actual name begins
|
||||||
|
int initialsEnd = 0;
|
||||||
|
for (int i = 0; i < parts.length - 1; i++) {
|
||||||
|
if (isInitial(parts[i])) {
|
||||||
|
initialsEnd = i + 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialsEnd > 0 && initialsEnd < parts.length) {
|
||||||
|
// Try the non-initial part of the name
|
||||||
|
final restOfName = parts.sublist(initialsEnd).join(' ');
|
||||||
|
if (restOfName.isNotEmpty && !variations.contains(restOfName)) {
|
||||||
|
variations.add(restOfName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try initials without periods + last name (e.g., "JRR Tolkien", "JK Rowling")
|
||||||
|
final initialsNoPeriods = parts.sublist(0, initialsEnd)
|
||||||
|
.map((s) => s.replaceAll('.', ''))
|
||||||
|
.join('');
|
||||||
|
final compactInitials = '$initialsNoPeriods ${parts.last}';
|
||||||
|
if (!variations.contains(compactInitials)) {
|
||||||
|
variations.add(compactInitials);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove duplicates while preserving order
|
||||||
|
return variations.toSet().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to fetch author image from Audnexus
|
||||||
|
/// Audnexus requires a two-step process:
|
||||||
|
/// 1. Search by name to get the ASIN
|
||||||
|
/// 2. Fetch full author details by ASIN to get the image
|
||||||
|
static Future<String?> _tryAudnexus(String name) async {
|
||||||
try {
|
try {
|
||||||
final audnexusUri = Uri.https(
|
// Step 1: Search for author to get ASIN
|
||||||
'api.audnex.us',
|
final searchUri = Uri.https('api.audnex.us', '/authors', {'name': name});
|
||||||
'/authors',
|
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
|
||||||
{'name': authorName},
|
|
||||||
);
|
|
||||||
|
|
||||||
final audnexusResponse = await http.get(audnexusUri).timeout(const Duration(seconds: 5));
|
if (searchResponse.statusCode == 200) {
|
||||||
|
final searchData = json.decode(searchResponse.body);
|
||||||
|
if (searchData is List && searchData.isNotEmpty) {
|
||||||
|
// Find the best match - prefer exact name matches
|
||||||
|
String? bestAsin;
|
||||||
|
final nameLower = name.toLowerCase().replaceAll(RegExp(r'[.\s]+'), '');
|
||||||
|
|
||||||
if (audnexusResponse.statusCode == 200) {
|
for (final result in searchData) {
|
||||||
final data = json.decode(audnexusResponse.body);
|
final resultName = (result['name'] as String?)?.toLowerCase().replaceAll(RegExp(r'[.\s]+'), '');
|
||||||
// Audnexus returns an array of authors
|
final asin = result['asin'] as String?;
|
||||||
if (data is List && data.isNotEmpty) {
|
if (asin != null) {
|
||||||
final author = data[0];
|
if (resultName == nameLower) {
|
||||||
final imageUrl = author['image'] as String?;
|
bestAsin = asin;
|
||||||
if (imageUrl != null && imageUrl.isNotEmpty) {
|
break; // Exact match found
|
||||||
_authorImageCache[cacheKey] = imageUrl;
|
}
|
||||||
return imageUrl;
|
bestAsin ??= asin; // Keep first result as fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestAsin != null) {
|
||||||
|
// Step 2: Fetch full author details by ASIN
|
||||||
|
final detailsUri = Uri.https('api.audnex.us', '/authors/$bestAsin');
|
||||||
|
final detailsResponse = await http.get(detailsUri).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
if (detailsResponse.statusCode == 200) {
|
||||||
|
final detailsData = json.decode(detailsResponse.body);
|
||||||
|
final imageUrl = detailsData['image'] as String?;
|
||||||
|
if (imageUrl != null && imageUrl.isNotEmpty) {
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.warning('Audnexus author image error: $e', context: 'Metadata');
|
_logger.warning('Audnexus author image error for "$name": $e', context: 'Metadata');
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Fall back to Open Library API
|
/// Try to fetch author image from Open Library
|
||||||
|
static Future<String?> _tryOpenLibrary(String name) async {
|
||||||
try {
|
try {
|
||||||
final uri = Uri.https(
|
final uri = Uri.https('openlibrary.org', '/search/authors.json', {
|
||||||
'openlibrary.org',
|
'q': name,
|
||||||
'/search/authors.json',
|
'limit': '1',
|
||||||
{
|
});
|
||||||
'q': authorName,
|
|
||||||
'limit': '1',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
final response = await http.get(uri).timeout(const Duration(seconds: 5));
|
final response = await http.get(uri).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = json.decode(response.body);
|
final data = json.decode(response.body);
|
||||||
final authors = data['docs'] as List?;
|
final authors = data['docs'] as List?;
|
||||||
if (authors != null && authors.isNotEmpty) {
|
if (authors != null && authors.isNotEmpty) {
|
||||||
final author = authors[0];
|
final authorKey = authors[0]['key'] as String?;
|
||||||
final authorKey = author['key'] as String?;
|
|
||||||
if (authorKey != null) {
|
if (authorKey != null) {
|
||||||
// Open Library author images are available at:
|
|
||||||
// https://covers.openlibrary.org/a/olid/{OLID}-L.jpg
|
|
||||||
final olid = authorKey.replaceAll('/authors/', '');
|
final olid = authorKey.replaceAll('/authors/', '');
|
||||||
final imageUrl = 'https://covers.openlibrary.org/a/olid/$olid-L.jpg';
|
final imageUrl = 'https://covers.openlibrary.org/a/olid/$olid-L.jpg';
|
||||||
|
|
||||||
// Verify the image actually exists (Open Library returns 404 for missing images)
|
// Verify image exists - Open Library returns 302 redirects for valid images
|
||||||
try {
|
try {
|
||||||
final imageCheck = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3));
|
final check = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3));
|
||||||
if (imageCheck.statusCode == 200) {
|
// Accept 200 (direct), 301/302 (redirect to archive.org), or 307/308 (temporary redirect)
|
||||||
_authorImageCache[cacheKey] = imageUrl;
|
if (check.statusCode == 200 || check.statusCode == 301 ||
|
||||||
|
check.statusCode == 302 || check.statusCode == 307 || check.statusCode == 308) {
|
||||||
return imageUrl;
|
return imageUrl;
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {}
|
||||||
// Image doesn't exist, continue to cache null
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.warning('Open Library author image error for "$name": $e', context: 'Metadata');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to fetch author image from Wikidata (structured Wikipedia data)
|
||||||
|
static Future<String?> _tryWikidata(String name) async {
|
||||||
|
try {
|
||||||
|
// Search for entity by name
|
||||||
|
final searchUri = Uri.https('www.wikidata.org', '/w/api.php', {
|
||||||
|
'action': 'wbsearchentities',
|
||||||
|
'search': name,
|
||||||
|
'language': 'en',
|
||||||
|
'type': 'item',
|
||||||
|
'limit': '3',
|
||||||
|
'format': 'json',
|
||||||
|
});
|
||||||
|
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
if (searchResponse.statusCode == 200) {
|
||||||
|
final searchData = json.decode(searchResponse.body);
|
||||||
|
final results = searchData['search'] as List?;
|
||||||
|
|
||||||
|
// Try each result to find one with an image
|
||||||
|
for (final result in results ?? []) {
|
||||||
|
final entityId = result['id'] as String?;
|
||||||
|
if (entityId == null) continue;
|
||||||
|
|
||||||
|
// Get the image property (P18)
|
||||||
|
final claimsUri = Uri.https('www.wikidata.org', '/w/api.php', {
|
||||||
|
'action': 'wbgetclaims',
|
||||||
|
'entity': entityId,
|
||||||
|
'property': 'P18', // P18 is the "image" property
|
||||||
|
'format': 'json',
|
||||||
|
});
|
||||||
|
final claimsResponse = await http.get(claimsUri).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
if (claimsResponse.statusCode == 200) {
|
||||||
|
final claimsData = json.decode(claimsResponse.body);
|
||||||
|
final claims = claimsData['claims'] as Map<String, dynamic>?;
|
||||||
|
final imageProperty = claims?['P18'] as List?;
|
||||||
|
|
||||||
|
if (imageProperty != null && imageProperty.isNotEmpty) {
|
||||||
|
final imageName = imageProperty[0]['mainsnak']?['datavalue']?['value'] as String?;
|
||||||
|
if (imageName != null && imageName.isNotEmpty) {
|
||||||
|
// Convert filename to Wikimedia Commons URL
|
||||||
|
final encodedName = Uri.encodeComponent(imageName.replaceAll(' ', '_'));
|
||||||
|
return 'https://commons.wikimedia.org/wiki/Special:FilePath/$encodedName?width=500';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.warning('Open Library author image error: $e', context: 'Metadata');
|
_logger.warning('Wikidata author image error for "$name": $e', context: 'Metadata');
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Fall back to Wikipedia API
|
/// Try to fetch author image from Wikipedia
|
||||||
|
static Future<String?> _tryWikipedia(String name) async {
|
||||||
try {
|
try {
|
||||||
// Search for author on Wikipedia
|
// Search for author - "writer author" suffix helps find the right page
|
||||||
final searchUri = Uri.https(
|
final searchUri = Uri.https('en.wikipedia.org', '/w/api.php', {
|
||||||
'en.wikipedia.org',
|
'action': 'query',
|
||||||
'/w/api.php',
|
'list': 'search',
|
||||||
{
|
'srsearch': '$name writer author',
|
||||||
'action': 'query',
|
'srlimit': '1',
|
||||||
'list': 'search',
|
'format': 'json',
|
||||||
'srsearch': '$authorName writer',
|
});
|
||||||
'srlimit': '1',
|
|
||||||
'format': 'json',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
|
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
if (searchResponse.statusCode == 200) {
|
if (searchResponse.statusCode == 200) {
|
||||||
final searchData = json.decode(searchResponse.body);
|
final searchData = json.decode(searchResponse.body);
|
||||||
final searchResults = searchData['query']?['search'] as List?;
|
final results = searchData['query']?['search'] as List?;
|
||||||
if (searchResults != null && searchResults.isNotEmpty) {
|
if (results != null && results.isNotEmpty) {
|
||||||
final pageTitle = searchResults[0]['title'] as String?;
|
final pageTitle = results[0]['title'] as String?;
|
||||||
if (pageTitle != null) {
|
if (pageTitle != null) {
|
||||||
// Get page image
|
// Get page image
|
||||||
final imageUri = Uri.https(
|
final imageUri = Uri.https('en.wikipedia.org', '/w/api.php', {
|
||||||
'en.wikipedia.org',
|
'action': 'query',
|
||||||
'/w/api.php',
|
'titles': pageTitle,
|
||||||
{
|
'prop': 'pageimages',
|
||||||
'action': 'query',
|
'pithumbsize': '500',
|
||||||
'titles': pageTitle,
|
'format': 'json',
|
||||||
'prop': 'pageimages',
|
});
|
||||||
'pithumbsize': '500',
|
|
||||||
'format': 'json',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
final imageResponse = await http.get(imageUri).timeout(const Duration(seconds: 5));
|
final imageResponse = await http.get(imageUri).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
if (imageResponse.statusCode == 200) {
|
if (imageResponse.statusCode == 200) {
|
||||||
@@ -450,7 +631,6 @@ class MetadataService {
|
|||||||
final thumbnail = page?['thumbnail'] as Map<String, dynamic>?;
|
final thumbnail = page?['thumbnail'] as Map<String, dynamic>?;
|
||||||
final imageUrl = thumbnail?['source'] as String?;
|
final imageUrl = thumbnail?['source'] as String?;
|
||||||
if (imageUrl != null && imageUrl.isNotEmpty) {
|
if (imageUrl != null && imageUrl.isNotEmpty) {
|
||||||
_authorImageCache[cacheKey] = imageUrl;
|
|
||||||
return imageUrl;
|
return imageUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -459,7 +639,100 @@ class MetadataService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.warning('Wikipedia author image error: $e', context: 'Metadata');
|
_logger.warning('Wikipedia author image error for "$name": $e', context: 'Metadata');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches author image URL from multiple sources with fuzzy name matching
|
||||||
|
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia
|
||||||
|
/// Tries name variations if exact match fails
|
||||||
|
static Future<String?> getAuthorImageUrl(String authorName) async {
|
||||||
|
// Check cache first
|
||||||
|
final cacheKey = 'authorImage:$authorName';
|
||||||
|
if (_authorImageCache.containsKey(cacheKey)) {
|
||||||
|
return _authorImageCache[cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle comma-separated author names (e.g., "Neil Gaiman, Dirk Maggs")
|
||||||
|
// Try to get image for the first/primary author
|
||||||
|
if (authorName.contains(',')) {
|
||||||
|
final authors = authorName.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
||||||
|
|
||||||
|
// Skip if this looks like "Lastname, Firstname" format or has suffixes
|
||||||
|
// - Second part is initials (J.R.R., J.K., etc.)
|
||||||
|
// - Second part is a suffix (Jr., Sr., III, PhD, etc.)
|
||||||
|
final suffixes = ['jr', 'jr.', 'sr', 'sr.', 'ii', 'iii', 'iv', 'phd', 'ph.d.', 'md', 'm.d.'];
|
||||||
|
if (authors.length == 2) {
|
||||||
|
final secondPart = authors[1].toLowerCase();
|
||||||
|
// Check if second part is a suffix or looks like initials
|
||||||
|
if (suffixes.contains(secondPart) ||
|
||||||
|
RegExp(r'^[a-z]\.?\s*[a-z]?\.?\s*[a-z]?\.?$').hasMatch(secondPart)) {
|
||||||
|
// This is "Lastname, First" or "Name, Jr." - don't split
|
||||||
|
} else if (authors[0].split(' ').length > 1 && authors[1].split(' ').length > 1) {
|
||||||
|
// Both parts have multiple words - likely "Author1, Author2"
|
||||||
|
for (final author in authors) {
|
||||||
|
final result = await getAuthorImageUrl(author);
|
||||||
|
if (result != null) {
|
||||||
|
_authorImageCache[cacheKey] = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Don't cache null here - fall through to try the full name
|
||||||
|
}
|
||||||
|
} else if (authors.length > 2) {
|
||||||
|
// Multiple commas = definitely multiple authors
|
||||||
|
for (final author in authors) {
|
||||||
|
final result = await getAuthorImageUrl(author);
|
||||||
|
if (result != null) {
|
||||||
|
_authorImageCache[cacheKey] = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Don't cache null - fall through to try full name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate name variations for fuzzy matching
|
||||||
|
final variations = _generateNameVariations(authorName);
|
||||||
|
|
||||||
|
// Try each source with all variations before moving to next source
|
||||||
|
// This prioritizes source quality over name variation
|
||||||
|
|
||||||
|
// 1. Try Audnexus (best for audiobook authors)
|
||||||
|
for (final name in variations) {
|
||||||
|
final result = await _tryAudnexus(name);
|
||||||
|
if (result != null) {
|
||||||
|
_authorImageCache[cacheKey] = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try Open Library
|
||||||
|
for (final name in variations) {
|
||||||
|
final result = await _tryOpenLibrary(name);
|
||||||
|
if (result != null) {
|
||||||
|
_authorImageCache[cacheKey] = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try Wikidata (structured data, good for finding exact person)
|
||||||
|
for (final name in variations.take(2)) {
|
||||||
|
final wikidataResult = await _tryWikidata(name);
|
||||||
|
if (wikidataResult != null) {
|
||||||
|
_authorImageCache[cacheKey] = wikidataResult;
|
||||||
|
return wikidataResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Try Wikipedia (search-based, good fallback)
|
||||||
|
for (final name in variations.take(3)) {
|
||||||
|
final wikiResult = await _tryWikipedia(name);
|
||||||
|
if (wikiResult != null) {
|
||||||
|
_authorImageCache[cacheKey] = wikiResult;
|
||||||
|
return wikiResult;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the null result to avoid repeated failed lookups
|
// Cache the null result to avoid repeated failed lookups
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:uuid/uuid.dart';
|
|||||||
import '../constants/network.dart';
|
import '../constants/network.dart';
|
||||||
import '../constants/timings.dart' show Timings, LibraryConstants;
|
import '../constants/timings.dart' show Timings, LibraryConstants;
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
|
import '../models/music_provider.dart';
|
||||||
import '../models/player.dart';
|
import '../models/player.dart';
|
||||||
import 'debug_logger.dart';
|
import 'debug_logger.dart';
|
||||||
import 'settings_service.dart';
|
import 'settings_service.dart';
|
||||||
@@ -52,6 +53,10 @@ class MusicAssistantAPI {
|
|||||||
bool _authRequired = false;
|
bool _authRequired = false;
|
||||||
int? _schemaVersion;
|
int? _schemaVersion;
|
||||||
|
|
||||||
|
// Music providers (type == "music")
|
||||||
|
List<MusicProvider> _musicProviders = [];
|
||||||
|
List<MusicProvider> get musicProviders => _musicProviders;
|
||||||
|
|
||||||
// Auth state
|
// Auth state
|
||||||
bool _isAuthenticated = false;
|
bool _isAuthenticated = false;
|
||||||
bool get isAuthenticated => _isAuthenticated;
|
bool get isAuthenticated => _isAuthenticated;
|
||||||
@@ -376,23 +381,30 @@ class MusicAssistantAPI {
|
|||||||
String? search,
|
String? search,
|
||||||
bool? favoriteOnly,
|
bool? favoriteOnly,
|
||||||
bool albumArtistsOnly = true,
|
bool albumArtistsOnly = true,
|
||||||
|
String? orderBy,
|
||||||
|
String? provider, // Filter to specific provider instance
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
|
final args = {
|
||||||
|
if (limit != null) 'limit': limit,
|
||||||
|
if (offset != null) 'offset': offset,
|
||||||
|
if (search != null) 'search': search,
|
||||||
|
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||||
|
'album_artists_only': albumArtistsOnly,
|
||||||
|
if (orderBy != null) 'order_by': orderBy,
|
||||||
|
if (provider != null) 'provider': provider,
|
||||||
|
};
|
||||||
|
_logger.log('🔍 API getArtists args: $args');
|
||||||
final response = await _sendCommand(
|
final response = await _sendCommand(
|
||||||
'music/artists/library_items',
|
'music/artists/library_items',
|
||||||
args: {
|
args: args,
|
||||||
if (limit != null) 'limit': limit,
|
|
||||||
if (offset != null) 'offset': offset,
|
|
||||||
if (search != null) 'search': search,
|
|
||||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
|
||||||
'album_artists_only': albumArtistsOnly,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final items = response['result'] as List<dynamic>?;
|
final items = response['result'] as List<dynamic>?;
|
||||||
if (items == null) {
|
if (items == null) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
_logger.log('🔍 API getArtists returned ${items.length} items');
|
||||||
return items
|
return items
|
||||||
.map((item) => Artist.fromJson(item as Map<String, dynamic>))
|
.map((item) => Artist.fromJson(item as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
@@ -402,7 +414,7 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Artist>> getRandomArtists({int limit = 10, bool albumArtistsOnly = true}) async {
|
Future<List<Artist>> getRandomArtists({int limit = 10, bool albumArtistsOnly = true, String? provider}) async {
|
||||||
try {
|
try {
|
||||||
final response = await _sendCommand(
|
final response = await _sendCommand(
|
||||||
'music/artists/library_items',
|
'music/artists/library_items',
|
||||||
@@ -410,6 +422,7 @@ class MusicAssistantAPI {
|
|||||||
'limit': limit,
|
'limit': limit,
|
||||||
'order_by': 'random',
|
'order_by': 'random',
|
||||||
'album_artists_only': albumArtistsOnly,
|
'album_artists_only': albumArtistsOnly,
|
||||||
|
if (provider != null) 'provider': provider,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -431,6 +444,8 @@ class MusicAssistantAPI {
|
|||||||
String? search,
|
String? search,
|
||||||
bool? favoriteOnly,
|
bool? favoriteOnly,
|
||||||
String? artistId,
|
String? artistId,
|
||||||
|
String? orderBy,
|
||||||
|
String? provider, // Filter to specific provider instance
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final args = <String, dynamic>{
|
final args = <String, dynamic>{
|
||||||
@@ -439,7 +454,10 @@ class MusicAssistantAPI {
|
|||||||
if (search != null) 'search': search,
|
if (search != null) 'search': search,
|
||||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||||
if (artistId != null) 'artist_id': artistId,
|
if (artistId != null) 'artist_id': artistId,
|
||||||
|
if (orderBy != null) 'order_by': orderBy,
|
||||||
|
if (provider != null) 'provider': provider,
|
||||||
};
|
};
|
||||||
|
_logger.log('🔍 API getAlbums args: $args');
|
||||||
|
|
||||||
final response = await _sendCommand(
|
final response = await _sendCommand(
|
||||||
'music/albums/library_items',
|
'music/albums/library_items',
|
||||||
@@ -448,6 +466,7 @@ class MusicAssistantAPI {
|
|||||||
|
|
||||||
final items = response['result'] as List<dynamic>?;
|
final items = response['result'] as List<dynamic>?;
|
||||||
if (items == null) return [];
|
if (items == null) return [];
|
||||||
|
_logger.log('🔍 API getAlbums returned ${items.length} items');
|
||||||
|
|
||||||
return items
|
return items
|
||||||
.map((item) => Album.fromJson(item as Map<String, dynamic>))
|
.map((item) => Album.fromJson(item as Map<String, dynamic>))
|
||||||
@@ -465,6 +484,8 @@ class MusicAssistantAPI {
|
|||||||
bool? favoriteOnly,
|
bool? favoriteOnly,
|
||||||
String? artistId,
|
String? artistId,
|
||||||
String? albumId,
|
String? albumId,
|
||||||
|
String? orderBy,
|
||||||
|
String? provider, // Filter to specific provider instance
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await _sendCommand(
|
final response = await _sendCommand(
|
||||||
@@ -473,9 +494,11 @@ class MusicAssistantAPI {
|
|||||||
if (limit != null) 'limit': limit,
|
if (limit != null) 'limit': limit,
|
||||||
if (offset != null) 'offset': offset,
|
if (offset != null) 'offset': offset,
|
||||||
if (search != null) 'search': search,
|
if (search != null) 'search': search,
|
||||||
|
if (provider != null) 'provider': provider,
|
||||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||||
if (artistId != null) 'artist': artistId,
|
if (artistId != null) 'artist': artistId,
|
||||||
if (albumId != null) 'album': albumId,
|
if (albumId != null) 'album': albumId,
|
||||||
|
if (orderBy != null) 'order_by': orderBy,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -496,6 +519,7 @@ class MusicAssistantAPI {
|
|||||||
int? limit,
|
int? limit,
|
||||||
int? offset,
|
int? offset,
|
||||||
bool? favoriteOnly,
|
bool? favoriteOnly,
|
||||||
|
String? orderBy,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await _sendCommand(
|
final response = await _sendCommand(
|
||||||
@@ -504,6 +528,7 @@ class MusicAssistantAPI {
|
|||||||
if (limit != null) 'limit': limit,
|
if (limit != null) 'limit': limit,
|
||||||
if (offset != null) 'offset': offset,
|
if (offset != null) 'offset': offset,
|
||||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||||
|
if (orderBy != null) 'order_by': orderBy,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -555,6 +580,7 @@ class MusicAssistantAPI {
|
|||||||
int? limit,
|
int? limit,
|
||||||
int? offset,
|
int? offset,
|
||||||
bool? favoriteOnly,
|
bool? favoriteOnly,
|
||||||
|
String? orderBy,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await _sendCommand(
|
final response = await _sendCommand(
|
||||||
@@ -563,6 +589,7 @@ class MusicAssistantAPI {
|
|||||||
if (limit != null) 'limit': limit,
|
if (limit != null) 'limit': limit,
|
||||||
if (offset != null) 'offset': offset,
|
if (offset != null) 'offset': offset,
|
||||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||||
|
if (orderBy != null) 'order_by': orderBy,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1082,7 +1109,7 @@ class MusicAssistantAPI {
|
|||||||
for (final item in providerRoot) {
|
for (final item in providerRoot) {
|
||||||
final itemMap = item as Map<String, dynamic>;
|
final itemMap = item as Map<String, dynamic>;
|
||||||
final path = itemMap['path'] as String? ?? '';
|
final path = itemMap['path'] as String? ?? '';
|
||||||
final name = itemMap['name'] as String? ?? '';
|
final name = (itemMap['name'] as String?) ?? (itemMap['label'] as String?) ?? '';
|
||||||
if (path != 'root' && name != '..') {
|
if (path != 'root' && name != '..') {
|
||||||
discoveredLibraries.add({'path': path, 'name': name});
|
discoveredLibraries.add({'path': path, 'name': name});
|
||||||
}
|
}
|
||||||
@@ -1101,7 +1128,7 @@ class MusicAssistantAPI {
|
|||||||
for (final libraryItem in providerRoot) {
|
for (final libraryItem in providerRoot) {
|
||||||
final libraryMap = libraryItem as Map<String, dynamic>;
|
final libraryMap = libraryItem as Map<String, dynamic>;
|
||||||
final libraryPath = libraryMap['path'] as String? ?? '';
|
final libraryPath = libraryMap['path'] as String? ?? '';
|
||||||
final libraryName = libraryMap['name'] as String? ?? '';
|
final libraryName = (libraryMap['name'] as String?) ?? (libraryMap['label'] as String?) ?? '';
|
||||||
|
|
||||||
// Skip "root" (parent navigation) item
|
// Skip "root" (parent navigation) item
|
||||||
if (libraryPath == 'root' || libraryName == '..') continue;
|
if (libraryPath == 'root' || libraryName == '..') continue;
|
||||||
@@ -1121,7 +1148,7 @@ class MusicAssistantAPI {
|
|||||||
// Log library contents to find series
|
// Log library contents to find series
|
||||||
for (final item in libraryContents) {
|
for (final item in libraryContents) {
|
||||||
final itemMap = item as Map<String, dynamic>;
|
final itemMap = item as Map<String, dynamic>;
|
||||||
final name = itemMap['name'] as String? ?? '';
|
final name = (itemMap['name'] as String?) ?? (itemMap['label'] as String?) ?? '';
|
||||||
final path = itemMap['path'] as String? ?? '';
|
final path = itemMap['path'] as String? ?? '';
|
||||||
_logger.log('📚 Library item: name=$name, path=$path');
|
_logger.log('📚 Library item: name=$name, path=$path');
|
||||||
}
|
}
|
||||||
@@ -1129,10 +1156,11 @@ class MusicAssistantAPI {
|
|||||||
// Look for "Series" folder in the library
|
// Look for "Series" folder in the library
|
||||||
for (final item in libraryContents) {
|
for (final item in libraryContents) {
|
||||||
final itemMap = item as Map<String, dynamic>;
|
final itemMap = item as Map<String, dynamic>;
|
||||||
final name = (itemMap['name'] as String? ?? '').toLowerCase();
|
final name = ((itemMap['name'] as String?) ?? (itemMap['label'] as String?) ?? '').toLowerCase();
|
||||||
final path = itemMap['path'] as String? ?? '';
|
final path = itemMap['path'] as String? ?? '';
|
||||||
|
|
||||||
if (name.contains('series') || path.contains('series')) {
|
// Check for "series" in name/path, or abbreviated path ending with "/s"
|
||||||
|
if (name.contains('series') || path.contains('series') || path.endsWith('/s')) {
|
||||||
_logger.log('📚 Found series folder: $path');
|
_logger.log('📚 Found series folder: $path');
|
||||||
|
|
||||||
// Browse the series folder
|
// Browse the series folder
|
||||||
@@ -1141,13 +1169,15 @@ class MusicAssistantAPI {
|
|||||||
|
|
||||||
for (final seriesItem in seriesItems) {
|
for (final seriesItem in seriesItems) {
|
||||||
final seriesMap = seriesItem as Map<String, dynamic>;
|
final seriesMap = seriesItem as Map<String, dynamic>;
|
||||||
|
final seriesName = (seriesMap['name'] as String?) ?? (seriesMap['label'] as String?) ?? '';
|
||||||
|
final seriesPath = seriesMap['path'] as String? ?? '';
|
||||||
// Skip parent navigation
|
// Skip parent navigation
|
||||||
if (seriesMap['path'] == 'root' || seriesMap['name'] == '..') continue;
|
if (seriesPath == 'root' || seriesName == '..' || seriesPath.endsWith('://')) continue;
|
||||||
// Skip actual audiobook items (we only want series folders)
|
// Skip actual audiobook items (we only want series folders)
|
||||||
final mediaType = seriesMap['media_type'] as String?;
|
final mediaType = seriesMap['media_type'] as String?;
|
||||||
if (mediaType == 'audiobook' || mediaType == 'track') continue;
|
if (mediaType == 'audiobook' || mediaType == 'track') continue;
|
||||||
|
|
||||||
_logger.log('📚 Series: name=${seriesMap['name']}, path=${seriesMap['path']}, media_type=$mediaType');
|
_logger.log('📚 Series: name=$seriesName, path=$seriesPath, media_type=$mediaType');
|
||||||
allSeries.add(AudiobookSeries.fromJson(seriesMap));
|
allSeries.add(AudiobookSeries.fromJson(seriesMap));
|
||||||
}
|
}
|
||||||
break; // Found series in this library, move to next library
|
break; // Found series in this library, move to next library
|
||||||
@@ -1335,13 +1365,14 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get random albums
|
/// Get random albums
|
||||||
Future<List<Album>> getRandomAlbums({int limit = 10}) async {
|
Future<List<Album>> getRandomAlbums({int limit = 10, String? provider}) async {
|
||||||
try {
|
try {
|
||||||
final response = await _sendCommand(
|
final response = await _sendCommand(
|
||||||
'music/albums/library_items',
|
'music/albums/library_items',
|
||||||
args: {
|
args: {
|
||||||
'limit': limit,
|
'limit': limit,
|
||||||
'order_by': 'random',
|
'order_by': 'random',
|
||||||
|
if (provider != null) 'provider': provider,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1457,6 +1488,7 @@ class MusicAssistantAPI {
|
|||||||
int? offset,
|
int? offset,
|
||||||
String? search,
|
String? search,
|
||||||
bool? favoriteOnly,
|
bool? favoriteOnly,
|
||||||
|
String? orderBy,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await _sendCommand(
|
final response = await _sendCommand(
|
||||||
@@ -1466,6 +1498,7 @@ class MusicAssistantAPI {
|
|||||||
if (offset != null) 'offset': offset,
|
if (offset != null) 'offset': offset,
|
||||||
if (search != null) 'search': search,
|
if (search != null) 'search': search,
|
||||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||||
|
if (orderBy != null) 'order_by': orderBy,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1487,7 +1520,7 @@ class MusicAssistantAPI {
|
|||||||
final response = await _sendCommand(
|
final response = await _sendCommand(
|
||||||
'music/playlist',
|
'music/playlist',
|
||||||
args: {
|
args: {
|
||||||
'provider': provider,
|
'provider_instance_id_or_domain': provider,
|
||||||
'item_id': itemId,
|
'item_id': itemId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -2469,15 +2502,16 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move an item to a new position in the queue
|
/// Move an item in the queue by a relative position shift
|
||||||
Future<void> queueCommandMoveItem(String queueId, String itemId, int newIndex) async {
|
/// pos_shift > 0: move down, pos_shift < 0: move up, pos_shift = 0: move to next
|
||||||
|
Future<void> queueCommandMoveItem(String queueId, String itemId, int posShift) async {
|
||||||
try {
|
try {
|
||||||
await _sendCommand(
|
await _sendCommand(
|
||||||
'player_queues/move_item',
|
'player_queues/move_item',
|
||||||
args: {
|
args: {
|
||||||
'queue_id': queueId,
|
'queue_id': queueId,
|
||||||
'item_id_or_index': itemId,
|
'queue_item_id': itemId,
|
||||||
'new_index': newIndex,
|
'pos_shift': posShift,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -2486,6 +2520,75 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Play a specific item in the queue by its queue_item_id
|
||||||
|
Future<void> queueCommandPlayIndex(String queueId, String queueItemId) async {
|
||||||
|
try {
|
||||||
|
await _sendCommand(
|
||||||
|
'player_queues/play_index',
|
||||||
|
args: {
|
||||||
|
'queue_id': queueId,
|
||||||
|
'index': queueItemId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error playing queue item: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all items from the queue
|
||||||
|
Future<void> queueCommandClear(String queueId) async {
|
||||||
|
try {
|
||||||
|
await _sendCommand(
|
||||||
|
'player_queues/clear',
|
||||||
|
args: {
|
||||||
|
'queue_id': queueId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error clearing queue: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transfer the current queue to another player
|
||||||
|
/// This moves the entire playback queue from source player to target player,
|
||||||
|
/// including shuffle and repeat settings.
|
||||||
|
///
|
||||||
|
/// [sourceQueueId] - The queue ID of the source player (optional, uses first playing player if null)
|
||||||
|
/// [targetQueueId] - The queue ID of the target player (required)
|
||||||
|
/// [autoPlay] - Whether to automatically start playback on the target player (default: true)
|
||||||
|
Future<void> transferQueue({
|
||||||
|
String? sourceQueueId,
|
||||||
|
required String targetQueueId,
|
||||||
|
bool autoPlay = true,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
_logger.log('🔄 Transferring queue to player: $targetQueueId (source: ${sourceQueueId ?? "auto"}, autoPlay: $autoPlay)');
|
||||||
|
|
||||||
|
final args = <String, dynamic>{
|
||||||
|
'target_queue_id': targetQueueId,
|
||||||
|
'auto_play': autoPlay,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include source_queue_id if explicitly provided
|
||||||
|
// When null, MA will use the first playing player
|
||||||
|
if (sourceQueueId != null) {
|
||||||
|
args['source_queue_id'] = sourceQueueId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _sendCommand(
|
||||||
|
'player_queues/transfer',
|
||||||
|
args: args,
|
||||||
|
);
|
||||||
|
|
||||||
|
_logger.log('✅ Queue transferred successfully');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Error transferring queue: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// BUILT-IN PLAYER MANAGEMENT
|
// BUILT-IN PLAYER MANAGEMENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2529,6 +2632,23 @@ class MusicAssistantAPI {
|
|||||||
return _eventStreams['media_item_deleted']!.stream;
|
return _eventStreams['media_item_deleted']!.stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stream of queue_updated events (for queue metadata changes)
|
||||||
|
Stream<Map<String, dynamic>> get queueUpdatedEvents {
|
||||||
|
if (!_eventStreams.containsKey('queue_updated')) {
|
||||||
|
_eventStreams['queue_updated'] = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
}
|
||||||
|
return _eventStreams['queue_updated']!.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream of queue_items_updated events (for queue item add/remove/reorder)
|
||||||
|
/// This is critical for keeping queue UI in sync, especially in radio mode
|
||||||
|
Stream<Map<String, dynamic>> get queueItemsUpdatedEvents {
|
||||||
|
if (!_eventStreams.containsKey('queue_items_updated')) {
|
||||||
|
_eventStreams['queue_items_updated'] = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
}
|
||||||
|
return _eventStreams['queue_items_updated']!.stream;
|
||||||
|
}
|
||||||
|
|
||||||
/// Register this device as a player with retry logic
|
/// Register this device as a player with retry logic
|
||||||
/// CRITICAL: This creates a player config in MA's settings.json
|
/// CRITICAL: This creates a player config in MA's settings.json
|
||||||
/// The server expects: player_id and player_name
|
/// The server expects: player_id and player_name
|
||||||
@@ -3179,10 +3299,22 @@ class MusicAssistantAPI {
|
|||||||
_logger.log('⚠️ Could not load provider manifests: $e');
|
_logger.log('⚠️ Could not load provider manifests: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch provider instances (active providers)
|
// Fetch provider instances (active providers) and extract music providers
|
||||||
try {
|
try {
|
||||||
await _sendCommand('providers');
|
final response = await _sendCommand('providers');
|
||||||
_logger.log('✓ Provider instances loaded');
|
final providers = response['result'] as List<dynamic>?;
|
||||||
|
if (providers != null) {
|
||||||
|
_musicProviders = providers
|
||||||
|
.where((p) => (p as Map<String, dynamic>)['type'] == 'music')
|
||||||
|
.map((p) => MusicProvider.fromJson(p as Map<String, dynamic>))
|
||||||
|
.where((p) => p.available) // Only include available providers
|
||||||
|
.where((p) => p.domain != 'music_assistant') // Exclude MA itself
|
||||||
|
.toList();
|
||||||
|
_logger.log('✓ Found ${_musicProviders.length} music providers:');
|
||||||
|
for (final p in _musicProviders) {
|
||||||
|
_logger.log(' - ${p.name}: instanceId="${p.instanceId}", domain="${p.domain}"');
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('⚠️ Could not load provider instances: $e');
|
_logger.log('⚠️ Could not load provider instances: $e');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,17 @@ class SecureStorageService {
|
|||||||
|
|
||||||
// Auth Token (for stream requests)
|
// Auth Token (for stream requests)
|
||||||
static Future<String?> getAuthToken() async {
|
static Future<String?> getAuthToken() async {
|
||||||
return await _storage.read(key: _keyAuthToken);
|
try {
|
||||||
|
return await _storage.read(key: _keyAuthToken);
|
||||||
|
} catch (e) {
|
||||||
|
// Handle decryption errors (e.g., after keystore/signature change)
|
||||||
|
if (e.toString().contains('BadPaddingException') ||
|
||||||
|
e.toString().contains('BAD_DECRYPT')) {
|
||||||
|
await _storage.delete(key: _keyAuthToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setAuthToken(String? token) async {
|
static Future<void> setAuthToken(String? token) async {
|
||||||
@@ -33,7 +43,17 @@ class SecureStorageService {
|
|||||||
|
|
||||||
// Music Assistant Native Auth Token (long-lived token)
|
// Music Assistant Native Auth Token (long-lived token)
|
||||||
static Future<String?> getMaAuthToken() async {
|
static Future<String?> getMaAuthToken() async {
|
||||||
return await _storage.read(key: _keyMaAuthToken);
|
try {
|
||||||
|
return await _storage.read(key: _keyMaAuthToken);
|
||||||
|
} catch (e) {
|
||||||
|
// Handle decryption errors (e.g., after keystore/signature change)
|
||||||
|
if (e.toString().contains('BadPaddingException') ||
|
||||||
|
e.toString().contains('BAD_DECRYPT')) {
|
||||||
|
await _storage.delete(key: _keyMaAuthToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setMaAuthToken(String? token) async {
|
static Future<void> setMaAuthToken(String? token) async {
|
||||||
@@ -50,7 +70,18 @@ class SecureStorageService {
|
|||||||
|
|
||||||
// Auth Credentials (serialized auth strategy credentials)
|
// Auth Credentials (serialized auth strategy credentials)
|
||||||
static Future<Map<String, dynamic>?> getAuthCredentials() async {
|
static Future<Map<String, dynamic>?> getAuthCredentials() async {
|
||||||
final json = await _storage.read(key: _keyAuthCredentials);
|
String? json;
|
||||||
|
try {
|
||||||
|
json = await _storage.read(key: _keyAuthCredentials);
|
||||||
|
} catch (e) {
|
||||||
|
// Handle decryption errors (e.g., after keystore/signature change)
|
||||||
|
if (e.toString().contains('BadPaddingException') ||
|
||||||
|
e.toString().contains('BAD_DECRYPT')) {
|
||||||
|
await _storage.delete(key: _keyAuthCredentials);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
if (json == null) return null;
|
if (json == null) return null;
|
||||||
try {
|
try {
|
||||||
return jsonDecode(json) as Map<String, dynamic>;
|
return jsonDecode(json) as Map<String, dynamic>;
|
||||||
@@ -69,7 +100,17 @@ class SecureStorageService {
|
|||||||
|
|
||||||
// Password
|
// Password
|
||||||
static Future<String?> getPassword() async {
|
static Future<String?> getPassword() async {
|
||||||
return await _storage.read(key: _keyPassword);
|
try {
|
||||||
|
return await _storage.read(key: _keyPassword);
|
||||||
|
} catch (e) {
|
||||||
|
// Handle decryption errors (e.g., after keystore/signature change)
|
||||||
|
if (e.toString().contains('BadPaddingException') ||
|
||||||
|
e.toString().contains('BAD_DECRYPT')) {
|
||||||
|
await _storage.delete(key: _keyPassword);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setPassword(String? password) async {
|
static Future<void> setPassword(String? password) async {
|
||||||
@@ -82,7 +123,17 @@ class SecureStorageService {
|
|||||||
|
|
||||||
// Audiobookshelf API Token
|
// Audiobookshelf API Token
|
||||||
static Future<String?> getAbsApiToken() async {
|
static Future<String?> getAbsApiToken() async {
|
||||||
return await _storage.read(key: _keyAbsApiToken);
|
try {
|
||||||
|
return await _storage.read(key: _keyAbsApiToken);
|
||||||
|
} catch (e) {
|
||||||
|
// Handle decryption errors (e.g., after keystore/signature change)
|
||||||
|
if (e.toString().contains('BadPaddingException') ||
|
||||||
|
e.toString().contains('BAD_DECRYPT')) {
|
||||||
|
await _storage.delete(key: _keyAbsApiToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setAbsApiToken(String? token) async {
|
static Future<void> setAbsApiToken(String? token) async {
|
||||||
@@ -98,12 +149,21 @@ class SecureStorageService {
|
|||||||
await _storage.deleteAll();
|
await _storage.deleteAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration completed flag key
|
||||||
|
static const String _keyMigrationCompleted = 'secure_storage_migration_completed';
|
||||||
|
|
||||||
/// Migrate credentials from SharedPreferences to secure storage.
|
/// Migrate credentials from SharedPreferences to secure storage.
|
||||||
/// Call this once during app upgrade to migrate existing users.
|
/// Call this once during app upgrade to migrate existing users.
|
||||||
|
/// Uses a flag to skip migration on subsequent runs for performance.
|
||||||
static Future<void> migrateFromSharedPreferences() async {
|
static Future<void> migrateFromSharedPreferences() async {
|
||||||
// Import SharedPreferences for migration
|
// Import SharedPreferences for migration
|
||||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
// Skip if migration already completed
|
||||||
|
if (prefs.getBool(_keyMigrationCompleted) == true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Migrate auth token
|
// Migrate auth token
|
||||||
final authToken = prefs.getString('auth_token');
|
final authToken = prefs.getString('auth_token');
|
||||||
if (authToken != null && authToken.isNotEmpty) {
|
if (authToken != null && authToken.isNotEmpty) {
|
||||||
@@ -144,5 +204,8 @@ class SecureStorageService {
|
|||||||
await setAbsApiToken(absApiToken);
|
await setAbsApiToken(absApiToken);
|
||||||
await prefs.remove('abs_api_token');
|
await prefs.remove('abs_api_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark migration as completed to skip on future runs
|
||||||
|
await prefs.setBool(_keyMigrationCompleted, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,8 @@ class SyncService with ChangeNotifier {
|
|||||||
|
|
||||||
/// Sync library data from MA API in background
|
/// Sync library data from MA API in background
|
||||||
/// Updates database cache and notifies listeners when complete
|
/// Updates database cache and notifies listeners when complete
|
||||||
Future<void> syncFromApi(MusicAssistantAPI api, {bool force = false}) async {
|
/// If provider is specified, only fetch items from that provider (server-side filtering)
|
||||||
|
Future<void> syncFromApi(MusicAssistantAPI api, {bool force = false, String? provider}) async {
|
||||||
if (_isSyncing) {
|
if (_isSyncing) {
|
||||||
_logger.log('🔄 Sync already in progress, skipping');
|
_logger.log('🔄 Sync already in progress, skipping');
|
||||||
return;
|
return;
|
||||||
@@ -149,16 +150,17 @@ class SyncService with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_logger.log('🔄 Starting background library sync...');
|
_logger.log('🔄 Starting background library sync (provider=$provider)...');
|
||||||
|
|
||||||
// Read artist filter setting - when ON, only fetch artists that have albums
|
// Read artist filter setting - when ON, only fetch artists that have albums
|
||||||
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||||
_logger.log('🎨 Sync using albumArtistsOnly: $showOnlyArtistsWithAlbums');
|
_logger.log('🎨 Sync using albumArtistsOnly: $showOnlyArtistsWithAlbums, provider: $provider');
|
||||||
|
|
||||||
// Fetch fresh data from MA API (in parallel for speed)
|
// Fetch fresh data from MA API (in parallel for speed)
|
||||||
|
// If provider is specified, filter to only that provider's library items
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
api.getAlbums(limit: 1000),
|
api.getAlbums(limit: 1000, provider: provider),
|
||||||
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums),
|
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums, provider: provider),
|
||||||
api.getAudiobooks(limit: 1000),
|
api.getAudiobooks(limit: 1000),
|
||||||
api.getPlaylists(limit: 1000),
|
api.getPlaylists(limit: 1000),
|
||||||
]);
|
]);
|
||||||
@@ -273,11 +275,12 @@ class SyncService with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Force a fresh sync (for pull-to-refresh)
|
/// Force a fresh sync (for pull-to-refresh)
|
||||||
Future<void> forceSync(MusicAssistantAPI api) async {
|
Future<void> forceSync(MusicAssistantAPI api, {String? provider}) async {
|
||||||
await syncFromApi(api, force: true);
|
await syncFromApi(api, force: true, provider: provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all cached data
|
/// Clear all cached data and reset sync state
|
||||||
|
/// This allows a fresh sync to start even if one was in progress
|
||||||
Future<void> clearCache() async {
|
Future<void> clearCache() async {
|
||||||
if (!_db.isInitialized) return;
|
if (!_db.isInitialized) return;
|
||||||
|
|
||||||
@@ -287,9 +290,10 @@ class SyncService with ChangeNotifier {
|
|||||||
_cachedAudiobooks = [];
|
_cachedAudiobooks = [];
|
||||||
_cachedPlaylists = [];
|
_cachedPlaylists = [];
|
||||||
_lastSyncTime = null;
|
_lastSyncTime = null;
|
||||||
|
_isSyncing = false; // Reset so a new sync can start
|
||||||
_status = SyncStatus.idle;
|
_status = SyncStatus.idle;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_logger.log('🗑️ Library cache cleared');
|
_logger.log('🗑️ Library cache cleared (sync state reset)');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get albums (from cache or empty if not loaded)
|
/// Get albums (from cache or empty if not loaded)
|
||||||
|
|||||||
@@ -124,3 +124,51 @@ class Durations {
|
|||||||
static const Duration normal = Duration(milliseconds: 300);
|
static const Duration normal = Duration(milliseconds: 300);
|
||||||
static const Duration slow = Duration(milliseconds: 500);
|
static const Duration slow = Duration(milliseconds: 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Button size tokens
|
||||||
|
class ButtonSizes {
|
||||||
|
ButtonSizes._();
|
||||||
|
|
||||||
|
// Touch target sizes (Material Design minimum: 48x48)
|
||||||
|
static const double sm = 32.0;
|
||||||
|
static const double md = 36.0;
|
||||||
|
static const double lg = 44.0;
|
||||||
|
static const double xl = 48.0;
|
||||||
|
static const double xxl = 56.0;
|
||||||
|
|
||||||
|
// Icon sizes within buttons
|
||||||
|
static const double iconSm = 16.0;
|
||||||
|
static const double iconMd = 18.0;
|
||||||
|
static const double iconLg = 20.0;
|
||||||
|
static const double iconXl = 24.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Semantic status colors for consistent feedback
|
||||||
|
/// Use with Theme brightness for proper contrast
|
||||||
|
class StatusColors {
|
||||||
|
StatusColors._();
|
||||||
|
|
||||||
|
// Success states
|
||||||
|
static const Color success = Color(0xFF4CAF50);
|
||||||
|
static const Color successLight = Color(0xFF81C784);
|
||||||
|
static const Color successDark = Color(0xFF388E3C);
|
||||||
|
|
||||||
|
// Error states
|
||||||
|
static const Color error = Color(0xFFF44336);
|
||||||
|
static const Color errorLight = Color(0xFFE57373);
|
||||||
|
static const Color errorDark = Color(0xFFD32F2F);
|
||||||
|
|
||||||
|
// Warning states
|
||||||
|
static const Color warning = Color(0xFFFF9800);
|
||||||
|
static const Color warningLight = Color(0xFFFFB74D);
|
||||||
|
static const Color warningDark = Color(0xFFF57C00);
|
||||||
|
|
||||||
|
// Info states
|
||||||
|
static const Color info = Color(0xFF2196F3);
|
||||||
|
static const Color infoLight = Color(0xFF64B5F6);
|
||||||
|
static const Color infoDark = Color(0xFF1976D2);
|
||||||
|
|
||||||
|
// Favorites (heart icon)
|
||||||
|
static const Color favorite = Color(0xFFF44336);
|
||||||
|
static const Color favoriteActive = Color(0xFFE53935);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ final _themeLogger = DebugLogger();
|
|||||||
|
|
||||||
/// Global function to update adaptive colors from an image URL
|
/// Global function to update adaptive colors from an image URL
|
||||||
/// This can be called from anywhere in the app (e.g., when tapping an album/artist)
|
/// This can be called from anywhere in the app (e.g., when tapping an album/artist)
|
||||||
|
/// Sets isOnDetailScreen=true so nav bar will also use adaptive colors
|
||||||
Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUrl) async {
|
Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUrl) async {
|
||||||
if (imageUrl == null || imageUrl.isEmpty) return;
|
if (imageUrl == null || imageUrl.isEmpty) return;
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUr
|
|||||||
final colorSchemes = await PaletteHelper.extractColorSchemes(CachedNetworkImageProvider(imageUrl));
|
final colorSchemes = await PaletteHelper.extractColorSchemes(CachedNetworkImageProvider(imageUrl));
|
||||||
if (colorSchemes != null && context.mounted) {
|
if (colorSchemes != null && context.mounted) {
|
||||||
final themeProvider = context.read<ThemeProvider>();
|
final themeProvider = context.read<ThemeProvider>();
|
||||||
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2);
|
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently fail - colors will update when track plays
|
// Silently fail - colors will update when track plays
|
||||||
@@ -42,6 +43,10 @@ class ThemeProvider extends ChangeNotifier {
|
|||||||
ColorScheme? _adaptiveLightScheme;
|
ColorScheme? _adaptiveLightScheme;
|
||||||
ColorScheme? _adaptiveDarkScheme;
|
ColorScheme? _adaptiveDarkScheme;
|
||||||
|
|
||||||
|
// Track if we're on a detail screen (vs just player-extracted colors)
|
||||||
|
// When true, nav bar should use adaptive colors even when player is collapsed
|
||||||
|
bool _isOnDetailScreen = false;
|
||||||
|
|
||||||
ThemeProvider() {
|
ThemeProvider() {
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
}
|
}
|
||||||
@@ -55,6 +60,7 @@ class ThemeProvider extends ChangeNotifier {
|
|||||||
AdaptiveColors? get adaptiveColors => _adaptiveColors;
|
AdaptiveColors? get adaptiveColors => _adaptiveColors;
|
||||||
ColorScheme? get adaptiveLightScheme => _adaptiveLightScheme;
|
ColorScheme? get adaptiveLightScheme => _adaptiveLightScheme;
|
||||||
ColorScheme? get adaptiveDarkScheme => _adaptiveDarkScheme;
|
ColorScheme? get adaptiveDarkScheme => _adaptiveDarkScheme;
|
||||||
|
bool get isOnDetailScreen => _isOnDetailScreen;
|
||||||
|
|
||||||
/// Get the current adaptive primary color (for bottom nav highlight, etc.)
|
/// Get the current adaptive primary color (for bottom nav highlight, etc.)
|
||||||
Color get adaptivePrimaryColor => _adaptiveColors?.primary ?? _customColor;
|
Color get adaptivePrimaryColor => _adaptiveColors?.primary ?? _customColor;
|
||||||
@@ -162,10 +168,18 @@ class ThemeProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update adaptive colors from album art
|
/// Update adaptive colors from album art
|
||||||
void updateAdaptiveColors(ColorScheme? lightScheme, ColorScheme? darkScheme) {
|
/// [isFromDetailScreen] - if true, this is from navigating to a detail screen
|
||||||
|
/// and the nav bar should use adaptive colors even when player is collapsed
|
||||||
|
void updateAdaptiveColors(ColorScheme? lightScheme, ColorScheme? darkScheme, {bool isFromDetailScreen = false}) {
|
||||||
_adaptiveLightScheme = lightScheme;
|
_adaptiveLightScheme = lightScheme;
|
||||||
_adaptiveDarkScheme = darkScheme;
|
_adaptiveDarkScheme = darkScheme;
|
||||||
|
|
||||||
|
// Only set detail screen flag if explicitly from a detail screen navigation
|
||||||
|
// Player-extracted colors should NOT set this flag
|
||||||
|
if (isFromDetailScreen) {
|
||||||
|
_isOnDetailScreen = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Extract AdaptiveColors from the schemes
|
// Extract AdaptiveColors from the schemes
|
||||||
if (darkScheme != null) {
|
if (darkScheme != null) {
|
||||||
_adaptiveColors = AdaptiveColors(
|
_adaptiveColors = AdaptiveColors(
|
||||||
@@ -181,11 +195,12 @@ class ThemeProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear adaptive colors (when no track is playing)
|
/// Clear adaptive colors (when navigating back from detail screen or no track playing)
|
||||||
void clearAdaptiveColors() {
|
void clearAdaptiveColors() {
|
||||||
_adaptiveColors = null;
|
_adaptiveColors = null;
|
||||||
_adaptiveLightScheme = null;
|
_adaptiveLightScheme = null;
|
||||||
_adaptiveDarkScheme = null;
|
_adaptiveDarkScheme = null;
|
||||||
|
_isOnDetailScreen = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../models/media_item.dart';
|
|||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../screens/album_details_screen.dart';
|
import '../screens/album_details_screen.dart';
|
||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
|
import '../constants/timings.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
import '../services/metadata_service.dart';
|
import '../services/metadata_service.dart';
|
||||||
@@ -36,6 +37,7 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
bool _maImageFailed = false;
|
bool _maImageFailed = false;
|
||||||
String? _cachedMaImageUrl;
|
String? _cachedMaImageUrl;
|
||||||
Timer? _fallbackTimer;
|
Timer? _fallbackTimer;
|
||||||
|
bool _isNavigating = false;
|
||||||
|
|
||||||
/// Delay before fetching fallback images to avoid requests during fast scroll
|
/// Delay before fetching fallback images to avoid requests during fast scroll
|
||||||
static const _fallbackDelay = Duration(milliseconds: 200);
|
static const _fallbackDelay = Duration(milliseconds: 200);
|
||||||
@@ -117,6 +119,10 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: widget.onTap ?? () {
|
onTap: widget.onTap ?? () {
|
||||||
|
// Prevent double-tap navigation
|
||||||
|
if (_isNavigating) return;
|
||||||
|
_isNavigating = true;
|
||||||
|
|
||||||
// Update adaptive colors immediately on tap
|
// Update adaptive colors immediately on tap
|
||||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
@@ -128,7 +134,12 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
initialImageUrl: imageUrl,
|
initialImageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
).then((_) {
|
||||||
|
// Reset after navigation debounce delay
|
||||||
|
Future.delayed(Timings.navigationDebounce, () {
|
||||||
|
if (mounted) _isNavigating = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// Get cached data synchronously BEFORE first build (no spinner flash)
|
||||||
|
final cached = widget.getCachedAlbums?.call();
|
||||||
|
if (cached != null && cached.isNotEmpty) {
|
||||||
|
_albums = cached;
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
_loadAlbums();
|
_loadAlbums();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,18 +51,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
|||||||
if (_hasLoaded) return;
|
if (_hasLoaded) return;
|
||||||
_hasLoaded = true;
|
_hasLoaded = true;
|
||||||
|
|
||||||
// 1. Try to get cached data synchronously for instant display
|
// Load fresh data (always update - fresh data may have images that cached data lacks)
|
||||||
final cachedAlbums = widget.getCachedAlbums?.call();
|
|
||||||
if (cachedAlbums != null && cachedAlbums.isNotEmpty) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_albums = cachedAlbums;
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Load fresh data (always update - fresh data may have images that cached data lacks)
|
|
||||||
try {
|
try {
|
||||||
final freshAlbums = await widget.loadAlbums();
|
final freshAlbums = await widget.loadAlbums();
|
||||||
if (mounted && freshAlbums.isNotEmpty) {
|
if (mounted && freshAlbums.isNotEmpty) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../models/media_item.dart';
|
|||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../screens/artist_details_screen.dart';
|
import '../screens/artist_details_screen.dart';
|
||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
|
import '../constants/timings.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
import '../services/metadata_service.dart';
|
import '../services/metadata_service.dart';
|
||||||
@@ -38,6 +39,7 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
bool _maImageFailed = false;
|
bool _maImageFailed = false;
|
||||||
String? _cachedMaImageUrl;
|
String? _cachedMaImageUrl;
|
||||||
Timer? _fallbackTimer;
|
Timer? _fallbackTimer;
|
||||||
|
bool _isNavigating = false;
|
||||||
|
|
||||||
/// Delay before fetching fallback images to avoid requests during fast scroll
|
/// Delay before fetching fallback images to avoid requests during fast scroll
|
||||||
static const _fallbackDelay = Duration(milliseconds: 200);
|
static const _fallbackDelay = Duration(milliseconds: 200);
|
||||||
@@ -110,6 +112,10 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: widget.onTap ?? () {
|
onTap: widget.onTap ?? () {
|
||||||
|
// Prevent double-tap navigation
|
||||||
|
if (_isNavigating) return;
|
||||||
|
_isNavigating = true;
|
||||||
|
|
||||||
// Update adaptive colors immediately on tap
|
// Update adaptive colors immediately on tap
|
||||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
@@ -121,7 +127,12 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
initialImageUrl: imageUrl,
|
initialImageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
).then((_) {
|
||||||
|
// Reset after navigation debounce delay
|
||||||
|
Future.delayed(Timings.navigationDebounce, () {
|
||||||
|
if (mounted) _isNavigating = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// Get cached data synchronously BEFORE first build (no spinner flash)
|
||||||
|
final cached = widget.getCachedArtists?.call();
|
||||||
|
if (cached != null && cached.isNotEmpty) {
|
||||||
|
_artists = cached;
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
_loadArtists();
|
_loadArtists();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,18 +51,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
|
|||||||
if (_hasLoaded) return;
|
if (_hasLoaded) return;
|
||||||
_hasLoaded = true;
|
_hasLoaded = true;
|
||||||
|
|
||||||
// 1. Try to get cached data synchronously for instant display
|
// Load fresh data (always update - fresh data may have images that cached data lacks)
|
||||||
final cachedArtists = widget.getCachedArtists?.call();
|
|
||||||
if (cachedArtists != null && cachedArtists.isNotEmpty) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_artists = cachedArtists;
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Load fresh data (always update - fresh data may have images that cached data lacks)
|
|
||||||
try {
|
try {
|
||||||
final freshArtists = await widget.loadArtists();
|
final freshArtists = await widget.loadArtists();
|
||||||
if (mounted && freshArtists.isNotEmpty) {
|
if (mounted && freshArtists.isNotEmpty) {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ class AudiobookRow extends StatefulWidget {
|
|||||||
final Future<List<Audiobook>> Function() loadAudiobooks;
|
final Future<List<Audiobook>> Function() loadAudiobooks;
|
||||||
final String? heroTagSuffix;
|
final String? heroTagSuffix;
|
||||||
final double? rowHeight;
|
final double? rowHeight;
|
||||||
|
/// Optional: synchronous getter for cached data (for instant display)
|
||||||
|
final List<Audiobook>? Function()? getCachedAudiobooks;
|
||||||
|
|
||||||
const AudiobookRow({
|
const AudiobookRow({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -21,6 +23,7 @@ class AudiobookRow extends StatefulWidget {
|
|||||||
required this.loadAudiobooks,
|
required this.loadAudiobooks,
|
||||||
this.heroTagSuffix,
|
this.heroTagSuffix,
|
||||||
this.rowHeight,
|
this.rowHeight,
|
||||||
|
this.getCachedAudiobooks,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -28,8 +31,8 @@ class AudiobookRow extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClientMixin {
|
class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClientMixin {
|
||||||
late Future<List<Audiobook>> _audiobooksFuture;
|
List<Audiobook> _audiobooks = [];
|
||||||
List<Audiobook>? _cachedAudiobooks;
|
bool _isLoading = true;
|
||||||
bool _hasLoaded = false;
|
bool _hasLoaded = false;
|
||||||
bool _hasPrecachedAudiobooks = false;
|
bool _hasPrecachedAudiobooks = false;
|
||||||
|
|
||||||
@@ -39,18 +42,36 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadAudiobooksOnce();
|
// Get cached data synchronously BEFORE first build (no spinner flash)
|
||||||
|
final cached = widget.getCachedAudiobooks?.call();
|
||||||
|
if (cached != null && cached.isNotEmpty) {
|
||||||
|
_audiobooks = cached;
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
|
_loadAudiobooks();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _loadAudiobooksOnce() {
|
Future<void> _loadAudiobooks() async {
|
||||||
if (!_hasLoaded) {
|
if (_hasLoaded) return;
|
||||||
_audiobooksFuture = widget.loadAudiobooks().then((audiobooks) {
|
_hasLoaded = true;
|
||||||
_cachedAudiobooks = audiobooks;
|
|
||||||
// Pre-cache images for smooth Hero animations
|
// Load fresh data (always update - fresh data may have updated progress)
|
||||||
_precacheAudiobookImages(audiobooks);
|
try {
|
||||||
return audiobooks;
|
final freshAudiobooks = await widget.loadAudiobooks();
|
||||||
});
|
if (mounted && freshAudiobooks.isNotEmpty) {
|
||||||
_hasLoaded = true;
|
setState(() {
|
||||||
|
_audiobooks = freshAudiobooks;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
// Pre-cache images for smooth hero animations
|
||||||
|
_precacheAudiobookImages(freshAudiobooks);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent failure - keep showing cached data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted && _isLoading) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,10 +101,57 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
|
|||||||
|
|
||||||
static final _logger = DebugLogger();
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
|
Widget _buildContent(double contentHeight, ColorScheme colorScheme, MusicAssistantProvider maProvider) {
|
||||||
|
// Only show loading if we have no data at all
|
||||||
|
if (_audiobooks.isEmpty && _isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_audiobooks.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No audiobooks found',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textAreaHeight = 44.0;
|
||||||
|
final artworkSize = contentHeight - textAreaHeight;
|
||||||
|
final cardWidth = artworkSize;
|
||||||
|
final itemExtent = cardWidth + 12;
|
||||||
|
|
||||||
|
return ScrollConfiguration(
|
||||||
|
behavior: const _StretchScrollBehavior(),
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
itemCount: _audiobooks.length,
|
||||||
|
itemExtent: itemExtent,
|
||||||
|
cacheExtent: 500,
|
||||||
|
addAutomaticKeepAlives: false,
|
||||||
|
addRepaintBoundaries: false,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final audiobook = _audiobooks[index];
|
||||||
|
return Container(
|
||||||
|
key: ValueKey(audiobook.uri ?? audiobook.itemId),
|
||||||
|
width: cardWidth,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||||
|
child: _AudiobookCard(
|
||||||
|
audiobook: audiobook,
|
||||||
|
heroTagSuffix: widget.heroTagSuffix ?? 'home_${widget.title.replaceAll(' ', '_').toLowerCase()}',
|
||||||
|
maProvider: maProvider,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
_logger.startBuild('AudiobookRow:${widget.title}');
|
_logger.startBuild('AudiobookRow:${widget.title}');
|
||||||
super.build(context);
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
@@ -109,62 +177,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FutureBuilder<List<Audiobook>>(
|
child: _buildContent(contentHeight, colorScheme, maProvider),
|
||||||
future: _audiobooksFuture,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
final audiobooks = snapshot.data ?? _cachedAudiobooks;
|
|
||||||
|
|
||||||
if (audiobooks == null && snapshot.connectionState == ConnectionState.waiting) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot.hasError && audiobooks == null) {
|
|
||||||
return Center(
|
|
||||||
child: Text('Error: ${snapshot.error}'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (audiobooks == null || audiobooks.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Text(
|
|
||||||
'No audiobooks found',
|
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const textAreaHeight = 44.0;
|
|
||||||
final artworkSize = contentHeight - textAreaHeight;
|
|
||||||
final cardWidth = artworkSize;
|
|
||||||
final itemExtent = cardWidth + 12;
|
|
||||||
|
|
||||||
return ScrollConfiguration(
|
|
||||||
behavior: const _StretchScrollBehavior(),
|
|
||||||
child: ListView.builder(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
||||||
itemCount: audiobooks.length,
|
|
||||||
itemExtent: itemExtent,
|
|
||||||
cacheExtent: 500,
|
|
||||||
addAutomaticKeepAlives: false,
|
|
||||||
addRepaintBoundaries: false,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final audiobook = audiobooks[index];
|
|
||||||
return Container(
|
|
||||||
key: ValueKey(audiobook.uri ?? audiobook.itemId),
|
|
||||||
width: cardWidth,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
|
||||||
child: _AudiobookCard(
|
|
||||||
audiobook: audiobook,
|
|
||||||
heroTagSuffix: widget.heroTagSuffix ?? 'home_${widget.title.replaceAll(' ', '_').toLowerCase()}',
|
|
||||||
maProvider: maProvider,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../providers/music_assistant_provider.dart';
|
|||||||
import '../providers/navigation_provider.dart';
|
import '../providers/navigation_provider.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
|
|
||||||
import 'expandable_player.dart';
|
import 'expandable_player.dart';
|
||||||
import 'player/mini_player_content.dart' show MiniPlayerLayout;
|
import 'player/mini_player_content.dart' show MiniPlayerLayout;
|
||||||
import 'player/player_reveal_overlay.dart';
|
import 'player/player_reveal_overlay.dart';
|
||||||
@@ -67,13 +68,14 @@ class BottomSpacing {
|
|||||||
static double get withMiniPlayer => navBarHeight + miniPlayerHeight + 22.0;
|
static double get withMiniPlayer => navBarHeight + miniPlayerHeight + 22.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ValueNotifier for player expansion progress (0.0 to 1.0) and background color
|
/// ValueNotifier for player expansion progress (0.0 to 1.0) and colors
|
||||||
class PlayerExpansionState {
|
class PlayerExpansionState {
|
||||||
final double progress;
|
final double progress;
|
||||||
final Color? backgroundColor;
|
final Color? backgroundColor;
|
||||||
PlayerExpansionState(this.progress, this.backgroundColor);
|
final Color? primaryColor;
|
||||||
|
PlayerExpansionState(this.progress, this.backgroundColor, this.primaryColor);
|
||||||
}
|
}
|
||||||
final playerExpansionNotifier = ValueNotifier<PlayerExpansionState>(PlayerExpansionState(0.0, null));
|
final playerExpansionNotifier = ValueNotifier<PlayerExpansionState>(PlayerExpansionState(0.0, null, null));
|
||||||
|
|
||||||
/// Wrapper widget that provides a global player overlay above all navigation.
|
/// Wrapper widget that provides a global player overlay above all navigation.
|
||||||
///
|
///
|
||||||
@@ -95,17 +97,29 @@ class GlobalPlayerOverlay extends StatefulWidget {
|
|||||||
globalPlayerKey.currentState?.collapse();
|
globalPlayerKey.currentState?.collapse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Force collapse player to mini state, instantly closing queue panel if open.
|
||||||
|
/// Used for queue transfer where we want to go directly to mini player.
|
||||||
|
static void forceCollapsePlayer() {
|
||||||
|
globalPlayerKey.currentState?.forceCollapse();
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if the player is currently expanded
|
/// Check if the player is currently expanded
|
||||||
static bool get isPlayerExpanded =>
|
static bool get isPlayerExpanded =>
|
||||||
globalPlayerKey.currentState?.isExpanded ?? false;
|
globalPlayerKey.currentState?.isExpanded ?? false;
|
||||||
|
|
||||||
/// Check if the queue panel is currently open
|
/// Check if the queue panel is currently open (animation value > 0.5)
|
||||||
static bool get isQueuePanelOpen =>
|
static bool get isQueuePanelOpen =>
|
||||||
globalPlayerKey.currentState?.isQueuePanelOpen ?? false;
|
globalPlayerKey.currentState?.isQueuePanelOpen ?? false;
|
||||||
|
|
||||||
|
/// Check if the queue panel is intended to be open (target state)
|
||||||
|
/// Use this for back gesture handling to avoid timing issues during animations
|
||||||
|
static bool get isQueuePanelTargetOpen =>
|
||||||
|
globalPlayerKey.currentState?.isQueuePanelTargetOpen ?? false;
|
||||||
|
|
||||||
/// Close the queue panel if open
|
/// Close the queue panel if open
|
||||||
static void closeQueuePanel() {
|
/// [withHaptic]: Set to false for Android back gesture (system provides haptic)
|
||||||
globalPlayerKey.currentState?.closeQueuePanel();
|
static void closeQueuePanel({bool withHaptic = true}) {
|
||||||
|
globalPlayerKey.currentState?.closeQueuePanel(withHaptic: withHaptic);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current expansion progress (0.0 to 1.0)
|
/// Get the current expansion progress (0.0 to 1.0)
|
||||||
@@ -418,15 +432,17 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
// Watch provider to trigger rebuilds when connection state changes
|
// PERF: Use select() to only rebuild when connection state or player changes
|
||||||
final provider = context.watch<MusicAssistantProvider>();
|
final (isConnected, hasSelectedPlayer) = context.select<MusicAssistantProvider, (bool, bool)>(
|
||||||
|
(p) => (p.isConnected, p.selectedPlayer != null),
|
||||||
|
);
|
||||||
|
|
||||||
// Compute whether this is a first-time user ready for welcome screen.
|
// Compute whether this is a first-time user ready for welcome screen.
|
||||||
// This is SYNCHRONOUS - no setState needed - so it works in the same frame.
|
// This is SYNCHRONOUS - no setState needed - so it works in the same frame.
|
||||||
final isFirstTimeUserReady = _settingsLoaded &&
|
final isFirstTimeUserReady = _settingsLoaded &&
|
||||||
!_hasCompletedOnboarding &&
|
!_hasCompletedOnboarding &&
|
||||||
provider.isConnected &&
|
isConnected &&
|
||||||
provider.selectedPlayer != null;
|
hasSelectedPlayer;
|
||||||
|
|
||||||
// Show welcome backdrop if:
|
// Show welcome backdrop if:
|
||||||
// 1. Already in hint mode (_isHintModeActive), OR
|
// 1. Already in hint mode (_isHintModeActive), OR
|
||||||
@@ -472,7 +488,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
// Hide when: not connected, no player selected, OR showing welcome screen
|
// Hide when: not connected, no player selected, OR showing welcome screen
|
||||||
// For first-time users, selectedPlayer becomes non-null at the same time
|
// For first-time users, selectedPlayer becomes non-null at the same time
|
||||||
// as shouldShowWelcomeBackdrop becomes true, preventing any gap.
|
// as shouldShowWelcomeBackdrop becomes true, preventing any gap.
|
||||||
if (provider.isConnected && provider.selectedPlayer != null && !shouldShowWelcomeBackdrop)
|
if (isConnected && hasSelectedPlayer && !shouldShowWelcomeBackdrop)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
@@ -482,76 +498,44 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
return Consumer<ThemeProvider>(
|
return Consumer<ThemeProvider>(
|
||||||
builder: (context, themeProvider, _) {
|
builder: (context, themeProvider, _) {
|
||||||
// Use adaptive primary color for bottom nav when adaptive theme is enabled
|
|
||||||
final sourceColor = themeProvider.adaptiveTheme
|
|
||||||
? themeProvider.adaptivePrimaryColor
|
|
||||||
: colorScheme.primary;
|
|
||||||
|
|
||||||
// Use cached color computation to avoid expensive HSL operations during scroll
|
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
|
|
||||||
|
|
||||||
// Base background: use adaptive surface color if available, otherwise default surface
|
|
||||||
// Use brightness-aware method to respect light/dark mode
|
|
||||||
final adaptiveBg = themeProvider.getAdaptiveSurfaceColorFor(Theme.of(context).brightness);
|
|
||||||
final baseBgColor = (themeProvider.adaptiveTheme && adaptiveBg != null)
|
|
||||||
? adaptiveBg
|
|
||||||
: colorScheme.surface;
|
|
||||||
|
|
||||||
return ValueListenableBuilder<PlayerExpansionState>(
|
return ValueListenableBuilder<PlayerExpansionState>(
|
||||||
valueListenable: playerExpansionNotifier,
|
valueListenable: playerExpansionNotifier,
|
||||||
// Pass BottomNavigationBar as child to avoid rebuilding it every frame
|
builder: (context, expansionState, _) {
|
||||||
child: BottomNavigationBar(
|
// Nav bar color logic - only use adaptive colors when:
|
||||||
currentIndex: navigationProvider.selectedIndex,
|
// 1. Player is expanding/expanded, OR
|
||||||
onTap: (index) {
|
// 2. On a detail screen (isOnDetailScreen)
|
||||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
// On home screen with collapsed player: always use default theme colors
|
||||||
GlobalPlayerOverlay.collapsePlayer();
|
final bool useAdaptiveColors = themeProvider.adaptiveTheme &&
|
||||||
}
|
(expansionState.progress > 0 || themeProvider.isOnDetailScreen);
|
||||||
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
|
||||||
if (index == 3) {
|
// Nav bar background color
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
final Color navBgColor;
|
||||||
} else if (navigationProvider.selectedIndex == 3) {
|
if (expansionState.progress > 0 && expansionState.backgroundColor != null) {
|
||||||
GlobalPlayerOverlay.showPlayer();
|
// Player is expanding - blend from surface to player's adaptive color
|
||||||
}
|
navBgColor = Color.lerp(colorScheme.surface, expansionState.backgroundColor, expansionState.progress)!;
|
||||||
navigationProvider.setSelectedIndex(index);
|
} else if (useAdaptiveColors) {
|
||||||
},
|
// On a detail screen - use adaptive surface color for nav bar
|
||||||
backgroundColor: Colors.transparent,
|
final adaptiveBg = themeProvider.getAdaptiveSurfaceColorFor(Theme.of(context).brightness);
|
||||||
selectedItemColor: navSelectedColor,
|
navBgColor = adaptiveBg ?? colorScheme.surface;
|
||||||
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
|
} else {
|
||||||
elevation: 0,
|
// Home screen with collapsed player - use default surface color
|
||||||
type: BottomNavigationBarType.fixed,
|
navBgColor = colorScheme.surface;
|
||||||
selectedFontSize: 12,
|
}
|
||||||
unselectedFontSize: 12,
|
|
||||||
items: [
|
// Icon color - only use adaptive colors when appropriate
|
||||||
BottomNavigationBarItem(
|
final Color baseSourceColor = (useAdaptiveColors && themeProvider.adaptiveColors != null)
|
||||||
icon: const Icon(Icons.home_outlined),
|
? themeProvider.adaptiveColors!.primary
|
||||||
activeIcon: const Icon(Icons.home_rounded),
|
: colorScheme.primary;
|
||||||
label: S.of(context)!.home,
|
|
||||||
),
|
// Blend icon color with player's primary color during expansion
|
||||||
BottomNavigationBarItem(
|
Color sourceColor = baseSourceColor;
|
||||||
icon: const Icon(Icons.library_music_outlined),
|
if (themeProvider.adaptiveTheme && expansionState.progress > 0 && expansionState.primaryColor != null) {
|
||||||
activeIcon: const Icon(Icons.library_music_rounded),
|
sourceColor = Color.lerp(baseSourceColor, expansionState.primaryColor!, expansionState.progress)!;
|
||||||
label: S.of(context)!.library,
|
}
|
||||||
),
|
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: const Icon(Icons.search_rounded),
|
|
||||||
activeIcon: const Icon(Icons.search_rounded),
|
|
||||||
label: S.of(context)!.search,
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: const Icon(Icons.settings_outlined),
|
|
||||||
activeIcon: const Icon(Icons.settings_rounded),
|
|
||||||
label: S.of(context)!.settings,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
builder: (context, expansionState, navBar) {
|
|
||||||
// Only compute colors during animation - this is the hot path
|
|
||||||
final navBgColor = expansionState.progress > 0 && expansionState.backgroundColor != null
|
|
||||||
? Color.lerp(baseBgColor, expansionState.backgroundColor, expansionState.progress)!
|
|
||||||
: baseBgColor;
|
|
||||||
|
|
||||||
// Use AnimatedContainer for smoother transitions instead of rebuilding
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: navBgColor,
|
color: navBgColor,
|
||||||
@@ -565,7 +549,50 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
]
|
]
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: navBar, // Reuse pre-built navigation bar
|
child: BottomNavigationBar(
|
||||||
|
currentIndex: navigationProvider.selectedIndex,
|
||||||
|
onTap: (index) {
|
||||||
|
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||||
|
GlobalPlayerOverlay.collapsePlayer();
|
||||||
|
}
|
||||||
|
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||||
|
if (index == 3) {
|
||||||
|
GlobalPlayerOverlay.hidePlayer();
|
||||||
|
} else if (navigationProvider.selectedIndex == 3) {
|
||||||
|
GlobalPlayerOverlay.showPlayer();
|
||||||
|
}
|
||||||
|
navigationProvider.setSelectedIndex(index);
|
||||||
|
},
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
selectedItemColor: navSelectedColor,
|
||||||
|
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
|
||||||
|
elevation: 0,
|
||||||
|
type: BottomNavigationBarType.fixed,
|
||||||
|
selectedFontSize: 12,
|
||||||
|
unselectedFontSize: 12,
|
||||||
|
items: [
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: const Icon(Icons.home_outlined),
|
||||||
|
activeIcon: const Icon(Icons.home_rounded),
|
||||||
|
label: S.of(context)!.home,
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: const Icon(Icons.library_music_outlined),
|
||||||
|
activeIcon: const Icon(Icons.library_music_rounded),
|
||||||
|
label: S.of(context)!.library,
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: const Icon(Icons.search_rounded),
|
||||||
|
activeIcon: const Icon(Icons.search_rounded),
|
||||||
|
label: S.of(context)!.search,
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: const Icon(Icons.settings_outlined),
|
||||||
|
activeIcon: const Icon(Icons.settings_rounded),
|
||||||
|
label: S.of(context)!.settings,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
/// A scrollbar that shows a letter popup when dragging, for fast navigation
|
/// A scrollbar that shows a letter popup when dragging, for fast navigation
|
||||||
/// through alphabetically sorted lists.
|
/// through alphabetically sorted lists.
|
||||||
|
///
|
||||||
|
/// Scrolls PROPORTIONALLY (like a normal scrollbar) and shows which letter
|
||||||
|
/// section you're currently in via a popup indicator.
|
||||||
class LetterScrollbar extends StatefulWidget {
|
class LetterScrollbar extends StatefulWidget {
|
||||||
/// The scrollable child widget (ListView, GridView, etc.)
|
/// The scrollable child widget (ListView, GridView, etc.)
|
||||||
final Widget child;
|
final Widget child;
|
||||||
@@ -9,16 +12,34 @@ class LetterScrollbar extends StatefulWidget {
|
|||||||
/// The scroll controller for the child
|
/// The scroll controller for the child
|
||||||
final ScrollController controller;
|
final ScrollController controller;
|
||||||
|
|
||||||
/// List of items to extract letters from (must be sorted alphabetically)
|
/// List of strings to extract letters from (should match visual sort order).
|
||||||
/// Each item's first character is used to determine the letter
|
/// Each item's first character is used to determine the letter shown in the popup.
|
||||||
final List<String> items;
|
final List<String> items;
|
||||||
|
|
||||||
/// Callback when user taps/drags to a specific index
|
/// Callback when user taps/drags to a specific index.
|
||||||
final void Function(int index)? onScrollToIndex;
|
final void Function(int index)? onScrollToIndex;
|
||||||
|
|
||||||
/// Callback when drag state changes (for disabling scroll-to-hide)
|
/// Callback when drag state changes (for disabling scroll-to-hide)
|
||||||
final void Function(bool isDragging)? onDragStateChanged;
|
final void Function(bool isDragging)? onDragStateChanged;
|
||||||
|
|
||||||
|
/// Fixed item height for lists. Enables precise scroll calculation.
|
||||||
|
final double? itemExtent;
|
||||||
|
|
||||||
|
/// Number of columns for grid layouts.
|
||||||
|
final int? crossAxisCount;
|
||||||
|
|
||||||
|
/// Aspect ratio of grid children (width / height).
|
||||||
|
final double? childAspectRatio;
|
||||||
|
|
||||||
|
/// Spacing between grid rows (mainAxisSpacing)
|
||||||
|
final double? mainAxisSpacing;
|
||||||
|
|
||||||
|
/// Horizontal padding of the scrollable area
|
||||||
|
final double? horizontalPadding;
|
||||||
|
|
||||||
|
/// Bottom padding to prevent scrollbar from going behind bottom nav/mini player
|
||||||
|
final double bottomPadding;
|
||||||
|
|
||||||
const LetterScrollbar({
|
const LetterScrollbar({
|
||||||
super.key,
|
super.key,
|
||||||
required this.child,
|
required this.child,
|
||||||
@@ -26,6 +47,12 @@ class LetterScrollbar extends StatefulWidget {
|
|||||||
required this.items,
|
required this.items,
|
||||||
this.onScrollToIndex,
|
this.onScrollToIndex,
|
||||||
this.onDragStateChanged,
|
this.onDragStateChanged,
|
||||||
|
this.itemExtent,
|
||||||
|
this.crossAxisCount,
|
||||||
|
this.childAspectRatio,
|
||||||
|
this.mainAxisSpacing,
|
||||||
|
this.horizontalPadding,
|
||||||
|
this.bottomPadding = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -55,87 +82,54 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
|||||||
final position = widget.controller.position;
|
final position = widget.controller.position;
|
||||||
if (position.maxScrollExtent > 0) {
|
if (position.maxScrollExtent > 0) {
|
||||||
final newFraction = (position.pixels / position.maxScrollExtent).clamp(0.0, 1.0);
|
final newFraction = (position.pixels / position.maxScrollExtent).clamp(0.0, 1.0);
|
||||||
// PERF: Only rebuild if fraction changed by more than 1% to reduce rebuilds
|
|
||||||
if ((newFraction - _scrollFraction).abs() > 0.01) {
|
if ((newFraction - _scrollFraction).abs() > 0.01) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_scrollFraction = newFraction;
|
_scrollFraction = newFraction;
|
||||||
|
_currentLetter = _getLetterAtFraction(newFraction);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a map of letter -> first index for that letter
|
/// Normalizes a character to a letter for the scrollbar.
|
||||||
Map<String, int> get _letterIndexMap {
|
String _normalizeToLetter(String char) {
|
||||||
final map = <String, int>{};
|
if (char.isEmpty) return '';
|
||||||
for (int i = 0; i < widget.items.length; i++) {
|
final upper = char[0].toUpperCase();
|
||||||
final item = widget.items[i];
|
if (upper.codeUnitAt(0) >= 65 && upper.codeUnitAt(0) <= 90) {
|
||||||
if (item.isNotEmpty) {
|
return upper;
|
||||||
final letter = item[0].toUpperCase();
|
|
||||||
if (!map.containsKey(letter)) {
|
|
||||||
map[letter] = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return map;
|
return '#';
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getLetterAtPosition(double position, double maxHeight) {
|
/// Get the letter at a given scroll fraction (0.0 to 1.0)
|
||||||
if (widget.items.isEmpty || maxHeight <= 0) return '';
|
String _getLetterAtFraction(double fraction) {
|
||||||
|
if (widget.items.isEmpty) return '';
|
||||||
|
|
||||||
// Calculate which item index corresponds to this position
|
final index = (fraction * (widget.items.length - 1)).round().clamp(0, widget.items.length - 1);
|
||||||
final fraction = (position / maxHeight).clamp(0.0, 1.0);
|
if (index < widget.items.length) {
|
||||||
final index = (fraction * (widget.items.length - 1)).round();
|
|
||||||
|
|
||||||
if (index >= 0 && index < widget.items.length) {
|
|
||||||
final item = widget.items[index];
|
final item = widget.items[index];
|
||||||
if (item.isNotEmpty) {
|
if (item.isNotEmpty) {
|
||||||
return item[0].toUpperCase();
|
return _normalizeToLetter(item[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scrollToLetter(String letter) {
|
|
||||||
final letterMap = _letterIndexMap;
|
|
||||||
if (letterMap.containsKey(letter)) {
|
|
||||||
final index = letterMap[letter]!;
|
|
||||||
if (widget.onScrollToIndex != null) {
|
|
||||||
widget.onScrollToIndex!(index);
|
|
||||||
} else {
|
|
||||||
// Estimate scroll position based on index
|
|
||||||
// This is approximate - works better with fixed height items
|
|
||||||
final scrollController = widget.controller;
|
|
||||||
if (scrollController.hasClients) {
|
|
||||||
final maxScroll = scrollController.position.maxScrollExtent;
|
|
||||||
final fraction = index / widget.items.length;
|
|
||||||
final targetScroll = (fraction * maxScroll).clamp(0.0, maxScroll);
|
|
||||||
|
|
||||||
// Update scroll fraction immediately for smooth thumb tracking
|
|
||||||
setState(() {
|
|
||||||
_scrollFraction = fraction;
|
|
||||||
});
|
|
||||||
|
|
||||||
scrollController.jumpTo(targetScroll);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleDragStart(DragStartDetails details) {
|
void _handleDragStart(DragStartDetails details) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDragging = true;
|
_isDragging = true;
|
||||||
_dragPosition = details.localPosition.dy;
|
_dragPosition = details.localPosition.dy;
|
||||||
});
|
});
|
||||||
widget.onDragStateChanged?.call(true);
|
widget.onDragStateChanged?.call(true);
|
||||||
_updateLetterAndScroll(details.localPosition.dy);
|
_scrollToPosition(details.localPosition.dy);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleDragUpdate(DragUpdateDetails details) {
|
void _handleDragUpdate(DragUpdateDetails details) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_dragPosition = details.localPosition.dy;
|
_dragPosition = details.localPosition.dy;
|
||||||
});
|
});
|
||||||
_updateLetterAndScroll(details.localPosition.dy);
|
_scrollToPosition(details.localPosition.dy);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleDragEnd(DragEndDetails details) {
|
void _handleDragEnd(DragEndDetails details) {
|
||||||
@@ -145,18 +139,35 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
|||||||
widget.onDragStateChanged?.call(false);
|
widget.onDragStateChanged?.call(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateLetterAndScroll(double position) {
|
/// Scroll proportionally based on drag position
|
||||||
|
void _scrollToPosition(double position) {
|
||||||
final renderBox = context.findRenderObject() as RenderBox?;
|
final renderBox = context.findRenderObject() as RenderBox?;
|
||||||
if (renderBox != null) {
|
if (renderBox == null || !widget.controller.hasClients) return;
|
||||||
final height = renderBox.size.height;
|
|
||||||
final letter = _getLetterAtPosition(position, height);
|
// Account for bottom padding in the effective track height
|
||||||
if (letter.isNotEmpty && letter != _currentLetter) {
|
final totalHeight = renderBox.size.height;
|
||||||
setState(() {
|
final effectiveHeight = totalHeight - widget.bottomPadding;
|
||||||
_currentLetter = letter;
|
if (effectiveHeight <= 0) return;
|
||||||
});
|
|
||||||
_scrollToLetter(letter);
|
// Calculate scroll fraction from position (clamped to effective area)
|
||||||
|
final clampedPosition = position.clamp(0.0, effectiveHeight);
|
||||||
|
final fraction = clampedPosition / effectiveHeight;
|
||||||
|
|
||||||
|
// Get letter for display
|
||||||
|
final letter = _getLetterAtFraction(fraction);
|
||||||
|
|
||||||
|
// Scroll proportionally
|
||||||
|
final maxScroll = widget.controller.position.maxScrollExtent;
|
||||||
|
final targetScroll = (fraction * maxScroll).clamp(0.0, maxScroll);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_scrollFraction = fraction;
|
||||||
|
if (letter.isNotEmpty) {
|
||||||
|
_currentLetter = letter;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
|
widget.controller.jumpTo(targetScroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -168,12 +179,12 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
|||||||
// The scrollable content
|
// The scrollable content
|
||||||
widget.child,
|
widget.child,
|
||||||
|
|
||||||
// The draggable scrollbar area (right edge) - wider grab area
|
// The draggable scrollbar area (right edge)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 0,
|
right: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: widget.bottomPadding,
|
||||||
width: 32, // Wider grab area
|
width: 32,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onVerticalDragStart: _handleDragStart,
|
onVerticalDragStart: _handleDragStart,
|
||||||
onVerticalDragUpdate: _handleDragUpdate,
|
onVerticalDragUpdate: _handleDragUpdate,
|
||||||
@@ -196,11 +207,11 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
|||||||
final trackHeight = constraints.maxHeight;
|
final trackHeight = constraints.maxHeight;
|
||||||
final thumbHeight = _isDragging ? 60.0 : 40.0;
|
final thumbHeight = _isDragging ? 60.0 : 40.0;
|
||||||
final availableTrack = trackHeight - thumbHeight;
|
final availableTrack = trackHeight - thumbHeight;
|
||||||
final thumbTop = availableTrack * _scrollFraction;
|
final thumbTop = (availableTrack * _scrollFraction).clamp(0.0, availableTrack);
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
// Thumb that tracks scroll position - use AnimatedPositioned for smooth motion
|
// Thumb that tracks scroll position
|
||||||
AnimatedPositioned(
|
AnimatedPositioned(
|
||||||
duration: const Duration(milliseconds: 50),
|
duration: const Duration(milliseconds: 50),
|
||||||
curve: Curves.linear,
|
curve: Curves.linear,
|
||||||
@@ -229,7 +240,10 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
|||||||
if (_isDragging && _currentLetter.isNotEmpty)
|
if (_isDragging && _currentLetter.isNotEmpty)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 48,
|
right: 48,
|
||||||
top: _dragPosition - 28,
|
top: (_dragPosition - 28).clamp(
|
||||||
|
0.0,
|
||||||
|
(context.findRenderObject() as RenderBox?)?.size.height ?? 500 - 56 - widget.bottomPadding,
|
||||||
|
),
|
||||||
child: Material(
|
child: Material(
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
color: colorScheme.primaryContainer,
|
color: colorScheme.primaryContainer,
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ class MiniPlayerLayout {
|
|||||||
static const double textLeftOffset = 10.0; // Gap between art and text
|
static const double textLeftOffset = 10.0; // Gap between art and text
|
||||||
static const double textLeft = artSize + textLeftOffset; // 82px
|
static const double textLeft = artSize + textLeftOffset; // 82px
|
||||||
// 3-line layout: track, artist, player
|
// 3-line layout: track, artist, player
|
||||||
static const double primaryTop = 5.0; // raised slightly from 7
|
static const double primaryTop = 9.0;
|
||||||
static const double secondaryTop = 26.0; // raised by 1
|
static const double secondaryTop = 26.0;
|
||||||
static const double tertiaryTop = 46.0;
|
static const double tertiaryTop = 46.0;
|
||||||
// 2-line layout (evenly spaced): player name, hint
|
// 2-line layout (evenly spaced): player name, hint
|
||||||
// Height 72 / 3 = 24px spacing. Line 1 center at 24, Line 2 center at 48
|
// Height 72 / 3 = 24px spacing. Line 1 center at 24, Line 2 center at 48
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ class PlayerCard extends StatefulWidget {
|
|||||||
static const double precisionStillnessThreshold = 5.0; // Max pixels of movement considered "still"
|
static const double precisionStillnessThreshold = 5.0; // Max pixels of movement considered "still"
|
||||||
static const double precisionSensitivity = 0.1; // 10x more precise (full swipe = 10% change)
|
static const double precisionSensitivity = 0.1; // 10x more precise (full swipe = 10% change)
|
||||||
|
|
||||||
|
// PERF Phase 4: Pre-cached BoxShadow to avoid allocation per frame
|
||||||
|
static const BoxShadow cardShadow = BoxShadow(
|
||||||
|
color: Color(0x33000000), // 20% black
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: Offset(0, 2),
|
||||||
|
);
|
||||||
|
|
||||||
const PlayerCard({
|
const PlayerCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.player,
|
required this.player,
|
||||||
@@ -69,6 +76,7 @@ class _PlayerCardState extends State<PlayerCard> {
|
|||||||
int _lastDragEndTime = 0; // Track when last drag ended for consecutive swipes
|
int _lastDragEndTime = 0; // Track when last drag ended for consecutive swipes
|
||||||
bool _hasLocalVolumeOverride = false; // True if we've set volume locally
|
bool _hasLocalVolumeOverride = false; // True if we've set volume locally
|
||||||
static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms
|
static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms
|
||||||
|
static const int _precisionThrottleMs = 50; // Faster updates in precision mode
|
||||||
static const int _consecutiveSwipeWindowMs = 5000; // 5 seconds - extended window for consecutive swipes
|
static const int _consecutiveSwipeWindowMs = 5000; // 5 seconds - extended window for consecutive swipes
|
||||||
|
|
||||||
// Precision mode state
|
// Precision mode state
|
||||||
@@ -169,13 +177,8 @@ class _PlayerCardState extends State<PlayerCard> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: unfilledColor,
|
color: unfilledColor,
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
boxShadow: [
|
// PERF Phase 4: Use pre-cached static BoxShadow
|
||||||
BoxShadow(
|
boxShadow: const [PlayerCard.cardShadow],
|
||||||
color: Colors.black.withOpacity(0.2),
|
|
||||||
blurRadius: 8,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: Align(
|
child: Align(
|
||||||
@@ -220,13 +223,8 @@ class _PlayerCardState extends State<PlayerCard> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: widget.backgroundColor,
|
color: widget.backgroundColor,
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
boxShadow: [
|
// PERF Phase 4: Use pre-cached static BoxShadow
|
||||||
BoxShadow(
|
boxShadow: const [PlayerCard.cardShadow],
|
||||||
color: Colors.black.withOpacity(0.2),
|
|
||||||
blurRadius: 8,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -444,9 +442,10 @@ class _PlayerCardState extends State<PlayerCard> {
|
|||||||
_dragVolumeLevel = newVolume;
|
_dragVolumeLevel = newVolume;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Throttle API calls to prevent flooding
|
// Throttle API calls to prevent flooding (faster in precision mode)
|
||||||
final now = DateTime.now().millisecondsSinceEpoch;
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
if (now - _lastVolumeUpdateTime >= _volumeThrottleMs) {
|
final throttleMs = _inPrecisionMode ? _precisionThrottleMs : _volumeThrottleMs;
|
||||||
|
if (now - _lastVolumeUpdateTime >= throttleMs) {
|
||||||
_lastVolumeUpdateTime = now;
|
_lastVolumeUpdateTime = now;
|
||||||
widget.onVolumeChange?.call(newVolume);
|
widget.onVolumeChange?.call(newVolume);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import '../../l10n/app_localizations.dart';
|
|||||||
import '../../providers/music_assistant_provider.dart';
|
import '../../providers/music_assistant_provider.dart';
|
||||||
import '../../services/settings_service.dart';
|
import '../../services/settings_service.dart';
|
||||||
import '../../theme/design_tokens.dart';
|
import '../../theme/design_tokens.dart';
|
||||||
import '../../theme/theme_provider.dart';
|
|
||||||
import '../../theme/palette_helper.dart';
|
import '../../theme/palette_helper.dart';
|
||||||
import '../../constants/timings.dart';
|
import '../../constants/timings.dart';
|
||||||
import '../global_player_overlay.dart';
|
import '../global_player_overlay.dart';
|
||||||
@@ -41,9 +40,11 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
Timer? _refreshTimer;
|
Timer? _refreshTimer;
|
||||||
|
|
||||||
// Track vertical drag for dismissal
|
// Track vertical drag for dismissal
|
||||||
double _dragOffset = 0;
|
|
||||||
bool _isDragging = false;
|
bool _isDragging = false;
|
||||||
|
|
||||||
|
// Threshold for drag-to-dismiss (in animation value, 0.0-1.0)
|
||||||
|
static const double _dismissThreshold = 0.7;
|
||||||
|
|
||||||
// Scroll controller for when player list overflows
|
// Scroll controller for when player list overflows
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
bool _isScrollable = false;
|
bool _isScrollable = false;
|
||||||
@@ -54,6 +55,13 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
// Hint system
|
// Hint system
|
||||||
bool _showHints = true;
|
bool _showHints = true;
|
||||||
|
|
||||||
|
// PERF: Pre-cached static BoxShadow to avoid allocation per frame
|
||||||
|
static const BoxShadow _cardShadow = BoxShadow(
|
||||||
|
color: Color(0x33000000), // 20% black
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: Offset(0, 2),
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -147,13 +155,24 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
// Trigger single bounce on mini player when cards are ~70% collapsed
|
// Trigger single bounce on mini player when cards are ~70% collapsed
|
||||||
// This creates the effect of cards "landing" on the mini player
|
// This creates the effect of cards "landing" on the mini player
|
||||||
bool bounceFired = false;
|
bool bounceFired = false;
|
||||||
void checkBounce() {
|
void fireBounce() {
|
||||||
if (!bounceFired && _revealController.value < 0.3) {
|
if (!bounceFired) {
|
||||||
bounceFired = true;
|
bounceFired = true;
|
||||||
GlobalPlayerOverlay.triggerBounce();
|
GlobalPlayerOverlay.triggerBounce();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void checkBounce() {
|
||||||
|
if (_revealController.value < 0.3) {
|
||||||
|
fireBounce();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user already dragged past bounce threshold, fire immediately
|
||||||
|
if (_revealController.value < 0.3) {
|
||||||
|
fireBounce();
|
||||||
|
}
|
||||||
|
|
||||||
_revealController.duration = const Duration(milliseconds: 150);
|
_revealController.duration = const Duration(milliseconds: 150);
|
||||||
_revealController.addListener(checkBounce);
|
_revealController.addListener(checkBounce);
|
||||||
_revealController.reverse().then((_) {
|
_revealController.reverse().then((_) {
|
||||||
@@ -194,11 +213,17 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
// Ignore swipe up when not scrollable - cards should stay fixed
|
// Ignore swipe up when not scrollable - cards should stay fixed
|
||||||
if (delta <= 0) return;
|
if (delta <= 0) return;
|
||||||
|
|
||||||
// Swiping down = dismiss gesture
|
// Swiping down = dismiss gesture - directly control animation controller
|
||||||
setState(() {
|
_isDragging = true;
|
||||||
_isDragging = true;
|
|
||||||
_dragOffset += delta;
|
// Convert drag delta to animation value change
|
||||||
});
|
// Use average card travel distance (~150px) as reference
|
||||||
|
const dragSensitivity = 150.0;
|
||||||
|
final valueDelta = delta / dragSensitivity;
|
||||||
|
|
||||||
|
// Decrease animation value (cards move down)
|
||||||
|
final newValue = (_revealController.value - valueDelta).clamp(0.0, 1.0);
|
||||||
|
_revealController.value = newValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleVerticalDragEnd(DragEndDetails details) {
|
void _handleVerticalDragEnd(DragEndDetails details) {
|
||||||
@@ -206,31 +231,27 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
|
|
||||||
// If not dragging (was scrolling), don't dismiss
|
// If not dragging (was scrolling), don't dismiss
|
||||||
if (!_isDragging) return;
|
if (!_isDragging) return;
|
||||||
|
_isDragging = false;
|
||||||
|
|
||||||
// Dismiss if dragged down enough or with enough velocity
|
// Dismiss if dragged past threshold or with enough velocity
|
||||||
if (_dragOffset > 100 || velocity > 500) {
|
if (_revealController.value < _dismissThreshold || velocity > 500) {
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
// Reset drag offset before dismiss so animation starts from correct position
|
|
||||||
setState(() {
|
|
||||||
_isDragging = false;
|
|
||||||
_dragOffset = 0;
|
|
||||||
});
|
|
||||||
dismiss();
|
dismiss();
|
||||||
} else {
|
} else {
|
||||||
// Spring back
|
// Spring back to fully revealed
|
||||||
setState(() {
|
_revealController.duration = const Duration(milliseconds: 150);
|
||||||
_isDragging = false;
|
_revealController.forward();
|
||||||
_dragOffset = 0;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final themeProvider = context.watch<ThemeProvider>();
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
// PERF Phase 4: Pre-compute hint colors outside animation loop
|
||||||
|
final hintColor = colorScheme.onSurface.withOpacity(0.7);
|
||||||
|
|
||||||
// Back gesture is handled at GlobalPlayerOverlay level
|
// Back gesture is handled at GlobalPlayerOverlay level
|
||||||
return Consumer<MusicAssistantProvider>(
|
return Consumer<MusicAssistantProvider>(
|
||||||
builder: (context, maProvider, child) {
|
builder: (context, maProvider, child) {
|
||||||
@@ -267,6 +288,31 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PERF Phase 4: Pre-compute player card data OUTSIDE AnimatedBuilder
|
||||||
|
// This avoids re-gathering player data every animation frame
|
||||||
|
final playerDataList = players.map((player) {
|
||||||
|
final isPlaying = player.state == 'playing';
|
||||||
|
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
|
||||||
|
String? albumArtUrl;
|
||||||
|
if (playerTrack != null && player.available && player.powered) {
|
||||||
|
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
|
||||||
|
}
|
||||||
|
final playerColorScheme = _playerColors[player.playerId];
|
||||||
|
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
|
||||||
|
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
|
||||||
|
final isGrouped = maProvider.isPlayerManuallySynced(player.playerId);
|
||||||
|
|
||||||
|
return _PlayerCardData(
|
||||||
|
player: player,
|
||||||
|
playerTrack: playerTrack,
|
||||||
|
albumArtUrl: albumArtUrl,
|
||||||
|
isPlaying: isPlaying,
|
||||||
|
isGrouped: isGrouped,
|
||||||
|
cardBgColor: cardBgColor,
|
||||||
|
cardTextColor: cardTextColor,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: _revealAnimation,
|
animation: _revealAnimation,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
@@ -287,7 +333,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
Positioned(
|
Positioned(
|
||||||
left: 12,
|
left: 12,
|
||||||
right: 12,
|
right: 12,
|
||||||
bottom: widget.miniPlayerBottom + widget.miniPlayerHeight + 8 - _dragOffset,
|
bottom: widget.miniPlayerBottom + widget.miniPlayerHeight + 8,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onVerticalDragUpdate: _handleVerticalDragUpdate,
|
onVerticalDragUpdate: _handleVerticalDragUpdate,
|
||||||
onVerticalDragEnd: _handleVerticalDragEnd,
|
onVerticalDragEnd: _handleVerticalDragEnd,
|
||||||
@@ -308,6 +354,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
// Show different hint for onboarding vs regular use
|
// Show different hint for onboarding vs regular use
|
||||||
final isOnboarding = widget.showOnboardingHints;
|
final isOnboarding = widget.showOnboardingHints;
|
||||||
|
|
||||||
|
// PERF Phase 4: Use pre-computed hintColor
|
||||||
Widget buildHintRow(IconData icon, String text) {
|
Widget buildHintRow(IconData icon, String text) {
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -315,13 +362,13 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
Icon(
|
Icon(
|
||||||
icon,
|
icon,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
color: hintColor,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
text,
|
text,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
color: hintColor,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
@@ -354,31 +401,23 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
// Build player cards - scrollable when overflow, otherwise static
|
// Build player cards - scrollable when overflow, otherwise static
|
||||||
// Using ListView.builder for many players: only builds visible items
|
// Using ListView.builder for many players: only builds visible items
|
||||||
// This significantly improves animation performance with 10+ players
|
// This significantly improves animation performance with 10+ players
|
||||||
|
// PERF Phase 4: Use pre-computed playerDataList
|
||||||
if (needsScroll)
|
if (needsScroll)
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: BoxConstraints(maxHeight: maxListHeight),
|
constraints: BoxConstraints(maxHeight: maxListHeight),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
physics: const ClampingScrollPhysics(),
|
physics: const ClampingScrollPhysics(),
|
||||||
itemCount: players.length,
|
itemCount: playerDataList.length,
|
||||||
// Fixed height per item for optimal scroll performance
|
// Fixed height per item for optimal scroll performance
|
||||||
itemExtent: cardHeight + cardSpacing,
|
itemExtent: cardHeight + cardSpacing,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final player = players[index];
|
final data = playerDataList[index];
|
||||||
final isPlaying = player.state == 'playing';
|
|
||||||
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
|
|
||||||
String? albumArtUrl;
|
|
||||||
if (playerTrack != null && player.available && player.powered) {
|
|
||||||
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
|
|
||||||
}
|
|
||||||
final playerColorScheme = _playerColors[player.playerId];
|
|
||||||
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
|
|
||||||
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
|
|
||||||
|
|
||||||
// Animation: slide from behind mini player
|
// Animation: slide from behind mini player
|
||||||
// Only calculate for items that will be built (visible ones)
|
// Only calculate for items that will be built (visible ones)
|
||||||
const baseOffset = 80.0;
|
const baseOffset = 80.0;
|
||||||
final reverseIndex = players.length - 1 - index;
|
final reverseIndex = playerDataList.length - 1 - index;
|
||||||
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
|
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
|
||||||
final slideOffset = distanceToTravel * (1.0 - t);
|
final slideOffset = distanceToTravel * (1.0 - t);
|
||||||
|
|
||||||
@@ -387,33 +426,33 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: cardSpacing),
|
padding: const EdgeInsets.only(bottom: cardSpacing),
|
||||||
child: PlayerCard(
|
child: PlayerCard(
|
||||||
player: player,
|
player: data.player,
|
||||||
trackInfo: playerTrack,
|
trackInfo: data.playerTrack,
|
||||||
albumArtUrl: albumArtUrl,
|
albumArtUrl: data.albumArtUrl,
|
||||||
isSelected: false,
|
isSelected: false,
|
||||||
isPlaying: isPlaying,
|
isPlaying: data.isPlaying,
|
||||||
isGrouped: maProvider.isPlayerManuallySynced(player.playerId),
|
isGrouped: data.isGrouped,
|
||||||
backgroundColor: cardBgColor,
|
backgroundColor: data.cardBgColor,
|
||||||
textColor: cardTextColor,
|
textColor: data.cardTextColor,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(data.player);
|
||||||
dismiss();
|
dismiss();
|
||||||
},
|
},
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
maProvider.togglePlayerSync(player.playerId);
|
maProvider.togglePlayerSync(data.player.playerId);
|
||||||
},
|
},
|
||||||
onPlayPause: () {
|
onPlayPause: () {
|
||||||
if (isPlaying) {
|
if (data.isPlaying) {
|
||||||
maProvider.pausePlayer(player.playerId);
|
maProvider.pausePlayer(data.player.playerId);
|
||||||
} else {
|
} else {
|
||||||
maProvider.resumePlayer(player.playerId);
|
maProvider.resumePlayer(data.player.playerId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSkipNext: () => maProvider.nextTrack(player.playerId),
|
onSkipNext: () => maProvider.nextTrack(data.player.playerId),
|
||||||
onPower: () => maProvider.togglePower(player.playerId),
|
onPower: () => maProvider.togglePower(data.player.playerId),
|
||||||
onVolumeChange: (volume) => maProvider.setVolume(player.playerId, (volume * 100).round()),
|
onVolumeChange: (volume) => maProvider.setVolume(data.player.playerId, (volume * 100).round()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -422,77 +461,51 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
// Non-scrollable list for when players fit
|
// Non-scrollable list for when players fit
|
||||||
...List.generate(players.length, (index) {
|
// PERF Phase 4: Use pre-computed playerDataList
|
||||||
final player = players[index];
|
...List.generate(playerDataList.length, (index) {
|
||||||
final isPlaying = player.state == 'playing';
|
final data = playerDataList[index];
|
||||||
|
|
||||||
// Get track info for this player
|
|
||||||
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
|
|
||||||
|
|
||||||
// Get album art URL - use track directly like mini player does
|
|
||||||
String? albumArtUrl;
|
|
||||||
if (playerTrack != null && player.available && player.powered) {
|
|
||||||
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use per-player colors if available, otherwise use defaults
|
|
||||||
final playerColorScheme = _playerColors[player.playerId];
|
|
||||||
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
|
|
||||||
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
|
|
||||||
|
|
||||||
// Animation: all cards start hidden behind mini player
|
// Animation: all cards start hidden behind mini player
|
||||||
// and fan out to their final positions with spring physics
|
// and fan out to their final positions with spring physics
|
||||||
const baseOffset = 80.0;
|
const baseOffset = 80.0;
|
||||||
final reverseIndex = players.length - 1 - index;
|
final reverseIndex = playerDataList.length - 1 - index;
|
||||||
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
|
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
|
||||||
|
|
||||||
// Use the elastic animation value directly (already has bounce)
|
// Use the elastic animation value directly (already has bounce)
|
||||||
final slideOffset = distanceToTravel * (1.0 - t);
|
final slideOffset = distanceToTravel * (1.0 - t);
|
||||||
|
|
||||||
// Darken cards when:
|
|
||||||
return Transform.translate(
|
return Transform.translate(
|
||||||
offset: Offset(0, slideOffset),
|
offset: Offset(0, slideOffset),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: PlayerCard(
|
child: PlayerCard(
|
||||||
player: player,
|
player: data.player,
|
||||||
trackInfo: playerTrack,
|
trackInfo: data.playerTrack,
|
||||||
albumArtUrl: albumArtUrl,
|
albumArtUrl: data.albumArtUrl,
|
||||||
isSelected: false,
|
isSelected: false,
|
||||||
isPlaying: isPlaying,
|
isPlaying: data.isPlaying,
|
||||||
isGrouped: maProvider.isPlayerManuallySynced(player.playerId),
|
isGrouped: data.isGrouped,
|
||||||
backgroundColor: cardBgColor,
|
backgroundColor: data.cardBgColor,
|
||||||
textColor: cardTextColor,
|
textColor: data.cardTextColor,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(data.player);
|
||||||
dismiss();
|
dismiss();
|
||||||
},
|
},
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
|
maProvider.togglePlayerSync(data.player.playerId);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Syncing ${player.name}...'),
|
|
||||||
duration: const Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
maProvider.togglePlayerSync(player.playerId);
|
|
||||||
},
|
},
|
||||||
onPlayPause: () {
|
onPlayPause: () {
|
||||||
if (isPlaying) {
|
if (data.isPlaying) {
|
||||||
maProvider.pausePlayer(player.playerId);
|
maProvider.pausePlayer(data.player.playerId);
|
||||||
} else {
|
} else {
|
||||||
maProvider.resumePlayer(player.playerId);
|
maProvider.resumePlayer(data.player.playerId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSkipNext: () {
|
onSkipNext: () => maProvider.nextTrack(data.player.playerId),
|
||||||
maProvider.nextTrack(player.playerId);
|
onPower: () => maProvider.togglePower(data.player.playerId),
|
||||||
},
|
onVolumeChange: (volume) => maProvider.setVolume(data.player.playerId, (volume * 100).round()),
|
||||||
onPower: () {
|
|
||||||
maProvider.togglePower(player.playerId);
|
|
||||||
},
|
|
||||||
onVolumeChange: (volume) => maProvider.setVolume(player.playerId, (volume * 100).round()),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -509,3 +522,24 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PERF Phase 4: Pre-computed player card data to avoid gathering per animation frame
|
||||||
|
class _PlayerCardData {
|
||||||
|
final dynamic player;
|
||||||
|
final dynamic playerTrack;
|
||||||
|
final String? albumArtUrl;
|
||||||
|
final bool isPlaying;
|
||||||
|
final bool isGrouped;
|
||||||
|
final Color cardBgColor;
|
||||||
|
final Color cardTextColor;
|
||||||
|
|
||||||
|
const _PlayerCardData({
|
||||||
|
required this.player,
|
||||||
|
required this.playerTrack,
|
||||||
|
required this.albumArtUrl,
|
||||||
|
required this.isPlaying,
|
||||||
|
required this.isGrouped,
|
||||||
|
required this.cardBgColor,
|
||||||
|
required this.cardTextColor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ Future<void> showPlayerPickerSheet({
|
|||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
|
useRootNavigator: true, // Required for overlays like QueuePanel
|
||||||
builder: (context) => PlayerPickerSheet(
|
builder: (context) => PlayerPickerSheet(
|
||||||
title: title,
|
title: title,
|
||||||
players: players,
|
players: players,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.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';
|
||||||
@@ -5,10 +6,11 @@ import '../models/media_item.dart';
|
|||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../screens/playlist_details_screen.dart';
|
import '../screens/playlist_details_screen.dart';
|
||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
|
import '../constants/timings.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
|
|
||||||
class PlaylistCard extends StatelessWidget {
|
class PlaylistCard extends StatefulWidget {
|
||||||
final Playlist playlist;
|
final Playlist playlist;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final String? heroTagSuffix;
|
final String? heroTagSuffix;
|
||||||
@@ -22,31 +24,47 @@ class PlaylistCard extends StatelessWidget {
|
|||||||
this.imageCacheSize,
|
this.imageCacheSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlaylistCard> createState() => _PlaylistCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlaylistCardState extends State<PlaylistCard> {
|
||||||
|
bool _isNavigating = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final imageUrl = maProvider.api?.getImageUrl(playlist, size: 256);
|
final imageUrl = maProvider.api?.getImageUrl(widget.playlist, size: 256);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
|
final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||||
final cacheSize = imageCacheSize ?? 256;
|
final cacheSize = widget.imageCacheSize ?? 256;
|
||||||
|
|
||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: onTap ?? () {
|
onTap: widget.onTap ?? () {
|
||||||
|
// Prevent double-tap navigation
|
||||||
|
if (_isNavigating) return;
|
||||||
|
_isNavigating = true;
|
||||||
|
|
||||||
// Update adaptive colors immediately on tap
|
// Update adaptive colors immediately on tap
|
||||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
FadeSlidePageRoute(
|
FadeSlidePageRoute(
|
||||||
child: PlaylistDetailsScreen(
|
child: PlaylistDetailsScreen(
|
||||||
playlist: playlist,
|
playlist: widget.playlist,
|
||||||
provider: playlist.provider,
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
itemId: playlist.itemId,
|
initialImageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
).then((_) {
|
||||||
|
// Reset after navigation debounce delay
|
||||||
|
Future.delayed(Timings.navigationDebounce, () {
|
||||||
|
if (mounted) _isNavigating = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -55,7 +73,7 @@ class PlaylistCard extends StatelessWidget {
|
|||||||
AspectRatio(
|
AspectRatio(
|
||||||
aspectRatio: 1.0,
|
aspectRatio: 1.0,
|
||||||
child: Hero(
|
child: Hero(
|
||||||
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + suffix,
|
tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + suffix,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12.0),
|
borderRadius: BorderRadius.circular(12.0),
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -91,11 +109,11 @@ class PlaylistCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Playlist title
|
// Playlist title
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + suffix,
|
tag: HeroTags.playlistTitle + (widget.playlist.uri ?? widget.playlist.itemId) + suffix,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Text(
|
child: Text(
|
||||||
playlist.name,
|
widget.playlist.name,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: textTheme.titleSmall?.copyWith(
|
style: textTheme.titleSmall?.copyWith(
|
||||||
@@ -107,7 +125,7 @@ class PlaylistCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
// Owner/track count
|
// Owner/track count
|
||||||
Text(
|
Text(
|
||||||
playlist.owner ?? (playlist.trackCount != null ? '${playlist.trackCount} tracks' : ''),
|
widget.playlist.owner ?? (widget.playlist.trackCount != null ? '${widget.playlist.trackCount} tracks' : ''),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: textTheme.bodySmall?.copyWith(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
import 'playlist_card.dart';
|
import 'playlist_card.dart';
|
||||||
|
|
||||||
class PlaylistRow extends StatefulWidget {
|
class PlaylistRow extends StatefulWidget {
|
||||||
@@ -30,12 +31,20 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
|
|||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _hasLoaded = false;
|
bool _hasLoaded = false;
|
||||||
|
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => true;
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// Get cached data synchronously BEFORE first build (no spinner flash)
|
||||||
|
final cached = widget.getCachedPlaylists?.call();
|
||||||
|
if (cached != null && cached.isNotEmpty) {
|
||||||
|
_playlists = cached;
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
_loadPlaylists();
|
_loadPlaylists();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,18 +52,7 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
|
|||||||
if (_hasLoaded) return;
|
if (_hasLoaded) return;
|
||||||
_hasLoaded = true;
|
_hasLoaded = true;
|
||||||
|
|
||||||
// 1. Try to get cached data synchronously for instant display
|
// Load fresh data
|
||||||
final cachedPlaylists = widget.getCachedPlaylists?.call();
|
|
||||||
if (cachedPlaylists != null && cachedPlaylists.isNotEmpty) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_playlists = cachedPlaylists;
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Load fresh data
|
|
||||||
try {
|
try {
|
||||||
final freshPlaylists = await widget.loadPlaylists();
|
final freshPlaylists = await widget.loadPlaylists();
|
||||||
if (mounted && freshPlaylists.isNotEmpty) {
|
if (mounted && freshPlaylists.isNotEmpty) {
|
||||||
@@ -91,75 +89,109 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
|
||||||
Widget build(BuildContext context) {
|
// Only show loading if we have no data at all
|
||||||
super.build(context);
|
if (_playlists.isEmpty && _isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
// Don't render if empty and done loading
|
|
||||||
if (_playlists.isEmpty && !_isLoading) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
if (_playlists.isEmpty) {
|
||||||
final textTheme = Theme.of(context).textTheme;
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No playlists found',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate card dimensions
|
// Card layout: square artwork + text below (same as AlbumRow)
|
||||||
// Default: 44 for title, remaining for content (artwork + 2 lines of text)
|
// Text area: 8px gap + ~18px title + ~18px owner = ~44px
|
||||||
final rowHeight = widget.rowHeight ?? 237.0;
|
const textAreaHeight = 44.0;
|
||||||
final titleHeight = 44.0;
|
final artworkSize = contentHeight - textAreaHeight;
|
||||||
final contentHeight = rowHeight - titleHeight;
|
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||||
// Artwork is square, text takes ~50px (8 padding + title + owner)
|
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||||
final artworkSize = contentHeight - 50;
|
|
||||||
final cardWidth = artworkSize;
|
|
||||||
|
|
||||||
return RepaintBoundary(
|
return ScrollConfiguration(
|
||||||
child: Column(
|
behavior: const _StretchScrollBehavior(),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: ListView.builder(
|
||||||
children: [
|
clipBehavior: Clip.none,
|
||||||
// Section title
|
scrollDirection: Axis.horizontal,
|
||||||
Padding(
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
itemCount: _playlists.length,
|
||||||
child: Text(
|
itemExtent: itemExtent,
|
||||||
widget.title,
|
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||||
style: textTheme.titleMedium?.copyWith(
|
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||||
fontWeight: FontWeight.bold,
|
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final playlist = _playlists[index];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4.0),
|
||||||
|
child: Container(
|
||||||
|
key: ValueKey(playlist.uri ?? playlist.itemId),
|
||||||
|
width: cardWidth,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||||
|
child: PlaylistCard(
|
||||||
|
playlist: playlist,
|
||||||
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
|
imageCacheSize: 256,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
// Horizontal list of playlists
|
},
|
||||||
SizedBox(
|
|
||||||
height: contentHeight,
|
|
||||||
child: _isLoading && _playlists.isEmpty
|
|
||||||
? Center(
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: colorScheme.primary,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: ListView.builder(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
||||||
itemCount: _playlists.length,
|
|
||||||
cacheExtent: 500,
|
|
||||||
addAutomaticKeepAlives: false,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final playlist = _playlists[index];
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
|
||||||
child: SizedBox(
|
|
||||||
width: cardWidth,
|
|
||||||
child: PlaylistCard(
|
|
||||||
playlist: playlist,
|
|
||||||
heroTagSuffix: widget.heroTagSuffix,
|
|
||||||
imageCacheSize: 256,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
_logger.startBuild('PlaylistRow:${widget.title}');
|
||||||
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// Total row height includes title + content (same as AlbumRow)
|
||||||
|
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
|
||||||
|
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||||
|
final contentHeight = totalHeight - titleHeight;
|
||||||
|
|
||||||
|
final result = RepaintBoundary(
|
||||||
|
child: SizedBox(
|
||||||
|
height: totalHeight,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||||
|
child: Text(
|
||||||
|
widget.title,
|
||||||
|
style: textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _buildContent(contentHeight, colorScheme),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_logger.endBuild('PlaylistRow:${widget.title}');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect
|
||||||
|
class _StretchScrollBehavior extends ScrollBehavior {
|
||||||
|
const _StretchScrollBehavior();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildOverscrollIndicator(
|
||||||
|
BuildContext context, Widget child, ScrollableDetails details) {
|
||||||
|
return StretchingOverscrollIndicator(
|
||||||
|
axisDirection: details.direction,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
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 '../screens/podcast_detail_screen.dart';
|
||||||
|
import '../constants/hero_tags.dart';
|
||||||
|
import '../constants/timings.dart';
|
||||||
|
import '../theme/theme_provider.dart';
|
||||||
|
import '../utils/page_transitions.dart';
|
||||||
|
|
||||||
|
class PodcastCard extends StatefulWidget {
|
||||||
|
final MediaItem podcast;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
/// Image decode size in pixels. Defaults to 256.
|
||||||
|
/// Use smaller values (e.g., 128) for list views, larger for grids.
|
||||||
|
final int? imageCacheSize;
|
||||||
|
|
||||||
|
const PodcastCard({
|
||||||
|
super.key,
|
||||||
|
required this.podcast,
|
||||||
|
this.onTap,
|
||||||
|
this.heroTagSuffix,
|
||||||
|
this.imageCacheSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PodcastCard> createState() => _PodcastCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PodcastCardState extends State<PodcastCard> {
|
||||||
|
bool _isNavigating = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
// Use provider's getPodcastImageUrl which includes iTunes cache
|
||||||
|
final imageUrl = maProvider.getPodcastImageUrl(widget.podcast, size: 256);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||||
|
|
||||||
|
// PERF: Use appropriate cache size based on display size
|
||||||
|
final cacheSize = widget.imageCacheSize ?? 256;
|
||||||
|
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: widget.onTap ?? () {
|
||||||
|
// Prevent double-tap navigation
|
||||||
|
if (_isNavigating) return;
|
||||||
|
_isNavigating = true;
|
||||||
|
|
||||||
|
// Update adaptive colors immediately on tap
|
||||||
|
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
FadeSlidePageRoute(
|
||||||
|
child: PodcastDetailScreen(
|
||||||
|
podcast: widget.podcast,
|
||||||
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
|
initialImageUrl: imageUrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).then((_) {
|
||||||
|
// Reset after navigation debounce delay
|
||||||
|
Future.delayed(Timings.navigationDebounce, () {
|
||||||
|
if (mounted) _isNavigating = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Podcast artwork - square with rounded corners
|
||||||
|
AspectRatio(
|
||||||
|
aspectRatio: 1.0,
|
||||||
|
child: Hero(
|
||||||
|
tag: HeroTags.podcastCover + (widget.podcast.uri ?? widget.podcast.itemId) + suffix,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12.0),
|
||||||
|
child: Container(
|
||||||
|
color: colorScheme.surfaceVariant,
|
||||||
|
child: imageUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: cacheSize,
|
||||||
|
memCacheHeight: cacheSize,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (context, url) => const SizedBox(),
|
||||||
|
errorWidget: (context, url, error) => Center(
|
||||||
|
child: Icon(
|
||||||
|
MdiIcons.podcast,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: Icon(
|
||||||
|
MdiIcons.podcast,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Podcast title
|
||||||
|
Hero(
|
||||||
|
tag: HeroTags.podcastTitle + (widget.podcast.uri ?? widget.podcast.itemId) + suffix,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Text(
|
||||||
|
widget.podcast.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textTheme.titleSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Podcast author (if available)
|
||||||
|
Text(
|
||||||
|
_getAuthor(widget.podcast),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract author from podcast metadata
|
||||||
|
String _getAuthor(MediaItem podcast) {
|
||||||
|
final metadata = podcast.metadata;
|
||||||
|
if (metadata == null) return '';
|
||||||
|
|
||||||
|
// Try different metadata fields for author
|
||||||
|
final author = metadata['author'] ??
|
||||||
|
metadata['artist'] ??
|
||||||
|
metadata['owner'] ?? '';
|
||||||
|
return author.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import 'podcast_card.dart';
|
||||||
|
|
||||||
|
class PodcastRow extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final Future<List<MediaItem>> Function() loadPodcasts;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
final double? rowHeight;
|
||||||
|
/// Optional: synchronous getter for cached data (for instant display)
|
||||||
|
final List<MediaItem>? Function()? getCachedPodcasts;
|
||||||
|
|
||||||
|
const PodcastRow({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.loadPodcasts,
|
||||||
|
this.heroTagSuffix,
|
||||||
|
this.rowHeight,
|
||||||
|
this.getCachedPodcasts,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PodcastRow> createState() => _PodcastRowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PodcastRowState extends State<PodcastRow> with AutomaticKeepAliveClientMixin {
|
||||||
|
List<MediaItem> _podcasts = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _hasLoaded = false;
|
||||||
|
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Get cached data synchronously BEFORE first build (no spinner flash)
|
||||||
|
final cached = widget.getCachedPodcasts?.call();
|
||||||
|
if (cached != null && cached.isNotEmpty) {
|
||||||
|
_podcasts = cached;
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
|
_loadPodcasts();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPodcasts() async {
|
||||||
|
if (_hasLoaded) return;
|
||||||
|
_hasLoaded = true;
|
||||||
|
|
||||||
|
// Load fresh data (always update - fresh data may have images that cached data lacks)
|
||||||
|
try {
|
||||||
|
final freshPodcasts = await widget.loadPodcasts();
|
||||||
|
if (mounted && freshPodcasts.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_podcasts = freshPodcasts;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
// Pre-cache images for smooth hero animations
|
||||||
|
_precachePodcastImages(freshPodcasts);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent failure - keep showing cached data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted && _isLoading) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-cache podcast images so hero animations are smooth on first tap
|
||||||
|
void _precachePodcastImages(List<MediaItem> podcasts) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
// Only precache first ~10 visible items to avoid excessive network/memory use
|
||||||
|
final podcastsToCache = podcasts.take(10);
|
||||||
|
|
||||||
|
for (final podcast in podcastsToCache) {
|
||||||
|
// Use getPodcastImageUrl which includes iTunes cache
|
||||||
|
final imageUrl = maProvider.getPodcastImageUrl(podcast, size: 256);
|
||||||
|
if (imageUrl != null) {
|
||||||
|
// Use CachedNetworkImageProvider to warm the cache
|
||||||
|
precacheImage(
|
||||||
|
CachedNetworkImageProvider(imageUrl),
|
||||||
|
context,
|
||||||
|
).catchError((_) {
|
||||||
|
// Silently ignore precache errors
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
|
||||||
|
// Only show loading if we have no data at all
|
||||||
|
if (_podcasts.isEmpty && _isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_podcasts.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No podcasts found',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card layout: square artwork + text below (same as AlbumRow)
|
||||||
|
// Text area: 8px gap + ~18px title + ~18px author = ~44px
|
||||||
|
const textAreaHeight = 44.0;
|
||||||
|
final artworkSize = contentHeight - textAreaHeight;
|
||||||
|
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||||
|
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||||
|
|
||||||
|
return ScrollConfiguration(
|
||||||
|
behavior: const _StretchScrollBehavior(),
|
||||||
|
child: ListView.builder(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
itemCount: _podcasts.length,
|
||||||
|
itemExtent: itemExtent,
|
||||||
|
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||||
|
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final podcast = _podcasts[index];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4.0),
|
||||||
|
child: Container(
|
||||||
|
key: ValueKey(podcast.uri ?? podcast.itemId),
|
||||||
|
width: cardWidth,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||||
|
child: PodcastCard(
|
||||||
|
podcast: podcast,
|
||||||
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
|
imageCacheSize: 256,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
_logger.startBuild('PodcastRow:${widget.title}');
|
||||||
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// Total row height includes title + content (same as AlbumRow)
|
||||||
|
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
|
||||||
|
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||||
|
final contentHeight = totalHeight - titleHeight;
|
||||||
|
|
||||||
|
final result = RepaintBoundary(
|
||||||
|
child: SizedBox(
|
||||||
|
height: totalHeight,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||||
|
child: Text(
|
||||||
|
widget.title,
|
||||||
|
style: textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _buildContent(contentHeight, colorScheme),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_logger.endBuild('PodcastRow:${widget.title}');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect
|
||||||
|
class _StretchScrollBehavior extends ScrollBehavior {
|
||||||
|
const _StretchScrollBehavior();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildOverscrollIndicator(
|
||||||
|
BuildContext context, Widget child, ScrollableDetails details) {
|
||||||
|
return StretchingOverscrollIndicator(
|
||||||
|
axisDirection: details.direction,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -5,8 +6,9 @@ import 'package:cached_network_image/cached_network_image.dart';
|
|||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
|
import '../constants/timings.dart';
|
||||||
|
|
||||||
class RadioStationCard extends StatelessWidget {
|
class RadioStationCard extends StatefulWidget {
|
||||||
final MediaItem radioStation;
|
final MediaItem radioStation;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final String? heroTagSuffix;
|
final String? heroTagSuffix;
|
||||||
@@ -20,25 +22,41 @@ class RadioStationCard extends StatelessWidget {
|
|||||||
this.imageCacheSize,
|
this.imageCacheSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RadioStationCard> createState() => _RadioStationCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RadioStationCardState extends State<RadioStationCard> {
|
||||||
|
bool _isTapping = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final imageUrl = maProvider.api?.getImageUrl(radioStation, size: 256);
|
final imageUrl = maProvider.api?.getImageUrl(widget.radioStation, size: 256);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
|
final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||||
final cacheSize = imageCacheSize ?? 256;
|
final cacheSize = widget.imageCacheSize ?? 256;
|
||||||
|
|
||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: onTap ?? () {
|
onTap: widget.onTap ?? () {
|
||||||
|
// Prevent double-tap actions
|
||||||
|
if (_isTapping) return;
|
||||||
|
_isTapping = true;
|
||||||
|
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
// Play the radio station on selected player
|
// Play the radio station on selected player
|
||||||
final selectedPlayer = maProvider.selectedPlayer;
|
final selectedPlayer = maProvider.selectedPlayer;
|
||||||
if (selectedPlayer != null) {
|
if (selectedPlayer != null) {
|
||||||
maProvider.api?.playRadioStation(selectedPlayer.playerId, radioStation);
|
maProvider.api?.playRadioStation(selectedPlayer.playerId, widget.radioStation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset after debounce delay
|
||||||
|
Future.delayed(Timings.navigationDebounce, () {
|
||||||
|
if (mounted) _isTapping = false;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -47,7 +65,7 @@ class RadioStationCard extends StatelessWidget {
|
|||||||
AspectRatio(
|
AspectRatio(
|
||||||
aspectRatio: 1.0,
|
aspectRatio: 1.0,
|
||||||
child: Hero(
|
child: Hero(
|
||||||
tag: HeroTags.radioCover + (radioStation.uri ?? radioStation.itemId) + suffix,
|
tag: HeroTags.radioCover + (widget.radioStation.uri ?? widget.radioStation.itemId) + suffix,
|
||||||
child: ClipOval(
|
child: ClipOval(
|
||||||
child: Container(
|
child: Container(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
@@ -80,19 +98,23 @@ class RadioStationCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Radio station name - centered for circular images
|
// Radio station name - fixed height container so image size is consistent
|
||||||
Hero(
|
SizedBox(
|
||||||
tag: HeroTags.radioTitle + (radioStation.uri ?? radioStation.itemId) + suffix,
|
height: 36, // Fixed height for 2 lines of text
|
||||||
child: Material(
|
child: Hero(
|
||||||
color: Colors.transparent,
|
tag: HeroTags.radioTitle + (widget.radioStation.uri ?? widget.radioStation.itemId) + suffix,
|
||||||
child: Text(
|
child: Material(
|
||||||
radioStation.name,
|
color: Colors.transparent,
|
||||||
maxLines: 2,
|
child: Text(
|
||||||
overflow: TextOverflow.ellipsis,
|
widget.radioStation.name,
|
||||||
textAlign: TextAlign.center,
|
maxLines: 2,
|
||||||
style: textTheme.titleSmall?.copyWith(
|
overflow: TextOverflow.ellipsis,
|
||||||
color: colorScheme.onSurface,
|
textAlign: TextAlign.center,
|
||||||
fontWeight: FontWeight.w500,
|
style: textTheme.titleSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
height: 1.15,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
import 'radio_station_card.dart';
|
import 'radio_station_card.dart';
|
||||||
|
|
||||||
class RadioStationRow extends StatefulWidget {
|
class RadioStationRow extends StatefulWidget {
|
||||||
@@ -30,12 +31,20 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
|
|||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _hasLoaded = false;
|
bool _hasLoaded = false;
|
||||||
|
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => true;
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// Get cached data synchronously BEFORE first build (no spinner flash)
|
||||||
|
final cached = widget.getCachedRadioStations?.call();
|
||||||
|
if (cached != null && cached.isNotEmpty) {
|
||||||
|
_radioStations = cached;
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
_loadRadioStations();
|
_loadRadioStations();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,18 +52,7 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
|
|||||||
if (_hasLoaded) return;
|
if (_hasLoaded) return;
|
||||||
_hasLoaded = true;
|
_hasLoaded = true;
|
||||||
|
|
||||||
// 1. Try to get cached data synchronously for instant display
|
// Load fresh data
|
||||||
final cachedStations = widget.getCachedRadioStations?.call();
|
|
||||||
if (cachedStations != null && cachedStations.isNotEmpty) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_radioStations = cachedStations;
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Load fresh data
|
|
||||||
try {
|
try {
|
||||||
final freshStations = await widget.loadRadioStations();
|
final freshStations = await widget.loadRadioStations();
|
||||||
if (mounted && freshStations.isNotEmpty) {
|
if (mounted && freshStations.isNotEmpty) {
|
||||||
@@ -91,75 +89,105 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
|
||||||
Widget build(BuildContext context) {
|
// Only show loading if we have no data at all
|
||||||
super.build(context);
|
if (_radioStations.isEmpty && _isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
// Don't render if empty and done loading
|
|
||||||
if (_radioStations.isEmpty && !_isLoading) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
if (_radioStations.isEmpty) {
|
||||||
final textTheme = Theme.of(context).textTheme;
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No radio stations found',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate card dimensions - similar to ArtistRow for circular items
|
// Card layout: circle image + name below (same as ArtistRow)
|
||||||
// Default: 44 for title, remaining for content (circular image + name)
|
// Text area: 8px gap + ~36px for 2-line name = ~44px
|
||||||
final rowHeight = widget.rowHeight ?? 207.0;
|
const textAreaHeight = 44.0;
|
||||||
final titleHeight = 44.0;
|
final imageSize = contentHeight - textAreaHeight;
|
||||||
final contentHeight = rowHeight - titleHeight;
|
final cardWidth = imageSize; // Card width = image width (circle)
|
||||||
// Circular artwork, text takes ~40px (8 padding + name lines)
|
final itemExtent = cardWidth + 16; // width + horizontal margins
|
||||||
final artworkSize = contentHeight - 40;
|
|
||||||
final cardWidth = artworkSize;
|
|
||||||
|
|
||||||
return RepaintBoundary(
|
return ScrollConfiguration(
|
||||||
child: Column(
|
behavior: const _StretchScrollBehavior(),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: ListView.builder(
|
||||||
children: [
|
scrollDirection: Axis.horizontal,
|
||||||
// Section title
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
Padding(
|
itemCount: _radioStations.length,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
itemExtent: itemExtent,
|
||||||
child: Text(
|
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||||
widget.title,
|
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||||
style: textTheme.titleMedium?.copyWith(
|
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||||
fontWeight: FontWeight.bold,
|
itemBuilder: (context, index) {
|
||||||
),
|
final station = _radioStations[index];
|
||||||
|
return Container(
|
||||||
|
key: ValueKey(station.uri ?? station.itemId),
|
||||||
|
width: cardWidth,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
|
child: RadioStationCard(
|
||||||
|
radioStation: station,
|
||||||
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
|
imageCacheSize: 256,
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
// Horizontal list of radio stations
|
},
|
||||||
SizedBox(
|
|
||||||
height: contentHeight,
|
|
||||||
child: _isLoading && _radioStations.isEmpty
|
|
||||||
? Center(
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: colorScheme.primary,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: ListView.builder(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
||||||
itemCount: _radioStations.length,
|
|
||||||
cacheExtent: 500,
|
|
||||||
addAutomaticKeepAlives: false,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final station = _radioStations[index];
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
|
||||||
child: SizedBox(
|
|
||||||
width: cardWidth,
|
|
||||||
child: RadioStationCard(
|
|
||||||
radioStation: station,
|
|
||||||
heroTagSuffix: widget.heroTagSuffix,
|
|
||||||
imageCacheSize: 256,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
_logger.startBuild('RadioStationRow:${widget.title}');
|
||||||
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// Total row height includes title + content (same as ArtistRow)
|
||||||
|
final totalHeight = widget.rowHeight ?? 207.0; // Default: 44 title + 163 content
|
||||||
|
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||||
|
final contentHeight = totalHeight - titleHeight;
|
||||||
|
|
||||||
|
final result = RepaintBoundary(
|
||||||
|
child: SizedBox(
|
||||||
|
height: totalHeight,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||||
|
child: Text(
|
||||||
|
widget.title,
|
||||||
|
style: textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _buildContent(contentHeight, colorScheme),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_logger.endBuild('RadioStationRow:${widget.title}');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect
|
||||||
|
class _StretchScrollBehavior extends ScrollBehavior {
|
||||||
|
const _StretchScrollBehavior();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildOverscrollIndicator(
|
||||||
|
BuildContext context, Widget child, ScrollableDetails details) {
|
||||||
|
return StretchingOverscrollIndicator(
|
||||||
|
axisDirection: details.direction,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ class SeriesRow extends StatefulWidget {
|
|||||||
final String title;
|
final String title;
|
||||||
final Future<List<AudiobookSeries>> Function() loadSeries;
|
final Future<List<AudiobookSeries>> Function() loadSeries;
|
||||||
final double? rowHeight;
|
final double? rowHeight;
|
||||||
|
/// Optional: synchronous getter for cached data (for instant display)
|
||||||
|
final List<AudiobookSeries>? Function()? getCachedSeries;
|
||||||
|
|
||||||
const SeriesRow({
|
const SeriesRow({
|
||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.loadSeries,
|
required this.loadSeries,
|
||||||
this.rowHeight,
|
this.rowHeight,
|
||||||
|
this.getCachedSeries,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -25,8 +28,8 @@ class SeriesRow extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixin {
|
class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixin {
|
||||||
late Future<List<AudiobookSeries>> _seriesFuture;
|
List<AudiobookSeries> _series = [];
|
||||||
List<AudiobookSeries>? _cachedSeries;
|
bool _isLoading = true;
|
||||||
bool _hasLoaded = false;
|
bool _hasLoaded = false;
|
||||||
|
|
||||||
// Cache for series cover images
|
// Cache for series cover images
|
||||||
@@ -43,16 +46,34 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadSeriesOnce();
|
// Get cached data synchronously BEFORE first build (no spinner flash)
|
||||||
|
final cached = widget.getCachedSeries?.call();
|
||||||
|
if (cached != null && cached.isNotEmpty) {
|
||||||
|
_series = cached;
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
|
_loadSeries();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _loadSeriesOnce() {
|
Future<void> _loadSeries() async {
|
||||||
if (!_hasLoaded) {
|
if (_hasLoaded) return;
|
||||||
_seriesFuture = widget.loadSeries().then((series) {
|
_hasLoaded = true;
|
||||||
_cachedSeries = series;
|
|
||||||
return series;
|
// Load fresh data (always update)
|
||||||
});
|
try {
|
||||||
_hasLoaded = true;
|
final freshSeries = await widget.loadSeries();
|
||||||
|
if (mounted && freshSeries.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_series = freshSeries;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent failure - keep showing cached data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted && _isLoading) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,8 +99,12 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
|||||||
_seriesCovers[seriesId] = covers;
|
_seriesCovers[seriesId] = covers;
|
||||||
_seriesBookCounts[seriesId] = books.length;
|
_seriesBookCounts[seriesId] = books.length;
|
||||||
});
|
});
|
||||||
// Extract colors asynchronously
|
// Precache images for smooth hero animations
|
||||||
_extractSeriesColors(seriesId, covers);
|
_precacheSeriesCovers(covers);
|
||||||
|
// Extract colors asynchronously (delayed to avoid jank during scroll)
|
||||||
|
Future.delayed(const Duration(milliseconds: 500), () {
|
||||||
|
if (mounted) _extractSeriesColors(seriesId, covers);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -87,6 +112,17 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Precache series cover images for smooth hero animations
|
||||||
|
void _precacheSeriesCovers(List<String> covers) {
|
||||||
|
if (!mounted) return;
|
||||||
|
for (final url in covers) {
|
||||||
|
precacheImage(
|
||||||
|
CachedNetworkImageProvider(url),
|
||||||
|
context,
|
||||||
|
).catchError((_) => false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Extract colors from series book covers for empty cell backgrounds
|
/// Extract colors from series book covers for empty cell backgrounds
|
||||||
Future<void> _extractSeriesColors(String seriesId, List<String> covers) async {
|
Future<void> _extractSeriesColors(String seriesId, List<String> covers) async {
|
||||||
if (_seriesExtractedColors.containsKey(seriesId) || covers.isEmpty) return;
|
if (_seriesExtractedColors.containsKey(seriesId) || covers.isEmpty) return;
|
||||||
@@ -127,10 +163,68 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
|||||||
|
|
||||||
static final _logger = DebugLogger();
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
|
Widget _buildContent(double contentHeight, ColorScheme colorScheme, TextTheme textTheme, MusicAssistantProvider maProvider) {
|
||||||
|
// Only show loading if we have no data at all
|
||||||
|
if (_series.isEmpty && _isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_series.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No series found',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textAreaHeight = 44.0;
|
||||||
|
final artworkSize = contentHeight - textAreaHeight;
|
||||||
|
final cardWidth = artworkSize;
|
||||||
|
final itemExtent = cardWidth + 12;
|
||||||
|
|
||||||
|
return ScrollConfiguration(
|
||||||
|
behavior: const _StretchScrollBehavior(),
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
itemCount: _series.length,
|
||||||
|
itemExtent: itemExtent,
|
||||||
|
cacheExtent: 500,
|
||||||
|
addAutomaticKeepAlives: false,
|
||||||
|
addRepaintBoundaries: false,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final s = _series[index];
|
||||||
|
|
||||||
|
// Load covers if not cached
|
||||||
|
if (!_seriesCovers.containsKey(s.id) && !_loadingCovers.contains(s.id)) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_loadSeriesCovers(s.id, maProvider);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
key: ValueKey(s.id),
|
||||||
|
width: cardWidth,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||||
|
child: _SeriesCard(
|
||||||
|
series: s,
|
||||||
|
covers: _seriesCovers[s.id],
|
||||||
|
cachedBookCount: _seriesBookCounts[s.id],
|
||||||
|
extractedColors: _seriesExtractedColors[s.id],
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
textTheme: textTheme,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
_logger.startBuild('SeriesRow:${widget.title}');
|
_logger.startBuild('SeriesRow:${widget.title}');
|
||||||
super.build(context);
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
@@ -156,73 +250,7 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FutureBuilder<List<AudiobookSeries>>(
|
child: _buildContent(contentHeight, colorScheme, textTheme, maProvider),
|
||||||
future: _seriesFuture,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
final series = snapshot.data ?? _cachedSeries;
|
|
||||||
|
|
||||||
if (series == null && snapshot.connectionState == ConnectionState.waiting) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot.hasError && series == null) {
|
|
||||||
return Center(
|
|
||||||
child: Text('Error: ${snapshot.error}'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (series == null || series.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Text(
|
|
||||||
'No series found',
|
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const textAreaHeight = 44.0;
|
|
||||||
final artworkSize = contentHeight - textAreaHeight;
|
|
||||||
final cardWidth = artworkSize;
|
|
||||||
final itemExtent = cardWidth + 12;
|
|
||||||
|
|
||||||
return ScrollConfiguration(
|
|
||||||
behavior: const _StretchScrollBehavior(),
|
|
||||||
child: ListView.builder(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
||||||
itemCount: series.length,
|
|
||||||
itemExtent: itemExtent,
|
|
||||||
cacheExtent: 500,
|
|
||||||
addAutomaticKeepAlives: false,
|
|
||||||
addRepaintBoundaries: false,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final s = series[index];
|
|
||||||
|
|
||||||
// Load covers if not cached
|
|
||||||
if (!_seriesCovers.containsKey(s.id) && !_loadingCovers.contains(s.id)) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
_loadSeriesCovers(s.id, maProvider);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
key: ValueKey(s.id),
|
|
||||||
width: cardWidth,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
|
||||||
child: _SeriesCard(
|
|
||||||
series: s,
|
|
||||||
covers: _seriesCovers[s.id],
|
|
||||||
cachedBookCount: _seriesBookCounts[s.id],
|
|
||||||
extractedColors: _seriesExtractedColors[s.id],
|
|
||||||
colorScheme: colorScheme,
|
|
||||||
textTheme: textTheme,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -286,49 +314,14 @@ class _SeriesCard extends StatelessWidget {
|
|||||||
// Use AspectRatio to guarantee square cover
|
// Use AspectRatio to guarantee square cover
|
||||||
Hero(
|
Hero(
|
||||||
tag: heroTag,
|
tag: heroTag,
|
||||||
// PERF: Use flightShuttleBuilder to avoid animating complex grid
|
// RepaintBoundary caches the rendered grid for smooth animation
|
||||||
// during hero flight. Show cached first cover image instead.
|
child: RepaintBoundary(
|
||||||
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
|
child: AspectRatio(
|
||||||
// Use first cover as simple placeholder during flight
|
aspectRatio: 1,
|
||||||
final heroChild = toContext.widget as Hero;
|
child: ClipRRect(
|
||||||
return AnimatedBuilder(
|
borderRadius: BorderRadius.circular(12), // Match detail screen
|
||||||
animation: animation,
|
child: _buildCoverGrid(),
|
||||||
builder: (context, child) {
|
),
|
||||||
return ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8 + 4 * animation.value),
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: covers != null && covers!.isNotEmpty
|
|
||||||
? CachedNetworkImage(
|
|
||||||
imageUrl: covers!.first,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
fadeInDuration: Duration.zero,
|
|
||||||
fadeOutDuration: Duration.zero,
|
|
||||||
placeholder: (_, __) => Container(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
),
|
|
||||||
errorWidget: (_, __, ___) => Container(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
child: Center(
|
|
||||||
child: Icon(
|
|
||||||
Icons.collections_bookmark_rounded,
|
|
||||||
size: 48,
|
|
||||||
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: AspectRatio(
|
|
||||||
aspectRatio: 1,
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: _buildCoverGrid(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ class TrackRow extends StatefulWidget {
|
|||||||
final String title;
|
final String title;
|
||||||
final Future<List<Track>> Function() loadTracks;
|
final Future<List<Track>> Function() loadTracks;
|
||||||
final double? rowHeight;
|
final double? rowHeight;
|
||||||
|
/// Optional: synchronous getter for cached data (for instant display)
|
||||||
|
final List<Track>? Function()? getCachedTracks;
|
||||||
|
|
||||||
const TrackRow({
|
const TrackRow({
|
||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.loadTracks,
|
required this.loadTracks,
|
||||||
this.rowHeight,
|
this.rowHeight,
|
||||||
|
this.getCachedTracks,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -22,8 +25,8 @@ class TrackRow extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin {
|
class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin {
|
||||||
late Future<List<Track>> _tracksFuture;
|
List<Track> _tracks = [];
|
||||||
List<Track>? _cachedTracks;
|
bool _isLoading = true;
|
||||||
bool _hasLoaded = false;
|
bool _hasLoaded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -32,25 +35,111 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadTracksOnce();
|
// Get cached data synchronously BEFORE first build (no spinner flash)
|
||||||
|
final cached = widget.getCachedTracks?.call();
|
||||||
|
if (cached != null && cached.isNotEmpty) {
|
||||||
|
_tracks = cached;
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
|
_loadTracks();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _loadTracksOnce() {
|
Future<void> _loadTracks() async {
|
||||||
if (!_hasLoaded) {
|
if (_hasLoaded) return;
|
||||||
_tracksFuture = widget.loadTracks().then((tracks) {
|
_hasLoaded = true;
|
||||||
_cachedTracks = tracks;
|
|
||||||
return tracks;
|
// Load fresh data (always update)
|
||||||
});
|
try {
|
||||||
_hasLoaded = true;
|
final freshTracks = await widget.loadTracks();
|
||||||
|
if (mounted && freshTracks.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_tracks = freshTracks;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
// Pre-cache images for smooth scrolling
|
||||||
|
_precacheTrackImages(freshTracks);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent failure - keep showing cached data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted && _isLoading) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _precacheTrackImages(List<Track> tracks) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
final tracksToCache = tracks.take(10);
|
||||||
|
|
||||||
|
for (final track in tracksToCache) {
|
||||||
|
final imageUrl = maProvider.api?.getImageUrl(track, size: 256);
|
||||||
|
if (imageUrl != null) {
|
||||||
|
precacheImage(
|
||||||
|
CachedNetworkImageProvider(imageUrl),
|
||||||
|
context,
|
||||||
|
).catchError((_) => false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static final _logger = DebugLogger();
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
|
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
|
||||||
|
// Only show loading if we have no data at all
|
||||||
|
if (_tracks.isEmpty && _isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_tracks.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No tracks found',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card layout: square artwork + text below
|
||||||
|
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
|
||||||
|
const textAreaHeight = 44.0;
|
||||||
|
final artworkSize = contentHeight - textAreaHeight;
|
||||||
|
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||||
|
final itemExtent = cardWidth + 12;
|
||||||
|
|
||||||
|
return ScrollConfiguration(
|
||||||
|
behavior: const _StretchScrollBehavior(),
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
itemCount: _tracks.length,
|
||||||
|
itemExtent: itemExtent,
|
||||||
|
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||||
|
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final track = _tracks[index];
|
||||||
|
return Container(
|
||||||
|
key: ValueKey(track.uri ?? track.itemId),
|
||||||
|
width: cardWidth,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||||
|
child: _TrackCard(
|
||||||
|
track: track,
|
||||||
|
tracks: _tracks,
|
||||||
|
index: index,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
_logger.startBuild('TrackRow:${widget.title}');
|
_logger.startBuild('TrackRow:${widget.title}');
|
||||||
super.build(context);
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
@@ -76,64 +165,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FutureBuilder<List<Track>>(
|
child: _buildContent(contentHeight, colorScheme),
|
||||||
future: _tracksFuture,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
final tracks = snapshot.data ?? _cachedTracks;
|
|
||||||
|
|
||||||
if (tracks == null && snapshot.connectionState == ConnectionState.waiting) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot.hasError && tracks == null) {
|
|
||||||
return Center(
|
|
||||||
child: Text('Error: ${snapshot.error}'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tracks == null || tracks.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Text(
|
|
||||||
'No tracks found',
|
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Card layout: square artwork + text below
|
|
||||||
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
|
|
||||||
const textAreaHeight = 44.0;
|
|
||||||
final artworkSize = contentHeight - textAreaHeight;
|
|
||||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
|
||||||
final itemExtent = cardWidth + 12;
|
|
||||||
|
|
||||||
return ScrollConfiguration(
|
|
||||||
behavior: const _StretchScrollBehavior(),
|
|
||||||
child: ListView.builder(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
||||||
itemCount: tracks.length,
|
|
||||||
itemExtent: itemExtent,
|
|
||||||
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
|
||||||
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
|
||||||
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final track = tracks[index];
|
|
||||||
return Container(
|
|
||||||
key: ValueKey(track.uri ?? track.itemId),
|
|
||||||
width: cardWidth,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
|
||||||
child: _TrackCard(
|
|
||||||
track: track,
|
|
||||||
tracks: tracks,
|
|
||||||
index: index,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
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:flutter_volume_controller/flutter_volume_controller.dart';
|
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
@@ -11,8 +12,9 @@ import '../services/settings_service.dart';
|
|||||||
/// For MA players: controls player volume via API
|
/// For MA players: controls player volume via API
|
||||||
class VolumeControl extends StatefulWidget {
|
class VolumeControl extends StatefulWidget {
|
||||||
final bool compact;
|
final bool compact;
|
||||||
|
final Color? accentColor;
|
||||||
|
|
||||||
const VolumeControl({super.key, this.compact = false});
|
const VolumeControl({super.key, this.compact = false, this.accentColor});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<VolumeControl> createState() => _VolumeControlState();
|
State<VolumeControl> createState() => _VolumeControlState();
|
||||||
@@ -25,11 +27,34 @@ class _VolumeControlState extends State<VolumeControl> {
|
|||||||
StreamSubscription? _volumeListener;
|
StreamSubscription? _volumeListener;
|
||||||
String? _localPlayerId;
|
String? _localPlayerId;
|
||||||
bool _isLocalPlayer = false;
|
bool _isLocalPlayer = false;
|
||||||
|
bool _isDragging = false;
|
||||||
|
|
||||||
|
// Live volume update state
|
||||||
|
int _lastVolumeUpdateTime = 0;
|
||||||
|
static const int _volumeThrottleMs = 150;
|
||||||
|
static const int _precisionThrottleMs = 50;
|
||||||
|
|
||||||
|
// Precision mode state
|
||||||
|
bool _inPrecisionMode = false;
|
||||||
|
Timer? _precisionTimer;
|
||||||
|
Offset? _lastDragPosition;
|
||||||
|
double _lastLocalX = 0.0;
|
||||||
|
bool _precisionModeEnabled = true;
|
||||||
|
double _precisionZoomCenter = 0.0;
|
||||||
|
double _precisionStartX = 0.0;
|
||||||
|
static const int _precisionTriggerMs = 800;
|
||||||
|
static const double _precisionStillnessThreshold = 5.0;
|
||||||
|
static const double _precisionSensitivity = 0.1; // 10% range in precision mode
|
||||||
|
|
||||||
|
// Button tap indicator state
|
||||||
|
bool _showButtonIndicator = false;
|
||||||
|
Timer? _buttonIndicatorTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_initLocalPlayer();
|
_initLocalPlayer();
|
||||||
|
_loadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initLocalPlayer() async {
|
Future<void> _initLocalPlayer() async {
|
||||||
@@ -43,7 +68,7 @@ class _VolumeControlState extends State<VolumeControl> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_volumeListener = FlutterVolumeController.addListener((volume) {
|
_volumeListener = FlutterVolumeController.addListener((volume) {
|
||||||
if (mounted && _isLocalPlayer) {
|
if (mounted && _isLocalPlayer && !_isDragging) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_systemVolume = volume;
|
_systemVolume = volume;
|
||||||
});
|
});
|
||||||
@@ -51,12 +76,99 @@ class _VolumeControlState extends State<VolumeControl> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSettings() async {
|
||||||
|
final precisionEnabled = await SettingsService.getVolumePrecisionMode();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_precisionModeEnabled = precisionEnabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _enterPrecisionMode() {
|
||||||
|
if (_inPrecisionMode) return;
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
setState(() {
|
||||||
|
_inPrecisionMode = true;
|
||||||
|
_precisionZoomCenter = _pendingVolume ?? 0.5;
|
||||||
|
_precisionStartX = _lastLocalX;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _exitPrecisionMode() {
|
||||||
|
_precisionTimer?.cancel();
|
||||||
|
_precisionTimer = null;
|
||||||
|
if (_inPrecisionMode) {
|
||||||
|
setState(() {
|
||||||
|
_inPrecisionMode = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_volumeListener?.cancel();
|
_volumeListener?.cancel();
|
||||||
|
_precisionTimer?.cancel();
|
||||||
|
_buttonIndicatorTimer?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _adjustVolume(MusicAssistantProvider maProvider, String playerId, double currentVolume, int delta) async {
|
||||||
|
final newVolume = ((currentVolume * 100).round() + delta).clamp(0, 100);
|
||||||
|
|
||||||
|
// Show indicator briefly on button tap
|
||||||
|
_buttonIndicatorTimer?.cancel();
|
||||||
|
setState(() {
|
||||||
|
_showButtonIndicator = true;
|
||||||
|
_pendingVolume = newVolume / 100.0;
|
||||||
|
});
|
||||||
|
_buttonIndicatorTimer = Timer(const Duration(milliseconds: 800), () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_showButtonIndicator = false;
|
||||||
|
_pendingVolume = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_isLocalPlayer) {
|
||||||
|
_logger.log('Volume: Setting system volume to $newVolume%');
|
||||||
|
try {
|
||||||
|
await FlutterVolumeController.setVolume(newVolume / 100.0);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_systemVolume = newVolume / 100.0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Volume: Error setting system volume - $e');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_logger.log('Volume: Setting to $newVolume%');
|
||||||
|
try {
|
||||||
|
await maProvider.setVolume(playerId, newVolume);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Volume: Error - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendVolumeUpdate(MusicAssistantProvider maProvider, String playerId, double volume) {
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final throttleMs = _inPrecisionMode ? _precisionThrottleMs : _volumeThrottleMs;
|
||||||
|
|
||||||
|
if (now - _lastVolumeUpdateTime >= throttleMs) {
|
||||||
|
_lastVolumeUpdateTime = now;
|
||||||
|
final volumeLevel = (volume * 100).round();
|
||||||
|
|
||||||
|
if (_isLocalPlayer) {
|
||||||
|
FlutterVolumeController.setVolume(volume);
|
||||||
|
} else {
|
||||||
|
maProvider.setVolume(playerId, volumeLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.watch<MusicAssistantProvider>();
|
final maProvider = context.watch<MusicAssistantProvider>();
|
||||||
@@ -68,118 +180,262 @@ class _VolumeControlState extends State<VolumeControl> {
|
|||||||
|
|
||||||
_isLocalPlayer = _localPlayerId != null && player.playerId == _localPlayerId;
|
_isLocalPlayer = _localPlayerId != null && player.playerId == _localPlayerId;
|
||||||
|
|
||||||
final currentVolume = _isLocalPlayer
|
// Use pending volume during drag or button tap to prevent stale state issues
|
||||||
? (_pendingVolume ?? (_systemVolume ?? 0.5))
|
final currentVolume = (_isDragging || _showButtonIndicator)
|
||||||
: (_pendingVolume ?? player.volume.toDouble()) / 100.0;
|
? (_pendingVolume ?? (_isLocalPlayer ? (_systemVolume ?? 0.5) : player.volume.toDouble() / 100.0))
|
||||||
final isMuted = _isLocalPlayer ? false : player.isMuted;
|
: _isLocalPlayer
|
||||||
|
? (_systemVolume ?? 0.5)
|
||||||
|
: player.volume.toDouble() / 100.0;
|
||||||
|
|
||||||
|
// Use accent color if provided, otherwise fall back to theme primary
|
||||||
|
final accentColor = widget.accentColor ?? Theme.of(context).colorScheme.primary;
|
||||||
|
|
||||||
if (widget.compact) {
|
if (widget.compact) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
isMuted ? Icons.volume_off : Icons.volume_up,
|
currentVolume < 0.01
|
||||||
|
? Icons.volume_off
|
||||||
|
: currentVolume < 0.3
|
||||||
|
? Icons.volume_down
|
||||||
|
: Icons.volume_up,
|
||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
try {
|
// In compact mode, toggle between 0 and 50%
|
||||||
await maProvider.setMute(player.playerId, !isMuted);
|
final newVolume = currentVolume < 0.01 ? 50 : 0;
|
||||||
} catch (e) {
|
await _adjustVolume(maProvider, player.playerId, newVolume / 100.0, 0);
|
||||||
// Error already logged by provider
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
|
// Volume decrease button
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: const Icon(
|
||||||
isMuted
|
Icons.volume_down,
|
||||||
? Icons.volume_off
|
|
||||||
: currentVolume < 0.3
|
|
||||||
? Icons.volume_down
|
|
||||||
: Icons.volume_up,
|
|
||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () => _adjustVolume(maProvider, player.playerId, currentVolume, -1),
|
||||||
try {
|
|
||||||
await maProvider.setMute(player.playerId, !isMuted);
|
|
||||||
} catch (e) {
|
|
||||||
// Error already logged by provider
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
// Slider with floating teardrop indicator
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 48, // Increase touch target height
|
height: 48,
|
||||||
child: SliderTheme(
|
child: LayoutBuilder(
|
||||||
data: SliderThemeData(
|
builder: (context, constraints) {
|
||||||
trackHeight: 2,
|
final sliderWidth = constraints.maxWidth;
|
||||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
// Calculate thumb position to match Flutter Slider's internal positioning
|
||||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
|
// The Slider uses overlayRadius as padding on each side for the track
|
||||||
activeTrackColor: Colors.white,
|
const overlayRadius = 20.0; // Must match SliderThemeData overlayShape
|
||||||
inactiveTrackColor: Colors.white.withOpacity(0.3),
|
final thumbPosition = overlayRadius +
|
||||||
thumbColor: Colors.white,
|
(currentVolume.clamp(0.0, 1.0) * (sliderWidth - 2 * overlayRadius));
|
||||||
overlayColor: Colors.white.withOpacity(0.2),
|
|
||||||
),
|
return Stack(
|
||||||
child: Slider(
|
clipBehavior: Clip.none,
|
||||||
value: currentVolume.clamp(0.0, 1.0),
|
children: [
|
||||||
onChanged: (value) {
|
// The slider with gesture detection for precision mode
|
||||||
setState(() {
|
GestureDetector(
|
||||||
_pendingVolume = _isLocalPlayer ? value : value * 100;
|
onHorizontalDragStart: (details) {
|
||||||
});
|
setState(() {
|
||||||
|
_isDragging = true;
|
||||||
|
_pendingVolume = currentVolume;
|
||||||
|
_lastDragPosition = details.globalPosition;
|
||||||
|
_lastLocalX = details.localPosition.dx;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onHorizontalDragUpdate: (details) {
|
||||||
|
if (!_isDragging) return;
|
||||||
|
|
||||||
|
final currentPosition = details.globalPosition;
|
||||||
|
|
||||||
|
// Check for stillness to trigger precision mode
|
||||||
|
if (_precisionModeEnabled && _lastDragPosition != null) {
|
||||||
|
final movement = (currentPosition - _lastDragPosition!).distance;
|
||||||
|
|
||||||
|
if (movement < _precisionStillnessThreshold) {
|
||||||
|
// Finger is still - start precision timer
|
||||||
|
if (_precisionTimer == null && !_inPrecisionMode) {
|
||||||
|
_precisionTimer = Timer(
|
||||||
|
Duration(milliseconds: _precisionTriggerMs),
|
||||||
|
_enterPrecisionMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Finger moved - cancel timer
|
||||||
|
_precisionTimer?.cancel();
|
||||||
|
_precisionTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_lastDragPosition = currentPosition;
|
||||||
|
_lastLocalX = details.localPosition.dx;
|
||||||
|
|
||||||
|
double newVolume;
|
||||||
|
|
||||||
|
if (_inPrecisionMode) {
|
||||||
|
// PRECISION MODE: Movement from entry point maps to zoomed range
|
||||||
|
final offsetX = details.localPosition.dx - _precisionStartX;
|
||||||
|
final normalizedOffset = offsetX / sliderWidth;
|
||||||
|
final volumeChange = normalizedOffset * _precisionSensitivity;
|
||||||
|
newVolume = (_precisionZoomCenter + volumeChange).clamp(0.0, 1.0);
|
||||||
|
} else {
|
||||||
|
// NORMAL MODE: Position-based (like standard slider)
|
||||||
|
newVolume = (details.localPosition.dx / sliderWidth).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((newVolume - (_pendingVolume ?? 0)).abs() > 0.001) {
|
||||||
|
setState(() {
|
||||||
|
_pendingVolume = newVolume;
|
||||||
|
});
|
||||||
|
_sendVolumeUpdate(maProvider, player.playerId, newVolume);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onHorizontalDragEnd: (details) {
|
||||||
|
if (!_isDragging) return;
|
||||||
|
|
||||||
|
// Send final volume
|
||||||
|
final finalVolume = _pendingVolume ?? currentVolume;
|
||||||
|
final volumeLevel = (finalVolume * 100).round();
|
||||||
|
|
||||||
|
if (_isLocalPlayer) {
|
||||||
|
FlutterVolumeController.setVolume(finalVolume);
|
||||||
|
_systemVolume = finalVolume;
|
||||||
|
} else {
|
||||||
|
maProvider.setVolume(player.playerId, volumeLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
_exitPrecisionMode();
|
||||||
|
_lastDragPosition = null;
|
||||||
|
setState(() {
|
||||||
|
_isDragging = false;
|
||||||
|
_pendingVolume = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: SliderTheme(
|
||||||
|
data: SliderThemeData(
|
||||||
|
trackHeight: 2,
|
||||||
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||||
|
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
|
||||||
|
activeTrackColor: _inPrecisionMode ? accentColor : Colors.white,
|
||||||
|
inactiveTrackColor: Colors.white.withOpacity(0.3),
|
||||||
|
thumbColor: _inPrecisionMode ? accentColor : Colors.white,
|
||||||
|
overlayColor: (_inPrecisionMode ? accentColor : Colors.white).withOpacity(0.2),
|
||||||
|
),
|
||||||
|
child: AbsorbPointer(
|
||||||
|
// AbsorbPointer prevents Slider from handling gestures
|
||||||
|
// Our GestureDetector handles everything for live updates
|
||||||
|
child: Slider(
|
||||||
|
value: currentVolume.clamp(0.0, 1.0),
|
||||||
|
onChanged: (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Floating teardrop indicator (visible when dragging or button tap)
|
||||||
|
if (_isDragging || _showButtonIndicator)
|
||||||
|
Positioned(
|
||||||
|
left: thumbPosition - 16,
|
||||||
|
top: -28,
|
||||||
|
child: CustomPaint(
|
||||||
|
size: const Size(32, 32),
|
||||||
|
painter: _TeardropPainter(
|
||||||
|
color: _inPrecisionMode ? accentColor : Colors.white,
|
||||||
|
volume: (currentVolume * 100).round(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onChangeEnd: (value) async {
|
|
||||||
if (_isLocalPlayer) {
|
|
||||||
_logger.log('Volume: Setting system volume to ${(value * 100).round()}%');
|
|
||||||
try {
|
|
||||||
await FlutterVolumeController.setVolume(value);
|
|
||||||
_systemVolume = value;
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Volume: Error setting system volume - $e');
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_pendingVolume = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
final volumeLevel = (value * 100).round();
|
|
||||||
_logger.log('Volume: Setting to $volumeLevel%');
|
|
||||||
try {
|
|
||||||
if (isMuted) {
|
|
||||||
_logger.log('Volume: Unmuting player first');
|
|
||||||
await maProvider.setMute(player.playerId, false);
|
|
||||||
}
|
|
||||||
await maProvider.setVolume(player.playerId, volumeLevel);
|
|
||||||
_logger.log('Volume: Set to $volumeLevel%');
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Volume: Error - $e');
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_pendingVolume = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(
|
// Volume increase button
|
||||||
width: 40,
|
IconButton(
|
||||||
child: Text(
|
icon: const Icon(
|
||||||
'${(currentVolume * 100).round()}%',
|
Icons.volume_up,
|
||||||
style: TextStyle(
|
color: Colors.white70,
|
||||||
color: Colors.grey[400],
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
),
|
||||||
|
onPressed: () => _adjustVolume(maProvider, player.playerId, currentVolume, 1),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Custom painter for upside-down teardrop volume indicator
|
||||||
|
class _TeardropPainter extends CustomPainter {
|
||||||
|
final Color color;
|
||||||
|
final int volume;
|
||||||
|
|
||||||
|
_TeardropPainter({required this.color, required this.volume});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()
|
||||||
|
..color = color
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
|
||||||
|
final center = Offset(size.width / 2, size.height / 2);
|
||||||
|
final radius = size.width / 2 - 2;
|
||||||
|
|
||||||
|
// Draw upside-down teardrop shape
|
||||||
|
final path = Path();
|
||||||
|
|
||||||
|
// Start at bottom point (the tip pointing down toward slider)
|
||||||
|
path.moveTo(center.dx, size.height - 2);
|
||||||
|
|
||||||
|
// Curve up to the left side of the circle
|
||||||
|
path.quadraticBezierTo(
|
||||||
|
center.dx - radius * 0.8,
|
||||||
|
center.dy + radius * 0.3,
|
||||||
|
center.dx - radius,
|
||||||
|
center.dy - radius * 0.2,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Arc around the top (the bulb)
|
||||||
|
path.arcToPoint(
|
||||||
|
Offset(center.dx + radius, center.dy - radius * 0.2),
|
||||||
|
radius: Radius.circular(radius),
|
||||||
|
clockwise: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Curve down to the bottom point
|
||||||
|
path.quadraticBezierTo(
|
||||||
|
center.dx + radius * 0.8,
|
||||||
|
center.dy + radius * 0.3,
|
||||||
|
center.dx,
|
||||||
|
size.height - 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
path.close();
|
||||||
|
canvas.drawPath(path, paint);
|
||||||
|
|
||||||
|
// Draw the volume number (no % symbol)
|
||||||
|
final textColor = color.computeLuminance() > 0.5 ? Colors.black87 : Colors.white;
|
||||||
|
final textPainter = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: '$volume',
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
);
|
||||||
|
textPainter.layout();
|
||||||
|
|
||||||
|
// Position text in the center of the bulb (slightly above center)
|
||||||
|
final textOffset = Offset(
|
||||||
|
center.dx - textPainter.width / 2,
|
||||||
|
center.dy - radius * 0.4 - textPainter.height / 2,
|
||||||
|
);
|
||||||
|
textPainter.paint(canvas, textOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _TeardropPainter oldDelegate) {
|
||||||
|
return oldDelegate.color != color || oldDelegate.volume != volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: ensemble
|
name: ensemble
|
||||||
description: A minimalistic Music Assistant client built with Flutter
|
description: A minimalistic Music Assistant client built with Flutter
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.8.5-beta+43
|
version: 2.8.8-beta+46
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
@@ -49,9 +49,6 @@ dependencies:
|
|||||||
# Raw PCM Audio Playback (for Sendspin streaming)
|
# Raw PCM Audio Playback (for Sendspin streaming)
|
||||||
flutter_pcm_sound: ^3.3.3
|
flutter_pcm_sound: ^3.3.3
|
||||||
|
|
||||||
# Animated list with drag-to-reorder
|
|
||||||
great_list_view: ^0.2.3
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||