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 | ||
|
|
90f004ea47 | ||
|
|
f24c98102c | ||
|
|
31a9f24d66 | ||
|
|
29f2ff707e | ||
|
|
318a095c15 | ||
|
|
1796829a77 | ||
|
|
96e6522a72 | ||
|
|
cde13864a3 | ||
|
|
ea5beec889 | ||
|
|
b75152d4ad | ||
|
|
970644493e | ||
|
|
80996e3e82 | ||
|
|
9584f806ee | ||
|
|
e2f420df0c | ||
|
|
11faba74ea | ||
|
|
6459f8bf87 | ||
|
|
cd1ab6e9f7 | ||
|
|
8e5bfa3657 | ||
|
|
01ea21917a | ||
|
|
236698f1b4 | ||
|
|
0699523914 | ||
|
|
9ebd49d7b3 | ||
|
|
ab734ac996 | ||
|
|
88fe49d506 | ||
|
|
b497a44930 | ||
|
|
1324b23167 | ||
|
|
c9eb3fb7ec | ||
|
|
84bfcf7e06 | ||
|
|
29167dcdf6 | ||
|
|
01aca2e27c | ||
|
|
26fcd90557 | ||
|
|
129a1c8b28 | ||
|
|
03f96537eb | ||
|
|
405816939d | ||
|
|
6661375f99 | ||
|
|
d6e759f1cc | ||
|
|
8a38516715 | ||
|
|
aaeb2109f6 | ||
|
|
f65e76e858 | ||
|
|
252395d164 | ||
|
|
f36d108878 | ||
|
|
8e548daf0c |
@@ -35,6 +35,16 @@ jobs:
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Get dependencies
|
||||
run: flutter pub get
|
||||
|
||||
@@ -65,7 +75,12 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Build APK
|
||||
run: flutter build apk --release
|
||||
run: |
|
||||
# Use init.gradle to force Maven mirrors (workaround for Maven Central 403)
|
||||
mkdir -p ~/.gradle
|
||||
cp android/init.gradle ~/.gradle/init.gradle
|
||||
|
||||
flutter build apk --release
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# Player System Animation & Polish Audit
|
||||
|
||||
## Context
|
||||
|
||||
This is a Music Assistant client app (Ensemble) built with Flutter. The player system has undergone extensive development and now needs a comprehensive polish audit. The goal is buttery smooth 60fps animations and a premium user experience.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The player system consists of three interconnected components:
|
||||
|
||||
1. **Mini Player** (collapsed state) - Floating card at bottom, shows current track
|
||||
2. **Expanded Player** - Full-screen player with album art, controls, progress
|
||||
3. **Queue Panel** - Slides in from right of expanded player, shows playback queue
|
||||
|
||||
Additionally:
|
||||
- **Player Selector** - Horizontal swipe between multiple players (collapsed mode)
|
||||
- **Device Reveal** - Swipe down from mini player to show available players
|
||||
|
||||
## Key Files
|
||||
|
||||
```
|
||||
lib/widgets/
|
||||
├── expandable_player.dart # Main player widget (1800+ lines)
|
||||
├── global_player_overlay.dart # Overlay wrapper, static accessors
|
||||
├── player/
|
||||
│ ├── queue_panel.dart # Queue list with great_list_view
|
||||
│ ├── chapters_panel.dart # Audiobook chapters
|
||||
│ └── mini_player_content.dart
|
||||
```
|
||||
|
||||
## CRITICAL CONSTRAINTS
|
||||
|
||||
1. **DO NOT MODIFY the queue list implementation** - It uses `great_list_view` package with `AutomaticAnimatedListView`. This was chosen specifically to avoid Flutter's grey screen overlay bug (#103804). The current implementation works correctly.
|
||||
|
||||
2. **Preserve all existing functionality** - Focus on polish, not rewrites
|
||||
|
||||
3. **Test changes incrementally** - This system is complex with many interdependencies
|
||||
|
||||
## Audit Areas
|
||||
|
||||
### 1. Animation Performance Audit
|
||||
|
||||
Use sub-agents to analyze each animation system:
|
||||
|
||||
**Agent 1: Expand/Collapse Animation**
|
||||
- Analyze `_controller` and `_expandAnimation`
|
||||
- Check for jank during morphing transition
|
||||
- Verify lerp functions are efficient
|
||||
- Look for unnecessary rebuilds during animation
|
||||
- Check `RepaintBoundary` placement
|
||||
|
||||
**Agent 2: Queue Panel Animation**
|
||||
- Analyze `_queuePanelController` and `_queuePanelAnimation`
|
||||
- Verify slide-in/out is smooth
|
||||
- Check interaction with expanded player state
|
||||
- Verify the Listener-based swipe detection doesn't cause issues
|
||||
|
||||
**Agent 3: Player Selector Animation**
|
||||
- Analyze horizontal swipe between players
|
||||
- Check `_slideOffset` and peek player animations
|
||||
- Verify finger-following feels natural
|
||||
- Look for edge cases with rapid swipes
|
||||
|
||||
**Agent 4: Micro-interactions**
|
||||
- Play/pause button animations
|
||||
- Progress bar interactions
|
||||
- Volume swipe overlay
|
||||
- Favorite button feedback
|
||||
- Skip button responsiveness
|
||||
|
||||
### 2. State Management Audit
|
||||
|
||||
**Check for:**
|
||||
- Unnecessary `setState` calls during animations
|
||||
- ValueNotifier usage efficiency
|
||||
- Animation listener cleanup
|
||||
- Memory leaks from subscriptions
|
||||
- State synchronization between components
|
||||
|
||||
### 3. Visual Polish Audit
|
||||
|
||||
**Check for:**
|
||||
- Color transitions during expand/collapse
|
||||
- Text fade/scale animations
|
||||
- Image loading placeholders
|
||||
- Shadow/elevation consistency
|
||||
- Border radius morphing smoothness
|
||||
- Safe area handling
|
||||
|
||||
### 4. Gesture Conflict Resolution
|
||||
|
||||
**Verify:**
|
||||
- Vertical drag (expand/collapse) vs horizontal drag (player switch)
|
||||
- Queue panel swipe-right-to-close vs Dismissible swipe-left-to-delete
|
||||
- Edge dead zones for Android back gesture
|
||||
- Tap vs drag disambiguation
|
||||
|
||||
### 5. Edge Cases
|
||||
|
||||
**Test scenarios:**
|
||||
- Rapid expand/collapse
|
||||
- Swipe during animation
|
||||
- Back button during animations
|
||||
- Track change during queue scroll
|
||||
- Network image loading failures
|
||||
- Very long track/artist names
|
||||
|
||||
## Execution Strategy
|
||||
|
||||
```
|
||||
ultrathink: true
|
||||
```
|
||||
|
||||
### Phase 1: Discovery (Read-Only)
|
||||
Launch parallel sub-agents to analyze each file:
|
||||
- Read and understand the animation architecture
|
||||
- Map all animation controllers and their relationships
|
||||
- Identify potential performance bottlenecks
|
||||
- Document current RepaintBoundary usage
|
||||
|
||||
### Phase 2: Analysis Report
|
||||
Compile findings into categories:
|
||||
- **Critical**: Causes visible jank or bugs
|
||||
- **Important**: Noticeable but not breaking
|
||||
- **Nice-to-have**: Minor polish improvements
|
||||
|
||||
### Phase 3: Implementation Plan
|
||||
For each finding:
|
||||
- Specific file and line numbers
|
||||
- Proposed change
|
||||
- Risk assessment
|
||||
- Testing approach
|
||||
|
||||
### Phase 4: Incremental Fixes
|
||||
- One fix at a time
|
||||
- Build and test after each change
|
||||
- Verify no regressions
|
||||
|
||||
## Output Format
|
||||
|
||||
Provide a structured audit report:
|
||||
|
||||
```markdown
|
||||
## Animation Audit Report
|
||||
|
||||
### Executive Summary
|
||||
[2-3 sentences on overall state]
|
||||
|
||||
### Critical Issues
|
||||
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
|
||||
|
||||
### Important Issues
|
||||
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
|
||||
|
||||
### Polish Opportunities
|
||||
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
|
||||
|
||||
### Architecture Observations
|
||||
[Notes on overall structure, patterns noticed]
|
||||
|
||||
### Recommended Priority Order
|
||||
1. [First fix]
|
||||
2. [Second fix]
|
||||
...
|
||||
```
|
||||
|
||||
## Success Criteria
|
||||
|
||||
After this audit and fixes:
|
||||
- All animations run at consistent 60fps
|
||||
- No jank during any transition
|
||||
- Gestures feel responsive and natural
|
||||
- State transitions are seamless
|
||||
- No visual glitches or flickers
|
||||
- Memory usage is stable during interactions
|
||||
|
||||
## Notes
|
||||
|
||||
- The app uses adaptive theming (colors extracted from album art)
|
||||
- There's an `AnimationDebugger` utility for profiling
|
||||
- Position tracking uses a shared `PositionTracker` singleton
|
||||
- The player supports both music and audiobooks (different UI modes)
|
||||
@@ -1,136 +1,257 @@
|
||||
# Ensemble Flutter App - Code Audit Report
|
||||
# Ensemble Codebase Audit Report
|
||||
|
||||
**Date:** January 4, 2026
|
||||
**Branch:** `audit/code-quality-review`
|
||||
**Files Audited:** 93 Dart files
|
||||
**Date**: 2026-01-14
|
||||
**Scope**: Full codebase audit (104 Dart files, 18 screens)
|
||||
**Focus Areas**: UI Continuity, Performance, Logic/Best Practices
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|----------|-------------|----------|
|
||||
| Deprecated APIs | 20 instances | HIGH |
|
||||
| Missing const constructors | 100+ instances | MEDIUM |
|
||||
| Image cache parameters | 7 locations | MEDIUM |
|
||||
| Code duplication | 8 view mode functions | LOW |
|
||||
| Color constants | 3 files with duplicates | LOW |
|
||||
| Severity | Count | Key Themes |
|
||||
|----------|-------|------------|
|
||||
| **Critical** | 6 | God classes (5,443 & 4,209 lines), design token adoption, startup blocking |
|
||||
| **High** | 15 | Touch targets, batch DB operations, over-notification, code duplication |
|
||||
| **Medium** | 25+ | Inconsistencies, memory issues, missing error boundaries |
|
||||
| **Low** | 15+ | Minor polish items |
|
||||
|
||||
**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)
|
||||
Flutter Material 3 deprecates `ColorScheme.background`. Replace with `colorScheme.surface`.
|
||||
### 1. God Object: MusicAssistantProvider (5,443 lines)
|
||||
**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:**
|
||||
- lib/main.dart:335, 336
|
||||
- lib/screens/library_artists_screen.dart:21
|
||||
- lib/screens/new_home_screen.dart:121
|
||||
- lib/screens/artist_details_screen.dart:708, 720
|
||||
- lib/screens/library_playlists_screen.dart:53
|
||||
- lib/screens/library_tracks_screen.dart:19
|
||||
- lib/screens/login_screen.dart:489
|
||||
- lib/screens/new_library_screen.dart:933, 957, 1020, 1021
|
||||
- lib/screens/album_details_screen.dart:807, 819
|
||||
- lib/screens/library_albums_screen.dart:19
|
||||
- lib/screens/search_screen.dart:427, 738, 739
|
||||
- lib/screens/settings_screen.dart:245
|
||||
### 2. God Object: NewLibraryScreen (4,209 lines)
|
||||
**File**: `lib/screens/new_library_screen.dart`
|
||||
- 10 scroll controllers
|
||||
- 15+ view mode states
|
||||
- 15+ sort order states
|
||||
- Handles: Music, Books, Podcasts, Radio tabs
|
||||
- **Fix**: Extract to ArtistsTab, AlbumsTab, TracksTab, PlaylistsTab, BooksTab, PodcastsTab, RadioTab
|
||||
|
||||
### 3. Sequential Blocking Startup (500-1500ms wasted)
|
||||
**File**: `lib/main.dart:33-66`
|
||||
```dart
|
||||
await DatabaseService.instance.initialize();
|
||||
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)
|
||||
High-impact optimization - used extensively in button padding.
|
||||
### Performance
|
||||
| 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:**
|
||||
- lib/screens/album_details_screen.dart (12 instances)
|
||||
- lib/screens/artist_details_screen.dart (8 instances)
|
||||
- lib/screens/audiobook_detail_screen.dart (3 instances)
|
||||
- lib/screens/audiobook_series_screen.dart (2 instances)
|
||||
- lib/screens/audiobook_author_screen.dart (2 instances)
|
||||
### Architecture
|
||||
| Issue | File | Impact |
|
||||
|-------|------|--------|
|
||||
| State duplication across 4 providers | providers/*.dart | Maintenance burden, sync issues |
|
||||
| 8 duplicate `_cycle*ViewMode()` methods | new_library_screen.dart:251-402 | DRY violation |
|
||||
| 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)
|
||||
Cannot use `const BorderRadius.circular()` directly. Alternative: `const BorderRadius.all(Radius.circular(X))`.
|
||||
### UI/UX
|
||||
| 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:**
|
||||
- Line 1395-1402: Author image (48x48) - missing cache params
|
||||
- Line 1480-1486: Author card image - missing cache params
|
||||
- Line 1572-1580: Audiobook cover (56x56) - missing cache params
|
||||
- Line 1740-1745: Audiobook grid cover - missing cache params
|
||||
- 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
|
||||
### UI Consistency
|
||||
- Border radius varies: 8, 12, 20, 22, 25 for same element types
|
||||
- `SizedBox(height: 8)` appears 40+ times instead of `Spacing.vGap8`
|
||||
- Hardcoded colors for status (Colors.red, Colors.green)
|
||||
- Inconsistent icon sizes across screens
|
||||
|
||||
**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)
|
||||
**File:** lib/screens/new_library_screen.dart
|
||||
|
||||
Functions that follow identical switch/case pattern:
|
||||
- `_cycleArtistsViewMode()` (line 184)
|
||||
- `_cycleAlbumsViewMode()` (line 200)
|
||||
- `_cyclePlaylistsViewMode()` (line 216)
|
||||
- `_cycleAuthorsViewMode()` (line 232)
|
||||
- `_cycleAudiobooksViewMode()` (line 248)
|
||||
- `_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
|
||||
| # | Task | File(s) | Effort |
|
||||
|---|------|---------|--------|
|
||||
| 1 | Replace hardcoded bottom spacing with `BottomSpacing.withMiniPlayer` | Detail screens | 30 min |
|
||||
| 2 | Add navigation debounce to card widgets | album_card, artist_card, etc. | 1 hr |
|
||||
| 3 | Fix touch targets to 48x48 minimum | new_library_screen.dart | 15 min |
|
||||
| 4 | Add migration skip flag for SecureStorage | secure_storage_service.dart | 30 min |
|
||||
| 5 | Add Flutter error boundary | main.dart | 30 min |
|
||||
| 6 | Use `context.select()` instead of `context.watch()` | global_player_overlay, etc. | 2 hr |
|
||||
| 7 | Cache `SharedPreferences.getInstance()` | settings_service.dart | 30 min |
|
||||
|
||||
---
|
||||
|
||||
## 5. POSITIVE FINDINGS (Already Optimized)
|
||||
## Refactoring Roadmap
|
||||
|
||||
- **RepaintBoundary**: Properly implemented in all list item widgets
|
||||
- **ListView.builder**: Used consistently with cacheExtent: 1000
|
||||
- **ScrollController disposal**: All 8+ controllers properly disposed
|
||||
- **Animation patterns**: Proper listener cleanup in expandable_player.dart
|
||||
- **Debounced operations**: Color extraction debounced to 300ms
|
||||
### Phase 1: Quick Wins (1-2 days)
|
||||
- Fix touch targets
|
||||
- Fix bottom spacing
|
||||
- Add navigation debounce
|
||||
- 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)
|
||||
1. Replace `colorScheme.background` with `colorScheme.surface` (20 locations)
|
||||
2. Add `const` to EdgeInsets.zero instances
|
||||
The codebase already demonstrates many good practices:
|
||||
|
||||
### Phase 2: Image Optimization
|
||||
1. Add memCacheWidth/memCacheHeight to CachedNetworkImage instances in new_library_screen.dart
|
||||
|
||||
### Phase 3: Code Cleanup (Optional)
|
||||
1. Extract view mode cycling to utility function
|
||||
2. Centralize color constants
|
||||
- **Performance awareness**: `// PERF:` comments, RepaintBoundary (55 occurrences), image caching (80 memCacheWidth/Height)
|
||||
- **Caching**: Multi-tier caching with LRU eviction, proper TTLs, proper DB indexes
|
||||
- **Error handling**: DebugLogger, RetryHelper, OfflineActionQueue well-implemented
|
||||
- **Theme system**: Material You support, adaptive colors from album art
|
||||
- **Offline support**: Cache-first loading, graceful degradation
|
||||
- **Navigation**: Consistent FadeSlidePageRoute, proper Hero animations (mostly)
|
||||
|
||||
---
|
||||
|
||||
## Files by Complexity
|
||||
## Files by Priority
|
||||
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| lib/screens/search_screen.dart | 3,489 | Good structure, minor fixes needed |
|
||||
| lib/screens/new_library_screen.dart | 3,351 | Good performance, some duplication |
|
||||
| lib/widgets/expandable_player.dart | 2,577 | Well-optimized animations |
|
||||
| Priority | File | Lines | Key Issues |
|
||||
|----------|------|-------|------------|
|
||||
| 1 | music_assistant_provider.dart | 5,443 | God object, over-notification |
|
||||
| 2 | new_library_screen.dart | 4,209 | God class, performance, touch targets |
|
||||
| 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
|
||||
|
||||
### 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
|
||||
- **Media Notifications** - Control playback from your notification shade with album art display
|
||||
- **Instant Response** - Pause/resume in ~300ms
|
||||
|
||||
### Remote Control
|
||||
- **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
|
||||
- **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
|
||||
- **Customizable Rows** - Toggle Recently Played, Discover Artists, and Discover Albums
|
||||
- **Favorites Rows** - Optional rows for Favorite Albums, Artists, and Tracks
|
||||
- **Customizable Rows** - Toggle and reorder: Recently Played, Discover Artists, Discover Albums
|
||||
- **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
|
||||
- **Pull to Refresh** - Refresh content with a simple pull gesture
|
||||
|
||||
### Library Browsing
|
||||
- **Browse Your Collection** - Artists, albums, playlists, and tracks from all your music sources
|
||||
### Library
|
||||
- **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
|
||||
- **Album Details** - View track listings with artwork
|
||||
- **Artist Details** - View artist albums and top tracks
|
||||
- **Playlist Support** - Browse and play your playlists
|
||||
- **Search** - Find music across your entire library
|
||||
- **Letter Scrollbar** - Fast navigation through long lists
|
||||
|
||||
### Search
|
||||
- **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 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
|
||||
- **Progress Tracking** - Track your listening progress across sessions
|
||||
- **Continue Listening** - Pick up where you left off
|
||||
- **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
|
||||
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
|
||||
- **Auto-Reconnect** - Automatically reconnects when connection is lost
|
||||
- **Offline Browsing** - Browse your cached library even when disconnected
|
||||
- **Hero Animations** - Smooth transitions between screens
|
||||
- **Welcome Screen** - Guided onboarding for first-time users
|
||||
|
||||
### Theming
|
||||
- **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
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/screenshots/1.png?v=2" alt="Connection Screen" width="150">
|
||||
<img src="assets/screenshots/2.png?v=2" alt="Home Screen" width="150">
|
||||
<img src="assets/screenshots/3.png?v=2" alt="Album Details" width="150">
|
||||
<img src="assets/screenshots/4.png?v=2" alt="Now Playing" width="150">
|
||||
<img src="assets/screenshots/5.png?v=2" alt="Queue" width="150">
|
||||
<img src="assets/screenshots/6.png?v=2" alt="Settings" width="150">
|
||||
<img src="assets/screenshots/7.png?v=2" alt="Audiobooks" width="150">
|
||||
<img src="assets/screenshots/8.png?v=2" alt="Audiobook Player" width="150">
|
||||
<img src="assets/screenshots/1.png?v=3" alt="Screenshot 1" width="150">
|
||||
<img src="assets/screenshots/2.png?v=3" alt="Screenshot 2" width="150">
|
||||
<img src="assets/screenshots/3.png?v=3" alt="Screenshot 3" width="150">
|
||||
<img src="assets/screenshots/4.png?v=3" alt="Screenshot 4" width="150">
|
||||
<img src="assets/screenshots/5.png?v=3" alt="Screenshot 5" width="150">
|
||||
<img src="assets/screenshots/6.png?v=3" alt="Screenshot 6" width="150">
|
||||
<img src="assets/screenshots/7.png?v=3" alt="Screenshot 7" 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>
|
||||
|
||||
## Download
|
||||
@@ -84,9 +105,29 @@ Download the latest release from the [Releases page](https://github.com/CollotsS
|
||||
## Setup
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -103,7 +144,7 @@ Ensemble supports multiple authentication methods:
|
||||
|
||||
## 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
|
||||
- Android device (Android 5.0+)
|
||||
- Audiobookshelf provider configured in Music Assistant (for audiobook features)
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
android:label="Ensemble"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:appCategory="audio"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<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"?>
|
||||
<resources>
|
||||
<color name="launch_background_color">#1a1a1a</color>
|
||||
</resources>
|
||||
<color name="ic_launcher_background">#1a1a1a</color>
|
||||
</resources>
|
||||
@@ -2,6 +2,9 @@ buildscript {
|
||||
ext.kotlin_version = '1.9.10'
|
||||
repositories {
|
||||
google()
|
||||
// Use Maven Central mirrors as fallback for 403 errors
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
@@ -14,6 +17,9 @@ buildscript {
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
// Use Maven Central mirrors as fallback for 403 errors
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,37 @@
|
||||
// Force all repositories to use mirrors first
|
||||
allprojects {
|
||||
repositories {
|
||||
all { ArtifactRepository repo ->
|
||||
if (repo instanceof MavenArtifactRepository) {
|
||||
def url = repo.url.toString()
|
||||
if (url.contains('repo.maven.apache.org') || url.contains('repo1.maven.org')) {
|
||||
remove repo
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add mirrors before Maven Central
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||
}
|
||||
}
|
||||
|
||||
settingsEvaluated { settings ->
|
||||
settings.pluginManagement {
|
||||
repositories {
|
||||
all { ArtifactRepository repo ->
|
||||
if (repo instanceof MavenArtifactRepository) {
|
||||
def url = repo.url.toString()
|
||||
if (url.contains('repo.maven.apache.org') || url.contains('repo1.maven.org')) {
|
||||
remove repo
|
||||
}
|
||||
}
|
||||
}
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
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 |
@@ -28,4 +28,19 @@ class HeroTags {
|
||||
|
||||
/// Podcast cover hero tag prefix
|
||||
static const String podcastCover = 'podcast_cover_';
|
||||
|
||||
/// Podcast title hero tag prefix
|
||||
static const String podcastTitle = 'podcast_title_';
|
||||
|
||||
/// Playlist cover hero tag prefix
|
||||
static const String playlistCover = 'playlist_cover_';
|
||||
|
||||
/// Playlist title hero tag prefix
|
||||
static const String playlistTitle = 'playlist_title_';
|
||||
|
||||
/// Radio station cover hero tag prefix
|
||||
static const String radioCover = 'radio_cover_';
|
||||
|
||||
/// Radio station title hero tag prefix
|
||||
static const String radioTitle = 'radio_title_';
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ class Timings {
|
||||
/// Search debounce delay
|
||||
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
|
||||
static const Duration trackChangeDelay = Duration(milliseconds: 500);
|
||||
}
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
"english": "Englisch",
|
||||
"german": "Deutsch",
|
||||
"spanish": "Spanisch",
|
||||
"french": "Französisch",
|
||||
|
||||
"noTracksInPlaylist": "Keine Songs in Wiedergabeliste",
|
||||
"sortAlphabetically": "Alphabetisch sortieren",
|
||||
|
||||
@@ -251,6 +251,21 @@
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"favoritePlaylists": "Favorite Playlists",
|
||||
"@favoritePlaylists": {
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"favoriteRadioStations": "Favorite Radio Stations",
|
||||
"@favoriteRadioStations": {
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"favoritePodcasts": "Favorite Podcasts",
|
||||
"@favoritePodcasts": {
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"searchMusic": "Search music...",
|
||||
"@searchMusic": {
|
||||
"description": "Search placeholder text"
|
||||
@@ -1045,6 +1060,11 @@
|
||||
"description": "Spanish language"
|
||||
},
|
||||
|
||||
"french": "French",
|
||||
"@french": {
|
||||
"description": "French language"
|
||||
},
|
||||
|
||||
"noTracksInPlaylist": "No tracks in playlist",
|
||||
"sortAlphabetically": "Sort alphabetically",
|
||||
"sortByYear": "Sort by year",
|
||||
@@ -1062,6 +1082,9 @@
|
||||
"showFavoriteAlbums": "Show a row of your favorite albums",
|
||||
"showFavoriteArtists": "Show a row of your favorite artists",
|
||||
"showFavoriteTracks": "Show a row of your favorite tracks",
|
||||
"showFavoritePlaylists": "Show a row of your favorite playlists",
|
||||
"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",
|
||||
"chooseHomeScreenRows": "Choose which rows to display on the home screen",
|
||||
"addedToFavorites": "Added to favorites",
|
||||
@@ -1378,5 +1401,35 @@
|
||||
"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",
|
||||
"german": "Alemán",
|
||||
"spanish": "Español",
|
||||
"french": "Francés",
|
||||
"noTracksInPlaylist": "No hay canciones en la lista",
|
||||
"sortAlphabetically": "Ordenar alfabéticamente",
|
||||
"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:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -33,6 +34,10 @@ final _logger = DebugLogger();
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize SharedPreferences cache early for performance
|
||||
await SettingsService.initialize();
|
||||
_logger.log('⚡ SharedPreferences cached');
|
||||
|
||||
// Initialize local database
|
||||
await DatabaseService.instance.initialize();
|
||||
_logger.log('💾 Database initialized');
|
||||
@@ -71,17 +76,42 @@ Future<void> main() async {
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
|
||||
// Set system UI overlay style
|
||||
// Set initial system UI overlay style based on platform brightness
|
||||
// SystemUIWrapper will update this dynamically when theme changes
|
||||
final platformBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
|
||||
final isDark = platformBrightness == Brightness.dark;
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
systemNavigationBarColor: Color(0xFF1a1a1a),
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarColor: isDark ? const Color(0xFF1a1a1a) : const Color(0xFFF5F5F5),
|
||||
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
),
|
||||
);
|
||||
|
||||
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 {
|
||||
@@ -176,7 +206,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
@override
|
||||
Future<bool> didPopRoute() async {
|
||||
// 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
|
||||
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
|
||||
@@ -184,6 +214,14 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
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
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
@@ -229,10 +267,17 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
ColorScheme? darkColorScheme;
|
||||
|
||||
if (themeProvider.useMaterialTheme && snapshot.hasData && snapshot.data != null) {
|
||||
// Use system color schemes
|
||||
// Use system color schemes, but override background to match app design
|
||||
// Material You doesn't set background consistently, causing black screen issues
|
||||
final (light, dark) = snapshot.data!;
|
||||
lightColorScheme = light;
|
||||
darkColorScheme = dark;
|
||||
lightColorScheme = light.copyWith(
|
||||
surface: light.surface,
|
||||
background: const Color(0xFFFAFAFA), // App's preferred light background
|
||||
);
|
||||
darkColorScheme = dark.copyWith(
|
||||
surface: const Color(0xFF2a2a2a), // App's preferred dark surface
|
||||
background: const Color(0xFF1a1a1a), // App's preferred dark background
|
||||
);
|
||||
} else {
|
||||
// Use custom color from theme provider
|
||||
lightColorScheme = generateLightColorScheme(themeProvider.customColor);
|
||||
@@ -256,6 +301,29 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
],
|
||||
supportedLocales: S.supportedLocales,
|
||||
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,
|
||||
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
|
||||
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
||||
@@ -332,8 +400,8 @@ class _SystemUIWrapperState extends State<SystemUIWrapper> {
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarColor: isDark
|
||||
? widget.darkColorScheme.background
|
||||
: widget.lightColorScheme.background,
|
||||
? widget.darkColorScheme.surface
|
||||
: widget.lightColorScheme.surface,
|
||||
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -58,6 +58,10 @@ class MediaItem {
|
||||
final bool? favorite;
|
||||
final int? position;
|
||||
final Duration? duration;
|
||||
final DateTime? timestampAdded;
|
||||
final DateTime? timestampModified;
|
||||
final DateTime? lastPlayed;
|
||||
final int? playCount;
|
||||
|
||||
MediaItem({
|
||||
required this.itemId,
|
||||
@@ -71,6 +75,10 @@ class MediaItem {
|
||||
this.favorite,
|
||||
this.position,
|
||||
this.duration,
|
||||
this.timestampAdded,
|
||||
this.timestampModified,
|
||||
this.lastPlayed,
|
||||
this.playCount,
|
||||
});
|
||||
|
||||
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(
|
||||
itemId: json['item_id']?.toString() ?? json['id']?.toString() ?? '',
|
||||
provider: json['provider'] as String? ?? 'unknown',
|
||||
@@ -99,6 +122,10 @@ class MediaItem {
|
||||
duration: json['duration'] != null
|
||||
? Duration(seconds: (json['duration'] as num).toInt())
|
||||
: 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 (position != null) 'position': position,
|
||||
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.metadata,
|
||||
super.favorite,
|
||||
super.timestampAdded,
|
||||
super.timestampModified,
|
||||
super.lastPlayed,
|
||||
super.playCount,
|
||||
}) : super(mediaType: MediaType.artist);
|
||||
|
||||
factory Artist.fromJson(Map<String, dynamic> json) {
|
||||
@@ -143,6 +178,10 @@ class Artist extends MediaItem {
|
||||
providerMappings: item.providerMappings,
|
||||
metadata: item.metadata,
|
||||
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.metadata,
|
||||
super.favorite,
|
||||
super.timestampAdded,
|
||||
super.timestampModified,
|
||||
super.lastPlayed,
|
||||
super.playCount,
|
||||
}) : super(mediaType: MediaType.album);
|
||||
|
||||
factory Album.fromJson(Map<String, dynamic> json) {
|
||||
@@ -194,6 +237,10 @@ class Album extends MediaItem {
|
||||
providerMappings: item.providerMappings,
|
||||
metadata: item.metadata,
|
||||
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.position,
|
||||
super.duration,
|
||||
super.timestampAdded,
|
||||
super.timestampModified,
|
||||
super.lastPlayed,
|
||||
super.playCount,
|
||||
}) : super(mediaType: MediaType.track);
|
||||
|
||||
factory Track.fromJson(Map<String, dynamic> json) {
|
||||
@@ -261,6 +312,10 @@ class Track extends MediaItem {
|
||||
favorite: item.favorite,
|
||||
position: item.position,
|
||||
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.metadata,
|
||||
super.favorite,
|
||||
super.timestampAdded,
|
||||
super.timestampModified,
|
||||
super.lastPlayed,
|
||||
super.playCount,
|
||||
}) : super(mediaType: MediaType.playlist);
|
||||
|
||||
factory Playlist.fromJson(Map<String, dynamic> json) {
|
||||
@@ -311,6 +370,10 @@ class Playlist extends MediaItem {
|
||||
providerMappings: item.providerMappings,
|
||||
metadata: item.metadata,
|
||||
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';
|
||||
case 'de':
|
||||
return 'Deutsch';
|
||||
case 'es':
|
||||
return 'Español';
|
||||
case 'fr':
|
||||
return 'Français';
|
||||
case null:
|
||||
return 'System';
|
||||
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:flutter_volume_controller/flutter_volume_controller.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/music_provider.dart';
|
||||
import '../models/player.dart';
|
||||
import '../services/music_assistant_api.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
|
||||
Track? _currentTrack;
|
||||
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
|
||||
String? _currentPodcastName; // Currently playing podcast's name (set when playing episode)
|
||||
Timer? _playerStateTimer;
|
||||
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
|
||||
final Map<String, String> _podcastCoverCache = {};
|
||||
|
||||
// Provider filter: list of allowed provider instance IDs from MA user settings
|
||||
// Empty list means all providers are allowed
|
||||
List<String> _providerFilter = [];
|
||||
// Provider filter: list of DISABLED provider instance IDs from local app settings
|
||||
// Empty list means all providers are enabled (shown)
|
||||
List<String> _disabledProviders = [];
|
||||
|
||||
// Player filter: list of allowed player IDs from MA user settings
|
||||
// Empty list means all players are allowed
|
||||
@@ -124,8 +126,14 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_connectionState == MAConnectionState.authenticated;
|
||||
|
||||
// Library getters with provider filtering applied
|
||||
List<Artist> get artists => filterByProvider(_artists);
|
||||
List<Album> get albums => filterByProvider(_albums);
|
||||
List<Artist> get artists {
|
||||
_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<MediaItem> get radioStations => filterByProvider(_radioStations);
|
||||
List<MediaItem> get podcasts => filterByProvider(_podcasts);
|
||||
@@ -137,54 +145,145 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
bool get isLoadingRadio => _isLoadingRadio;
|
||||
bool get isLoadingPodcasts => _isLoadingPodcasts;
|
||||
|
||||
/// Provider filter from MA user settings (empty = all providers allowed)
|
||||
List<String> get providerFilter => _providerFilter;
|
||||
/// Available music providers from MA server
|
||||
List<MusicProvider> get musicProviders => _api?.musicProviders ?? [];
|
||||
|
||||
/// Whether provider filtering is active
|
||||
bool get hasProviderFilter => _providerFilter.isNotEmpty;
|
||||
/// List of disabled provider instance IDs (from local app settings)
|
||||
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
|
||||
/// Returns true if:
|
||||
/// - No filter is active (empty list = all providers allowed)
|
||||
/// - The item has at least one provider mapping in the allowed list
|
||||
/// - The item's primary provider is in the allowed list
|
||||
/// Returns true if item has at least one ENABLED provider
|
||||
/// Hide only if ALL of the item's providers are disabled
|
||||
int _filterDebugCount = 0;
|
||||
bool isItemAllowedByProviderFilter(MediaItem item) {
|
||||
// No filter = show everything
|
||||
if (_providerFilter.isEmpty) return true;
|
||||
// No disabled providers = show everything
|
||||
if (_disabledProviders.isEmpty) return true;
|
||||
|
||||
// Check if item's provider mappings include any allowed provider
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings != null && mappings.isNotEmpty) {
|
||||
for (final mapping in mappings) {
|
||||
if (_providerFilter.contains(mapping.providerInstance)) {
|
||||
return true;
|
||||
// Get real provider mappings (exclude "None" which is MA's internal library)
|
||||
final realMappings = mappings
|
||||
.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)
|
||||
if (_providerFilter.contains(item.provider)) {
|
||||
return true;
|
||||
// Check primary provider field (for items without full mappings)
|
||||
final allowed = !_disabledProviders.contains(item.provider);
|
||||
if (_filterDebugCount < 10) {
|
||||
_logger.log('🔍 ${allowed ? "ALLOWED" : "FILTERED"} (no mappings): "${item.name}" provider=${item.provider}');
|
||||
_filterDebugCount++;
|
||||
}
|
||||
|
||||
return false;
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/// Filter a list of media items based on provider filter
|
||||
List<T> filterByProvider<T extends MediaItem>(List<T> items) {
|
||||
if (_providerFilter.isEmpty) return items;
|
||||
return items.where(isItemAllowedByProviderFilter).toList();
|
||||
if (_disabledProviders.isEmpty) return items;
|
||||
_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
|
||||
Map<String, List<MediaItem>> filterSearchResults(Map<String, List<MediaItem>> results) {
|
||||
if (_providerFilter.isEmpty) return results;
|
||||
if (_disabledProviders.isEmpty) return results;
|
||||
return {
|
||||
for (final entry in results.entries)
|
||||
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)
|
||||
List<String> get playerFilter => _playerFilter;
|
||||
|
||||
@@ -333,14 +432,18 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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;
|
||||
if (metadata != null) {
|
||||
// Check for podcast_name or podcast.name in metadata
|
||||
if (metadata['podcast_name'] != null) {
|
||||
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) {
|
||||
return _currentTrack!.album!.name;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallbacks removed - album/artist contain episode name, not podcast name
|
||||
// Return null to let UI show generic "Podcasts" label
|
||||
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;
|
||||
Map<String, List<MediaItem>> get lastSearchResults => filterSearchResults(_lastSearchResults);
|
||||
|
||||
@@ -490,6 +599,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
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
|
||||
int get pendingOfflineActionsCount => OfflineActionQueue.instance.pendingCount;
|
||||
|
||||
@@ -899,22 +1014,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_logger.log('✅ Set owner name from MA profile: $profileName');
|
||||
}
|
||||
|
||||
// Capture provider filter (empty list = all providers allowed)
|
||||
final providerFilterRaw = userInfo['provider_filter'];
|
||||
if (providerFilterRaw is List) {
|
||||
_providerFilter = providerFilterRaw.cast<String>().toList();
|
||||
if (_providerFilter.isNotEmpty) {
|
||||
_logger.log('🔒 Provider filter active: ${_providerFilter.length} providers allowed');
|
||||
_logger.log(' Allowed: ${_providerFilter.join(", ")}');
|
||||
} else {
|
||||
_logger.log('🔓 No provider filter - all providers visible');
|
||||
}
|
||||
} else {
|
||||
_providerFilter = [];
|
||||
_logger.log('🔓 No provider filter in user settings');
|
||||
}
|
||||
// Load provider filter from LOCAL app settings (not MA user profile)
|
||||
// MA user profile provider_filter is ignored as it causes issues with recents
|
||||
await loadDisabledProviders();
|
||||
|
||||
// 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'];
|
||||
if (playerFilterRaw is List) {
|
||||
_playerFilter = playerFilterRaw.cast<String>().toList();
|
||||
@@ -2350,6 +2454,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
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') {
|
||||
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
|
||||
final albumName = currentMedia['album'] as String?;
|
||||
@@ -2416,17 +2526,22 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_logger.log('📻 Found artist from artists array: $artistName');
|
||||
}
|
||||
|
||||
// Check for stream_title which might contain "Artist - Title" format
|
||||
// Check for stream_title which contains the actual now-playing metadata
|
||||
// For radio, stream_title has "Artist - Title" format from the stream's ICY metadata
|
||||
final streamTitle = currentMedia['stream_title'] as String?;
|
||||
if (streamTitle != null && streamTitle.contains(' - ') && artistName == null) {
|
||||
final parts = streamTitle.split(' - ');
|
||||
if (parts.length >= 2) {
|
||||
artistName = parts[0].trim();
|
||||
// If trackTitle is still the default or matches the stream_title, update it too
|
||||
if (trackTitle == 'Unknown Track' || trackTitle == streamTitle) {
|
||||
if (streamTitle != null && streamTitle.isNotEmpty) {
|
||||
if (streamTitle.contains(' - ')) {
|
||||
// Parse "Artist - Title" format
|
||||
final parts = streamTitle.split(' - ');
|
||||
if (parts.length >= 2) {
|
||||
artistName = parts[0].trim();
|
||||
trackTitle = parts.sublist(1).join(' - ').trim();
|
||||
_logger.log('📻 Parsed from stream_title: artist=$artistName, title=$trackTitle');
|
||||
}
|
||||
_logger.log('📻 Parsed artist from stream_title: $artistName, title: $trackTitle');
|
||||
} else {
|
||||
// No separator, use stream_title as the title
|
||||
trackTitle = streamTitle;
|
||||
_logger.log('📻 Using stream_title as title: $trackTitle');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2453,14 +2568,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
// Only cache if we don't already have better data from queue
|
||||
final existingTrack = _cacheService.getCachedTrackForPlayer(playerId);
|
||||
// Check if existing track has proper artist (not Unknown Artist and not empty)
|
||||
final existingArtist = existingTrack?.artistsString;
|
||||
final existingHasProperArtist = existingTrack != null &&
|
||||
existingTrack.artistsString != 'Unknown Artist' &&
|
||||
!existingTrack.name.contains(' - ');
|
||||
existingArtist != null &&
|
||||
existingArtist != 'Unknown Artist' &&
|
||||
existingArtist.trim().isNotEmpty;
|
||||
final existingHasImage = existingTrack?.metadata?['images'] != null;
|
||||
final newHasImage = metadata != null;
|
||||
final newHasAlbum = albumName != null;
|
||||
final existingHasAlbum = existingTrack?.album != null;
|
||||
final newHasProperArtist = artistName != 'Unknown Artist';
|
||||
// Check if new data has proper artist AND title (not malformed like "- Something")
|
||||
final newTitleIsMalformed = trackTitle.startsWith('- ') || trackTitle.trim().isEmpty;
|
||||
final newHasProperArtist = artistName != 'Unknown Artist' && !newTitleIsMalformed;
|
||||
|
||||
// For podcasts, album info is crucial (it's the podcast name)
|
||||
// If new track has album but existing doesn't, prefer new or merge
|
||||
@@ -2511,13 +2631,20 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
// For selected player, _updatePlayerState() is already called above which fetches queue data
|
||||
// Only update _currentTrack here if we don't have it yet OR if we have better album data for podcasts
|
||||
// Update _currentTrack if:
|
||||
// - We don't have it yet
|
||||
// - Podcast with new album data
|
||||
// - Radio with new stream metadata (artist changed from Unknown)
|
||||
final currentHasUnknownArtist = _currentTrack?.artistsString == 'Unknown Artist' ||
|
||||
_currentTrack?.artistsString == null;
|
||||
final shouldUpdateCurrentTrack = _selectedPlayer != null &&
|
||||
playerId == _selectedPlayer!.playerId &&
|
||||
(_currentTrack == null || (isPodcastUri && newHasAlbum && _currentTrack?.album == null));
|
||||
(_currentTrack == null ||
|
||||
(isPodcastUri && newHasAlbum && _currentTrack?.album == null) ||
|
||||
(isRadioUri && newHasProperArtist && currentHasUnknownArtist));
|
||||
if (shouldUpdateCurrentTrack) {
|
||||
_currentTrack = _cacheService.getCachedTrackForPlayer(playerId) ?? trackFromEvent;
|
||||
_logger.log('📋 Updated _currentTrack for podcast with album: ${_currentTrack?.album?.name}');
|
||||
_logger.log('📋 Updated _currentTrack: ${_currentTrack?.name} by ${_currentTrack?.artistsString}');
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
@@ -2554,15 +2681,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for stream_title with "Artist - Title" format
|
||||
// Check for stream_title which contains the actual now-playing metadata
|
||||
final streamTitle = currentMedia['stream_title'] as String?;
|
||||
if (streamTitle != null && streamTitle.contains(' - ') && artist == null) {
|
||||
final parts = streamTitle.split(' - ');
|
||||
if (parts.length >= 2) {
|
||||
artist = parts[0].trim();
|
||||
if (title == 'Unknown Track' || title == streamTitle) {
|
||||
if (streamTitle != null && streamTitle.isNotEmpty) {
|
||||
if (streamTitle.contains(' - ')) {
|
||||
// Parse "Artist - Title" format
|
||||
final parts = streamTitle.split(' - ');
|
||||
if (parts.length >= 2) {
|
||||
artist = parts[0].trim();
|
||||
title = parts.sublist(1).join(' - ').trim();
|
||||
}
|
||||
} else {
|
||||
// No separator, use stream_title as the title
|
||||
title = streamTitle;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2676,38 +2807,46 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
Future<List<Artist>> getDiscoverArtistsWithCache({bool forceRefresh = false}) async {
|
||||
if (_cacheService.isDiscoverArtistsCacheValid(forceRefresh: forceRefresh)) {
|
||||
_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 {
|
||||
_logger.log('🔄 Fetching fresh discover artists...');
|
||||
final artists = await _api!.getRandomArtists(limit: LibraryConstants.defaultRecentLimit);
|
||||
_cacheService.setCachedDiscoverArtists(artists);
|
||||
return artists;
|
||||
return filterByProvider(artists);
|
||||
} catch (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 {
|
||||
if (_cacheService.isDiscoverAlbumsCacheValid(forceRefresh: forceRefresh)) {
|
||||
_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 {
|
||||
_logger.log('🔄 Fetching fresh discover albums...');
|
||||
final albums = await _api!.getRandomAlbums(limit: LibraryConstants.defaultRecentLimit);
|
||||
_cacheService.setCachedDiscoverAlbums(albums);
|
||||
return albums;
|
||||
return filterByProvider(albums);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch discover albums: $e');
|
||||
return _cacheService.getCachedDiscoverAlbums() ?? [];
|
||||
final cached = _cacheService.getCachedDiscoverAlbums();
|
||||
return cached != null ? filterByProvider(cached) : [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2715,21 +2854,31 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_cacheService.invalidateHomeCache();
|
||||
}
|
||||
|
||||
/// Get cached recent albums synchronously (for instant display)
|
||||
List<Album>? getCachedRecentAlbums() => _cacheService.getCachedRecentAlbums();
|
||||
/// Get cached recent albums synchronously (for instant display, with filtering)
|
||||
List<Album>? getCachedRecentAlbums() {
|
||||
final cached = _cacheService.getCachedRecentAlbums();
|
||||
return cached != null ? filterByProvider(cached) : null;
|
||||
}
|
||||
|
||||
/// Get cached discover artists synchronously (for instant display)
|
||||
List<Artist>? getCachedDiscoverArtists() => _cacheService.getCachedDiscoverArtists();
|
||||
/// Get cached discover artists synchronously (for instant display, with filtering)
|
||||
List<Artist>? getCachedDiscoverArtists() {
|
||||
final cached = _cacheService.getCachedDiscoverArtists();
|
||||
return cached != null ? filterByProvider(cached) : null;
|
||||
}
|
||||
|
||||
/// Get cached discover albums synchronously (for instant display)
|
||||
List<Album>? getCachedDiscoverAlbums() => _cacheService.getCachedDiscoverAlbums();
|
||||
/// Get cached discover albums synchronously (for instant display, with filtering)
|
||||
List<Album>? getCachedDiscoverAlbums() {
|
||||
final cached = _cacheService.getCachedDiscoverAlbums();
|
||||
return cached != null ? filterByProvider(cached) : null;
|
||||
}
|
||||
|
||||
/// Force a full library sync (for pull-to-refresh)
|
||||
Future<void> forceLibrarySync() async {
|
||||
if (_api == null) return;
|
||||
|
||||
_logger.log('🔄 Forcing full library sync...');
|
||||
await SyncService.instance.forceSync(_api!);
|
||||
final providerFilter = selectedProviderForFilter;
|
||||
_logger.log('🔄 Forcing full library sync (provider=$providerFilter)...');
|
||||
await SyncService.instance.forceSync(_api!, provider: providerFilter);
|
||||
|
||||
// Update local lists from sync result
|
||||
_albums = SyncService.instance.cachedAlbums;
|
||||
@@ -2750,41 +2899,91 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// FAVORITES FOR HOME SCREEN
|
||||
// ============================================================================
|
||||
|
||||
/// Get favorite albums from the library
|
||||
/// Get favorite albums from the library (with provider filtering)
|
||||
Future<List<Album>> getFavoriteAlbums() async {
|
||||
// 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 {
|
||||
// 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 {
|
||||
// If full tracks list is loaded, filter from it
|
||||
// Use filtered tracks to respect provider settings
|
||||
if (_tracks.isNotEmpty) {
|
||||
final favTracks = _tracks.where((t) => t.favorite == true).toList();
|
||||
// Update cache if we have new data
|
||||
if (favTracks.isNotEmpty && favTracks.length != _cachedFavoriteTracks.length) {
|
||||
_cachedFavoriteTracks = favTracks;
|
||||
_persistFavoriteTracks(favTracks);
|
||||
final favTracks = tracks.where((t) => t.favorite == true).toList();
|
||||
// Update cache if we have new data (using unfiltered for persistence)
|
||||
final unfilteredFavTracks = _tracks.where((t) => t.favorite == true).toList();
|
||||
if (unfilteredFavTracks.isNotEmpty && unfilteredFavTracks.length != _cachedFavoriteTracks.length) {
|
||||
_cachedFavoriteTracks = unfilteredFavTracks;
|
||||
_persistFavoriteTracks(unfilteredFavTracks);
|
||||
}
|
||||
return favTracks;
|
||||
}
|
||||
// Otherwise return cached favorites (for instant display before full library loads)
|
||||
return _cachedFavoriteTracks;
|
||||
return filterByProvider(_cachedFavoriteTracks);
|
||||
}
|
||||
|
||||
/// Get favorite playlists from the library
|
||||
Future<List<Playlist>> getFavoritePlaylists() async {
|
||||
if (_api == null) return [];
|
||||
try {
|
||||
return await getPlaylists(favoriteOnly: true);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch favorite playlists: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get favorite radio stations from the library
|
||||
Future<List<MediaItem>> getFavoriteRadioStations() async {
|
||||
if (_api == null) return [];
|
||||
try {
|
||||
final stations = await _api!.getRadioStations(favoriteOnly: true);
|
||||
return filterByProvider(stations);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch favorite radio stations: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
// ============================================================================
|
||||
|
||||
/// Get audiobooks that have progress (continue listening)
|
||||
Future<List<Audiobook>> getInProgressAudiobooks() async {
|
||||
if (_api == null) return [];
|
||||
/// Get audiobooks that have progress (continue listening) with caching
|
||||
Future<List<Audiobook>> getInProgressAudiobooksWithCache({bool forceRefresh = false}) async {
|
||||
// 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 {
|
||||
_logger.log('📚 Fetching in-progress audiobooks...');
|
||||
@@ -2794,48 +2993,100 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
.where((a) => a.progress > 0 && a.progress < 1.0) // Has progress but not complete
|
||||
.toList()
|
||||
..sort((a, b) => b.progress.compareTo(a.progress)); // Sort by progress descending
|
||||
_logger.log('📚 Found ${inProgress.length} in-progress audiobooks');
|
||||
return inProgress.take(20).toList(); // Limit to 20 for home row
|
||||
final result = 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) {
|
||||
_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 [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get random audiobooks for discovery
|
||||
Future<List<Audiobook>> getDiscoverAudiobooks() async {
|
||||
if (_api == null) return [];
|
||||
/// Get cached in-progress audiobooks synchronously (for instant display, with filtering)
|
||||
List<Audiobook>? getCachedInProgressAudiobooks() {
|
||||
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 {
|
||||
_logger.log('📚 Fetching discover audiobooks...');
|
||||
final allAudiobooks = filterByProvider(await _api!.getAudiobooks());
|
||||
// Shuffle and take a subset
|
||||
final shuffled = List<Audiobook>.from(allAudiobooks)..shuffle();
|
||||
final result = shuffled.take(20).toList();
|
||||
_logger.log('📚 Found ${allAudiobooks.length} total audiobooks, returning random selection');
|
||||
return shuffled.take(20).toList();
|
||||
_cacheService.setCachedDiscoverAudiobooks(result);
|
||||
return result;
|
||||
} catch (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 [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get random series for discovery
|
||||
Future<List<AudiobookSeries>> getDiscoverSeries() async {
|
||||
if (_api == null) return [];
|
||||
/// Get cached discover audiobooks synchronously (for instant display, with filtering)
|
||||
List<Audiobook>? getCachedDiscoverAudiobooks() {
|
||||
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 {
|
||||
_logger.log('📚 Fetching discover series...');
|
||||
final allSeries = await _api!.getAudiobookSeries();
|
||||
// Shuffle and take a subset
|
||||
final shuffled = List<AudiobookSeries>.from(allSeries)..shuffle();
|
||||
final result = shuffled.take(20).toList();
|
||||
_logger.log('📚 Found ${allSeries.length} total series, returning random selection');
|
||||
return shuffled.take(20).toList();
|
||||
_cacheService.setCachedDiscoverSeries(result);
|
||||
return result;
|
||||
} catch (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 [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cached discover series synchronously (for instant display)
|
||||
List<AudiobookSeries>? getCachedDiscoverSeries() => _cacheService.getCachedDiscoverSeries();
|
||||
|
||||
// ============================================================================
|
||||
// DETAIL SCREEN CACHING
|
||||
// ============================================================================
|
||||
@@ -3946,6 +4197,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_currentAudiobook = null;
|
||||
stateChanged = true;
|
||||
}
|
||||
if (_currentPodcastName != null) {
|
||||
_logger.log('🎙️ External source active, clearing podcast context');
|
||||
_currentPodcastName = null;
|
||||
stateChanged = true;
|
||||
}
|
||||
// Clear notification for external source
|
||||
audioHandler.clearRemotePlaybackState();
|
||||
if (stateChanged) {
|
||||
@@ -3987,6 +4243,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_currentAudiobook = null;
|
||||
stateChanged = true;
|
||||
}
|
||||
// Clear podcast context when playback stops
|
||||
if (_currentPodcastName != null) {
|
||||
_logger.log('🎙️ Playback stopped, clearing podcast context');
|
||||
_currentPodcastName = null;
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
if (stateChanged) {
|
||||
notifyListeners();
|
||||
@@ -4255,10 +4517,15 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
// Fetch tracks from API (not cached - too many items)
|
||||
// Apply provider filter if a single provider is selected
|
||||
if (_api != null) {
|
||||
try {
|
||||
_tracks = await _api!.getTracks(limit: LibraryConstants.maxLibraryItems);
|
||||
_logger.log('📥 Fetched ${_tracks.length} tracks from MA');
|
||||
final providerFilter = selectedProviderForFilter;
|
||||
_tracks = await _api!.getTracks(
|
||||
limit: LibraryConstants.maxLibraryItems,
|
||||
provider: providerFilter,
|
||||
);
|
||||
_logger.log('📥 Fetched ${_tracks.length} tracks from MA (provider=$providerFilter)');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to fetch tracks: $e');
|
||||
}
|
||||
@@ -4284,6 +4551,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
if (_api == null) return;
|
||||
|
||||
final syncService = SyncService.instance;
|
||||
final providerFilter = selectedProviderForFilter;
|
||||
_logger.log('🔄 _syncLibraryInBackground starting with providerFilter=$providerFilter');
|
||||
|
||||
// Listen for sync completion to update our lists
|
||||
void onSyncComplete() {
|
||||
@@ -4297,18 +4566,18 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
syncService.addListener(onSyncComplete);
|
||||
await syncService.syncFromApi(_api!);
|
||||
await syncService.syncFromApi(_api!, provider: providerFilter);
|
||||
}
|
||||
|
||||
/// Load radio stations from the library
|
||||
Future<void> loadRadioStations() async {
|
||||
Future<void> loadRadioStations({String? orderBy}) async {
|
||||
if (!isConnected || _api == null) return;
|
||||
|
||||
try {
|
||||
_isLoadingRadio = true;
|
||||
notifyListeners();
|
||||
|
||||
_radioStations = await _api!.getRadioStations(limit: 100);
|
||||
_radioStations = await _api!.getRadioStations(limit: 100, orderBy: orderBy);
|
||||
_isLoadingRadio = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
@@ -4318,14 +4587,14 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadPodcasts() async {
|
||||
Future<void> loadPodcasts({String? orderBy}) async {
|
||||
if (!isConnected || _api == null) return;
|
||||
|
||||
try {
|
||||
_isLoadingPodcasts = true;
|
||||
notifyListeners();
|
||||
|
||||
_podcasts = await _api!.getPodcasts(limit: 100);
|
||||
_podcasts = await _api!.getPodcasts(limit: 100, orderBy: orderBy);
|
||||
|
||||
_logger.log('🎙️ Loaded ${_podcasts.length} podcasts');
|
||||
_isLoadingPodcasts = false;
|
||||
@@ -4368,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;
|
||||
|
||||
try {
|
||||
@@ -4381,6 +4650,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
offset: offset,
|
||||
search: search,
|
||||
albumArtistsOnly: false, // Show ALL library artists, not just those with albums
|
||||
orderBy: orderBy,
|
||||
);
|
||||
|
||||
_isLoading = false;
|
||||
@@ -4398,6 +4668,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
int? offset,
|
||||
String? search,
|
||||
String? artistId,
|
||||
String? orderBy,
|
||||
}) async {
|
||||
if (!isConnected) return;
|
||||
|
||||
@@ -4411,6 +4682,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
offset: offset,
|
||||
search: search,
|
||||
artistId: artistId,
|
||||
orderBy: orderBy,
|
||||
);
|
||||
|
||||
_isLoading = false;
|
||||
@@ -4449,10 +4721,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// 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 [];
|
||||
try {
|
||||
final playlists = await _api!.getPlaylists(limit: limit, favoriteOnly: favoriteOnly);
|
||||
final playlists = await _api!.getPlaylists(limit: limit, favoriteOnly: favoriteOnly, orderBy: orderBy);
|
||||
return filterByProvider(playlists);
|
||||
} catch (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/player_picker_sheet.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'artist_details_screen.dart';
|
||||
|
||||
class AlbumDetailsScreen extends StatefulWidget {
|
||||
@@ -529,12 +530,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
S.of(context)!.addAlbumToQueueOn,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
if (players.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
@@ -609,12 +610,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
S.of(context)!.addToQueueOn,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
if (players.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
@@ -902,7 +903,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Spacing.vGap8,
|
||||
Hero(
|
||||
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
||||
child: Material(
|
||||
@@ -922,7 +923,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
// Album Description
|
||||
if (_albumDescription != null && _albumDescription!.isNotEmpty) ...[
|
||||
InkWell(
|
||||
@@ -944,7 +945,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Spacing.vGap8,
|
||||
],
|
||||
Row(
|
||||
children: [
|
||||
@@ -1015,7 +1016,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(25), // Circular
|
||||
borderRadius: BorderRadius.circular(Radii.xxl), // Circular
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -1193,7 +1194,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -1216,7 +1217,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
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 '../utils/page_transitions.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
|
||||
class ArtistDetailsScreen extends StatefulWidget {
|
||||
final Artist artist;
|
||||
@@ -417,12 +418,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
S.of(context)!.startRadioOn(widget.artist.name),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
if (players.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
@@ -497,12 +498,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
S.of(context)!.addToQueueOn,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
if (players.isEmpty)
|
||||
Padding(
|
||||
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) ...[
|
||||
InkWell(
|
||||
onTap: () {
|
||||
@@ -807,7 +808,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Spacing.vGap8,
|
||||
],
|
||||
// Action Buttons Row
|
||||
Row(
|
||||
@@ -861,7 +862,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
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,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||
),
|
||||
// View mode toggle
|
||||
IconButton(
|
||||
@@ -968,7 +969,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
onPressed: _cycleViewMode,
|
||||
visualDensity: VisualDensity.compact,
|
||||
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,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
@@ -1024,7 +1025,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
onPressed: _cycleViewMode,
|
||||
visualDensity: VisualDensity.compact,
|
||||
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),
|
||||
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(
|
||||
tag: HeroTags.albumTitle + (album.uri ?? album.itemId) + '_$heroSuffix',
|
||||
child: Material(
|
||||
|
||||
@@ -150,10 +150,16 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
},
|
||||
child: Scaffold(
|
||||
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: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 200,
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
leading: IconButton(
|
||||
@@ -172,37 +178,41 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
Hero(
|
||||
tag: HeroTags.authorImage + widget.authorName + _heroTagSuffix,
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: ClipOval(
|
||||
child: _authorImageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: _authorImageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
width: 200,
|
||||
height: 200,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Icon(
|
||||
MdiIcons.accountOutline,
|
||||
size: 100,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Icon always present underneath
|
||||
Icon(
|
||||
MdiIcons.accountOutline,
|
||||
size: coverSize * 0.5,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
// Image covers icon when loaded
|
||||
if (_authorImageUrl != null)
|
||||
SizedBox(
|
||||
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(),
|
||||
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 '../constants/hero_tags.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
|
||||
class AudiobookDetailScreen extends StatefulWidget {
|
||||
final Audiobook audiobook;
|
||||
@@ -566,7 +567,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Spacing.vGap8,
|
||||
|
||||
// Author
|
||||
Text(
|
||||
@@ -588,7 +589,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
],
|
||||
|
||||
// Duration & Progress
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
Row(
|
||||
children: [
|
||||
if (book.duration != null) ...[
|
||||
@@ -712,7 +713,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -736,7 +737,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Spacing.vGap8,
|
||||
InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
@@ -767,7 +768,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
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
|
||||
..._buildChapterSection(book, colorScheme, textTheme),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 140)),
|
||||
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -68,6 +68,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
// If queue panel is open, close it first (before collapsing player)
|
||||
if (GlobalPlayerOverlay.isQueuePanelOpen) {
|
||||
GlobalPlayerOverlay.closeQueuePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
// If global player is expanded, collapse it first
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
|
||||
@@ -4,8 +4,12 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../models/media_item.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 '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
|
||||
class LibraryPlaylistsScreen extends StatefulWidget {
|
||||
const LibraryPlaylistsScreen({super.key});
|
||||
@@ -104,34 +108,55 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
// Unique suffix for this context to avoid hero tag conflicts
|
||||
const heroSuffix = '_playlists';
|
||||
|
||||
return RepaintBoundary(
|
||||
child: ListTile(
|
||||
key: ValueKey(playlist.itemId),
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant,
|
||||
leading: Hero(
|
||||
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
image: CachedNetworkImageProvider(imageUrl),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != 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(
|
||||
playlist.name,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
title: Hero(
|
||||
tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + heroSuffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
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(
|
||||
playlist.trackCount != null
|
||||
@@ -144,16 +169,17 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: playlist.favorite == true
|
||||
? const Icon(Icons.favorite, color: Colors.red, size: 20)
|
||||
? const Icon(Icons.favorite, color: StatusColors.favorite, size: 20)
|
||||
: null,
|
||||
onTap: () {
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PlaylistDetailsScreen(
|
||||
FadeSlidePageRoute(
|
||||
child: PlaylistDetailsScreen(
|
||||
playlist: playlist,
|
||||
provider: playlist.provider,
|
||||
itemId: playlist.itemId,
|
||||
heroTagSuffix: 'playlists',
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -13,6 +13,9 @@ import '../widgets/artist_row.dart';
|
||||
import '../widgets/track_row.dart';
|
||||
import '../widgets/audiobook_row.dart';
|
||||
import '../widgets/series_row.dart';
|
||||
import '../widgets/playlist_row.dart';
|
||||
import '../widgets/radio_station_row.dart';
|
||||
import '../widgets/podcast_row.dart';
|
||||
import '../widgets/common/disconnected_state.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
@@ -35,6 +38,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
bool _showFavoriteAlbums = false;
|
||||
bool _showFavoriteArtists = false;
|
||||
bool _showFavoriteTracks = false;
|
||||
bool _showFavoritePlaylists = false;
|
||||
bool _showFavoriteRadioStations = false;
|
||||
bool _showFavoritePodcasts = false;
|
||||
// Audiobook rows (default off)
|
||||
bool _showContinueListeningAudiobooks = false;
|
||||
bool _showDiscoverAudiobooks = false;
|
||||
@@ -45,19 +51,89 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
@override
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_loadSettings();
|
||||
// Listen to sync completion to refresh favorite rows when data becomes available
|
||||
SyncService.instance.addListener(_onSyncChanged);
|
||||
_lastSyncStatus = SyncService.instance.status;
|
||||
_checkInitialFavoriteState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
SyncService.instance.removeListener(_onSyncChanged);
|
||||
// Clean up provider listener if attached
|
||||
_providerListeningTo?.removeListener(_onProviderChanged);
|
||||
_providerListeningTo = null;
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
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
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// Reload settings when app resumes (coming back from settings)
|
||||
@@ -73,6 +149,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
|
||||
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
|
||||
final showFavPodcasts = await SettingsService.getShowFavoritePodcasts();
|
||||
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
||||
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
|
||||
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
|
||||
@@ -85,6 +164,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
_showFavoriteAlbums = showFavAlbums;
|
||||
_showFavoriteArtists = showFavArtists;
|
||||
_showFavoriteTracks = showFavTracks;
|
||||
_showFavoritePlaylists = showFavPlaylists;
|
||||
_showFavoriteRadioStations = showFavRadio;
|
||||
_showFavoritePodcasts = showFavPodcasts;
|
||||
_showContinueListeningAudiobooks = showContAudiobooks;
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
@@ -238,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(
|
||||
BuildContext context, MusicAssistantProvider provider) {
|
||||
// Use LayoutBuilder to get available screen height
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Simple calculation: available height divided by 3 rows
|
||||
// Each row includes its title, artwork, and info
|
||||
// Each row is always 1/3 of screen height
|
||||
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll
|
||||
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
|
||||
final 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
|
||||
return NotificationListener<ScrollNotification>(
|
||||
@@ -288,6 +405,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
for (final rowId in _homeRowOrder) {
|
||||
final widget = _buildRowWidget(rowId, provider, rowHeight);
|
||||
if (widget != null) {
|
||||
// Add spacing between rows (not before first row)
|
||||
if (rows.isNotEmpty) {
|
||||
rows.add(const SizedBox(height: 2.0));
|
||||
}
|
||||
rows.add(widget);
|
||||
}
|
||||
}
|
||||
@@ -330,7 +451,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
return AudiobookRow(
|
||||
key: const ValueKey('continue-listening'),
|
||||
title: S.of(context)!.continueListening,
|
||||
loadAudiobooks: () => provider.getInProgressAudiobooks(),
|
||||
loadAudiobooks: () => provider.getInProgressAudiobooksWithCache(),
|
||||
getCachedAudiobooks: () => provider.getCachedInProgressAudiobooks(),
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'discover-audiobooks':
|
||||
@@ -338,7 +460,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
return AudiobookRow(
|
||||
key: const ValueKey('discover-audiobooks'),
|
||||
title: S.of(context)!.discoverAudiobooks,
|
||||
loadAudiobooks: () => provider.getDiscoverAudiobooks(),
|
||||
loadAudiobooks: () => provider.getDiscoverAudiobooksWithCache(),
|
||||
getCachedAudiobooks: () => provider.getCachedDiscoverAudiobooks(),
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'discover-series':
|
||||
@@ -346,7 +469,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
return SeriesRow(
|
||||
key: const ValueKey('discover-series'),
|
||||
title: S.of(context)!.discoverSeries,
|
||||
loadSeries: () => provider.getDiscoverSeries(),
|
||||
loadSeries: () => provider.getDiscoverSeriesWithCache(),
|
||||
getCachedSeries: () => provider.getCachedDiscoverSeries(),
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'favorite-albums':
|
||||
@@ -373,6 +497,33 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
loadTracks: () => provider.getFavoriteTracks(),
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'favorite-playlists':
|
||||
if (!_showFavoritePlaylists) return null;
|
||||
return PlaylistRow(
|
||||
key: const ValueKey('favorite-playlists'),
|
||||
title: S.of(context)!.favoritePlaylists,
|
||||
loadPlaylists: () => provider.getFavoritePlaylists(),
|
||||
heroTagSuffix: 'home',
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'favorite-radio-stations':
|
||||
if (!_showFavoriteRadioStations) return null;
|
||||
return RadioStationRow(
|
||||
key: const ValueKey('favorite-radio-stations'),
|
||||
title: S.of(context)!.favoriteRadioStations,
|
||||
loadRadioStations: () => provider.getFavoriteRadioStations(),
|
||||
heroTagSuffix: 'home',
|
||||
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:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -225,6 +225,8 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
|
||||
if (selectedPlayer != null) {
|
||||
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);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -259,6 +261,8 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
onPlayerSelected: (player) async {
|
||||
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);
|
||||
},
|
||||
).whenComplete(() {
|
||||
@@ -856,8 +860,8 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
),
|
||||
|
||||
// Bottom padding for mini player
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 100),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: BottomSpacing.withMiniPlayer),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../providers/music_assistant_provider.dart';
|
||||
import '../models/player.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../widgets/common/empty_state.dart';
|
||||
import '../widgets/player_picker_sheet.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
@@ -87,6 +150,11 @@ class _QueueScreenState extends State<QueueScreen> {
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.swap_horiz_rounded),
|
||||
tooltip: 'Transfer queue',
|
||||
onPressed: () => _handleTransferQueue(maProvider),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadQueue,
|
||||
|
||||
@@ -15,12 +15,14 @@ import '../widgets/artist_avatar.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../utils/search/search_scoring.dart';
|
||||
import 'album_details_screen.dart';
|
||||
import 'artist_details_screen.dart';
|
||||
import 'playlist_details_screen.dart';
|
||||
import 'audiobook_detail_screen.dart';
|
||||
import 'podcast_detail_screen.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
|
||||
class SearchScreen extends StatefulWidget {
|
||||
const SearchScreen({super.key});
|
||||
@@ -128,11 +130,24 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
// PERF: Cache available filters to avoid recalculating during build
|
||||
List<String>? _cachedAvailableFilters;
|
||||
|
||||
// Advanced search scorer with fuzzy matching, stopword removal, etc.
|
||||
final SearchScorer _searchScorer = SearchScorer();
|
||||
|
||||
// Scroll-to-hide search bar (vertical scroll only)
|
||||
bool _isSearchBarVisible = true;
|
||||
double _lastVerticalScrollOffset = 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
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -141,18 +156,48 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
_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)
|
||||
/// Uses provider filter to exclude disabled providers from results
|
||||
List<String> _getAvailableFilters() {
|
||||
if (_cachedAvailableFilters != null) return _cachedAvailableFilters!;
|
||||
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
final filtered = _getFilteredResults(provider);
|
||||
final filters = <String>['all'];
|
||||
if (_searchResults['artists']?.isNotEmpty == true) filters.add('artists');
|
||||
if (_searchResults['albums']?.isNotEmpty == true) filters.add('albums');
|
||||
if (_searchResults['tracks']?.isNotEmpty == true) filters.add('tracks');
|
||||
if (_searchResults['playlists']?.isNotEmpty == true) filters.add('playlists');
|
||||
if (_searchResults['audiobooks']?.isNotEmpty == true) filters.add('audiobooks');
|
||||
if (_searchResults['radios']?.isNotEmpty == true) filters.add('radios');
|
||||
if (_searchResults['podcasts']?.isNotEmpty == true) filters.add('podcasts');
|
||||
if (filtered['artists']?.isNotEmpty == true) filters.add('artists');
|
||||
if (filtered['albums']?.isNotEmpty == true) filters.add('albums');
|
||||
if (filtered['tracks']?.isNotEmpty == true) filters.add('tracks');
|
||||
if (filtered['playlists']?.isNotEmpty == true) filters.add('playlists');
|
||||
if (filtered['audiobooks']?.isNotEmpty == true) filters.add('audiobooks');
|
||||
if (filtered['radios']?.isNotEmpty == true) filters.add('radios');
|
||||
if (filtered['podcasts']?.isNotEmpty == true) filters.add('podcasts');
|
||||
_cachedAvailableFilters = filters;
|
||||
return filters;
|
||||
}
|
||||
@@ -181,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
|
||||
/// Only scrolls when the filter would be obscured (off-screen)
|
||||
/// Uses post-frame callback to avoid interfering with PageView animation
|
||||
@@ -196,30 +274,35 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
// If all filters fit on screen, don't scroll at all
|
||||
if (maxScroll <= 0) return;
|
||||
|
||||
// Approximate width per filter chip (padding + text)
|
||||
const chipWidth = 80.0;
|
||||
const horizontalPadding = 16.0;
|
||||
final filters = _getAvailableFilters();
|
||||
if (filterIndex < 0 || filterIndex >= filters.length) return;
|
||||
|
||||
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 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
|
||||
final visibleLeft = currentScroll;
|
||||
final visibleRight = currentScroll + viewportWidth - (horizontalPadding * 2);
|
||||
final visibleRight = currentScroll + viewportWidth;
|
||||
|
||||
// Only scroll if the chip is actually obscured
|
||||
double? targetOffset;
|
||||
|
||||
if (chipRight > visibleRight) {
|
||||
// Chip is cut off on the right - scroll right just enough to show it
|
||||
targetOffset = chipRight - viewportWidth + (horizontalPadding * 2);
|
||||
// Chip is cut off on the right - scroll right to show it with padding
|
||||
targetOffset = chipRight - viewportWidth + 8;
|
||||
} else if (chipLeft < visibleLeft) {
|
||||
// Chip is cut off on the left - scroll left just enough to show it
|
||||
targetOffset = chipLeft;
|
||||
// Chip is cut off on the left - scroll left to show it with padding
|
||||
targetOffset = chipLeft - 8;
|
||||
}
|
||||
|
||||
// Only animate if we need to scroll
|
||||
@@ -437,81 +520,97 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
decoration: const BoxDecoration(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Search field
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
focusNode: _focusNode,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
cursorColor: colorScheme.primary,
|
||||
textInputAction: TextInputAction.search,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
||||
hintText: S.of(context)!.searchMusic,
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
border: InputBorder.none,
|
||||
suffixIcon: _hasSearchText
|
||||
? 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,
|
||||
child: Row(
|
||||
children: [
|
||||
// Search field container
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
focusNode: _focusNode,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
cursorColor: colorScheme.primary,
|
||||
textInputAction: TextInputAction.search,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
||||
hintText: S.of(context)!.searchMusic,
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(
|
||||
Icons.search_rounded,
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
onChanged: (value) {
|
||||
// PERF: Only rebuild when clear button visibility changes
|
||||
final hasText = value.isNotEmpty;
|
||||
if (hasText != _hasSearchText) {
|
||||
setState(() {
|
||||
_hasSearchText = hasText;
|
||||
});
|
||||
}
|
||||
_onSearchChanged(value);
|
||||
},
|
||||
onSubmitted: (query) => _performSearch(query),
|
||||
suffixIcon: _hasSearchText
|
||||
? 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Library-only toggle
|
||||
Tooltip(
|
||||
message: S.of(context)!.libraryOnly,
|
||||
child: IconButton(
|
||||
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);
|
||||
onChanged: (value) {
|
||||
// PERF: Only rebuild when clear button visibility changes
|
||||
final hasText = value.isNotEmpty;
|
||||
if (hasText != _hasSearchText) {
|
||||
setState(() {
|
||||
_hasSearchText = hasText;
|
||||
});
|
||||
}
|
||||
_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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -553,7 +652,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
size: 80,
|
||||
color: colorScheme.onBackground.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
S.of(context)!.searchForContent,
|
||||
style: TextStyle(
|
||||
@@ -606,7 +705,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
size: 64,
|
||||
color: colorScheme.error.withOpacity(0.7),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
_searchError!,
|
||||
style: TextStyle(
|
||||
@@ -615,7 +714,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
FilledButton.tonal(
|
||||
onPressed: () => _performSearch(_searchController.text),
|
||||
child: Text(S.of(context)!.retry),
|
||||
@@ -625,13 +724,16 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
final artists = _searchResults['artists'] as List<MediaItem>? ?? [];
|
||||
final albums = _searchResults['albums'] as List<MediaItem>? ?? [];
|
||||
final tracks = _searchResults['tracks'] as List<MediaItem>? ?? [];
|
||||
final playlists = _searchResults['playlists'] as List<MediaItem>? ?? [];
|
||||
final audiobooks = _searchResults['audiobooks'] as List<MediaItem>? ?? [];
|
||||
final radios = _searchResults['radios'] as List<MediaItem>? ?? [];
|
||||
final podcasts = _searchResults['podcasts'] as List<MediaItem>? ?? [];
|
||||
// Apply provider filter to search results
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
final filtered = _getFilteredResults(provider);
|
||||
final artists = filtered['artists'] as List<MediaItem>? ?? [];
|
||||
final albums = filtered['albums'] as List<MediaItem>? ?? [];
|
||||
final tracks = filtered['tracks'] 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 ||
|
||||
playlists.isNotEmpty || audiobooks.isNotEmpty || radios.isNotEmpty ||
|
||||
@@ -736,7 +838,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
colorScheme.background,
|
||||
colorScheme.surface.withOpacity(0),
|
||||
colorScheme.background.withOpacity(0),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -750,151 +852,15 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Calculate relevance score for a media item based on query match
|
||||
/// Calculate relevance score for a media item based on query match.
|
||||
///
|
||||
/// Uses advanced scoring with:
|
||||
/// - Stopword removal ("the Ramones" finds "Ramones")
|
||||
/// - Fuzzy matching (typo tolerance via Jaro-Winkler)
|
||||
/// - N-gram matching (partial matches)
|
||||
/// - Reverse matching (query contains result name)
|
||||
double _calculateRelevanceScore(MediaItem item, String query) {
|
||||
final queryLower = query.toLowerCase().trim();
|
||||
if (queryLower.isEmpty) return 0;
|
||||
|
||||
double score = 0;
|
||||
final nameLower = item.name.toLowerCase();
|
||||
|
||||
// Primary name matching
|
||||
if (nameLower == queryLower) {
|
||||
score = 100; // Exact match
|
||||
} else if (nameLower.startsWith(queryLower)) {
|
||||
score = 80; // Starts with query
|
||||
} else if (_matchesWordBoundary(nameLower, queryLower)) {
|
||||
score = 60; // Contains query at word boundary
|
||||
} else if (nameLower.contains(queryLower)) {
|
||||
score = 40; // Contains query anywhere
|
||||
} else {
|
||||
score = 20; // Fuzzy/partial match (MA returned it, so some relevance)
|
||||
}
|
||||
|
||||
// Bonus for library items (Album has inLibrary property)
|
||||
if (item is Album && item.inLibrary) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
// Bonus for favorites
|
||||
if (item.favorite == true) {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
// Secondary field matching (artist/author/creator name)
|
||||
if (item is Album) {
|
||||
final artistLower = item.artistsString.toLowerCase();
|
||||
if (artistLower == queryLower) {
|
||||
score += 15; // Artist exact match
|
||||
} else if (artistLower.contains(queryLower)) {
|
||||
score += 8; // Artist contains query
|
||||
}
|
||||
} else if (item is Track) {
|
||||
final artistLower = item.artistsString.toLowerCase();
|
||||
if (artistLower == queryLower) {
|
||||
score += 15;
|
||||
} else if (artistLower.contains(queryLower)) {
|
||||
score += 8;
|
||||
}
|
||||
// Also check album name for tracks
|
||||
if (item.album?.name != null) {
|
||||
final albumLower = item.album!.name.toLowerCase();
|
||||
if (albumLower.contains(queryLower)) {
|
||||
score += 5;
|
||||
}
|
||||
}
|
||||
} else if (item is Audiobook) {
|
||||
// Check audiobook author names
|
||||
final authorLower = item.authorsString.toLowerCase();
|
||||
if (authorLower == queryLower) {
|
||||
score += 15; // Author exact match
|
||||
} else if (authorLower.contains(queryLower)) {
|
||||
score += 8; // Author contains query
|
||||
}
|
||||
// Also check narrator names
|
||||
final narratorLower = item.narratorsString.toLowerCase();
|
||||
if (narratorLower.contains(queryLower)) {
|
||||
score += 5;
|
||||
}
|
||||
} else if (item.mediaType == MediaType.podcast || item.mediaType == MediaType.podcastEpisode) {
|
||||
// Check podcast metadata for author/creator
|
||||
final metadata = item.metadata;
|
||||
bool foundCreatorMatch = false;
|
||||
if (metadata != null) {
|
||||
final author = (metadata['author'] as String? ?? '').toLowerCase();
|
||||
final publisher = (metadata['publisher'] as String? ?? '').toLowerCase();
|
||||
final owner = (metadata['owner'] as String? ?? '').toLowerCase();
|
||||
final creator = (metadata['creator'] as String? ?? '').toLowerCase();
|
||||
|
||||
// Check all possible creator fields
|
||||
final creatorFields = [author, publisher, owner, creator].where((s) => s.isNotEmpty);
|
||||
bool foundExact = false;
|
||||
bool foundContains = false;
|
||||
for (final field in creatorFields) {
|
||||
if (field == queryLower) {
|
||||
foundExact = true;
|
||||
break;
|
||||
} else if (field.contains(queryLower)) {
|
||||
foundContains = true;
|
||||
}
|
||||
}
|
||||
if (foundExact) {
|
||||
score += 15; // Creator exact match
|
||||
foundCreatorMatch = true;
|
||||
} else if (foundContains) {
|
||||
score += 8; // Creator contains query
|
||||
foundCreatorMatch = true;
|
||||
}
|
||||
|
||||
// Also check description for keyword matches
|
||||
final description = (metadata['description'] as String? ?? '').toLowerCase();
|
||||
if (description.contains(queryLower)) {
|
||||
score += 5; // Increased from 3 to match other media types
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: If no creator metadata matched, but query matches strongly in name,
|
||||
// give a boost. Podcast names often include the host's name (e.g., "The Louis Theroux Podcast")
|
||||
if (!foundCreatorMatch && nameLower.contains(queryLower)) {
|
||||
// Multi-word query that matches in name suggests creator/host name
|
||||
if (queryLower.contains(' ')) {
|
||||
// Calculate how prominent the query is in the name
|
||||
// e.g., "Louis Theroux Interviews" with query "Louis Theroux" = 14/23 = ~60%
|
||||
final prominence = queryLower.length / nameLower.length;
|
||||
if (prominence >= 0.5) {
|
||||
score += 15; // Query is >50% of name - very strong signal
|
||||
} else if (prominence >= 0.3) {
|
||||
score += 12; // Query is 30-50% of name - strong signal
|
||||
} else {
|
||||
score += 8; // Query in name but less prominent
|
||||
}
|
||||
} else {
|
||||
score += 5; // Single word match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/// Check if query matches at a word boundary in text
|
||||
/// Handles both single-word and multi-word queries
|
||||
bool _matchesWordBoundary(String text, String query) {
|
||||
// For multi-word queries, check if query appears at start of a word in text
|
||||
// e.g., "The Louis Theroux Podcast" contains "louis theroux" at word boundary
|
||||
if (query.contains(' ')) {
|
||||
// Check if text starts with query or contains query after whitespace
|
||||
if (text.startsWith(query)) return true;
|
||||
if (text.contains(' $query')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// For single-word queries, check individual words
|
||||
final words = text.split(RegExp(r'\s+'));
|
||||
for (final word in words) {
|
||||
if (word.startsWith(query)) return true;
|
||||
}
|
||||
return false;
|
||||
return _searchScorer.scoreItem(item, query);
|
||||
}
|
||||
|
||||
/// Extract artists from tracks/albums that match the query but aren't in direct results
|
||||
@@ -907,8 +873,10 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
) {
|
||||
if (query.isEmpty) return [];
|
||||
|
||||
final queryLower = query.toLowerCase();
|
||||
final queryWords = queryLower.split(RegExp(r'\s+'));
|
||||
// Use normalizer to get tokens with stopwords removed
|
||||
// This way "the Beatles" becomes ["beatles"] for better matching
|
||||
final normalizedQuery = _searchScorer.normalizer.normalizeQuery(query);
|
||||
final queryWords = normalizedQuery.tokensNoStop;
|
||||
|
||||
// Create a set of existing artist identifiers to avoid duplicates
|
||||
final existingArtistKeys = <String>{};
|
||||
@@ -942,6 +910,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
}
|
||||
|
||||
// Filter candidates: only include if artist name contains any query word
|
||||
// (stopwords already removed from queryWords)
|
||||
final crossRefArtists = <Artist>[];
|
||||
for (final artist in candidateArtists.values) {
|
||||
final artistLower = artist.name.toLowerCase();
|
||||
@@ -1063,7 +1032,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
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) {
|
||||
final filters = _getAvailableFilters();
|
||||
final l10n = S.of(context)!;
|
||||
@@ -1082,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
|
||||
return ValueListenableBuilder<String>(
|
||||
valueListenable: _activeFilterNotifier,
|
||||
builder: (context, activeFilter, _) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: filters.map((filter) {
|
||||
final isSelected = activeFilter == filter;
|
||||
return Material(
|
||||
// Use theme-aware colors for light/dark mode support
|
||||
color: isSelected
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceVariant.withOpacity(0.6),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// Only update ValueNotifier - no setState needed
|
||||
_activeFilterNotifier.value = filter;
|
||||
_animateToFilter(filter);
|
||||
},
|
||||
child: Padding(
|
||||
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,
|
||||
// Measure positions after build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_measureFilterPositions();
|
||||
// Update highlight on first build or when filters change
|
||||
final position = _filterPositions[activeFilter];
|
||||
final width = _filterWidths[activeFilter];
|
||||
if (position != null && width != null &&
|
||||
(_highlightLeft != position || _highlightWidth != width)) {
|
||||
setState(() {
|
||||
_highlightLeft = position;
|
||||
_highlightWidth = width;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
final selectedIndex = filters.indexOf(activeFilter);
|
||||
final isFirstTab = selectedIndex == 0;
|
||||
final isLastTab = selectedIndex == filters.length - 1;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withOpacity(0.5),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
// Filter buttons row (transparent, on top of highlight)
|
||||
Row(
|
||||
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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1255,7 +1275,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -1471,7 +1491,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -1665,7 +1685,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -1695,37 +1715,58 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
final isExpanded = _expandedPlaylistId == playlistId;
|
||||
final isInLib = _isInLibrary(playlist);
|
||||
|
||||
// Unique suffix for search context
|
||||
const heroSuffix = '_search';
|
||||
|
||||
return RepaintBoundary(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
key: ValueKey(playlistId),
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant,
|
||||
leading: Hero(
|
||||
tag: HeroTags.playlistCover + playlistId + heroSuffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
image: CachedNetworkImageProvider(imageUrl),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != 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(
|
||||
playlist.name,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
title: Hero(
|
||||
tag: HeroTags.playlistTitle + playlistId + heroSuffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
playlist.name,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: showType
|
||||
? Row(
|
||||
@@ -1760,13 +1801,14 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
if (isExpanded) {
|
||||
setState(() => _expandedPlaylistId = null);
|
||||
} else {
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PlaylistDetailsScreen(
|
||||
FadeSlidePageRoute(
|
||||
child: PlaylistDetailsScreen(
|
||||
playlist: playlist,
|
||||
provider: playlist.provider,
|
||||
itemId: playlist.itemId,
|
||||
heroTagSuffix: 'search',
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -2008,7 +2050,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
|
||||
@@ -29,6 +29,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _showFavoriteAlbums = false;
|
||||
bool _showFavoriteArtists = false;
|
||||
bool _showFavoriteTracks = false;
|
||||
bool _showFavoritePlaylists = false;
|
||||
bool _showFavoriteRadioStations = false;
|
||||
bool _showFavoritePodcasts = false;
|
||||
// Audiobook home rows (default off)
|
||||
bool _showContinueListeningAudiobooks = false;
|
||||
bool _showDiscoverAudiobooks = false;
|
||||
@@ -41,10 +44,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Player settings
|
||||
bool _preferLocalPlayer = false;
|
||||
bool _smartSortPlayers = false;
|
||||
bool _volumePrecisionMode = true;
|
||||
// Hint settings
|
||||
bool _showHints = true;
|
||||
// Library settings
|
||||
bool _showOnlyArtistsWithAlbums = false;
|
||||
// Music provider filter settings
|
||||
List<String> _disabledProviders = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -70,6 +76,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
|
||||
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
|
||||
final showFavPodcasts = await SettingsService.getShowFavoritePodcasts();
|
||||
|
||||
// Load audiobook home row settings
|
||||
final showContinueAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
||||
@@ -92,6 +101,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Load player settings
|
||||
final preferLocal = await SettingsService.getPreferLocalPlayer();
|
||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||
final volumePrecision = await SettingsService.getVolumePrecisionMode();
|
||||
|
||||
// Load hint settings
|
||||
final showHints = await SettingsService.getShowHints();
|
||||
@@ -99,6 +109,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Load library settings
|
||||
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||
|
||||
// Load music provider filter settings
|
||||
final disabledProviders = await SettingsService.getDisabledMusicProviders();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showRecentAlbums = showRecent;
|
||||
@@ -107,6 +120,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_showFavoriteAlbums = showFavAlbums;
|
||||
_showFavoriteArtists = showFavArtists;
|
||||
_showFavoriteTracks = showFavTracks;
|
||||
_showFavoritePlaylists = showFavPlaylists;
|
||||
_showFavoriteRadioStations = showFavRadio;
|
||||
_showFavoritePodcasts = showFavPodcasts;
|
||||
_showContinueListeningAudiobooks = showContinueAudiobooks;
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
@@ -115,8 +131,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_libraryEnabled = libraryEnabled;
|
||||
_preferLocalPlayer = preferLocal;
|
||||
_smartSortPlayers = smartSort;
|
||||
_volumePrecisionMode = volumePrecision;
|
||||
_showHints = showHints;
|
||||
_showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums;
|
||||
_disabledProviders = disabledProviders;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -150,6 +168,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return {'title': s.favoriteArtists, 'subtitle': s.showFavoriteArtists};
|
||||
case 'favorite-tracks':
|
||||
return {'title': s.favoriteTracks, 'subtitle': s.showFavoriteTracks};
|
||||
case 'favorite-playlists':
|
||||
return {'title': s.favoritePlaylists, 'subtitle': s.showFavoritePlaylists};
|
||||
case 'favorite-radio-stations':
|
||||
return {'title': s.favoriteRadioStations, 'subtitle': s.showFavoriteRadioStations};
|
||||
case 'favorite-podcasts':
|
||||
return {'title': s.favoritePodcasts, 'subtitle': s.showFavoritePodcasts};
|
||||
default:
|
||||
return {'title': rowId, 'subtitle': ''};
|
||||
}
|
||||
@@ -176,6 +200,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
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;
|
||||
}
|
||||
@@ -221,6 +251,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_showFavoriteTracks = value;
|
||||
SettingsService.setShowFavoriteTracks(value);
|
||||
break;
|
||||
case 'favorite-playlists':
|
||||
_showFavoritePlaylists = value;
|
||||
SettingsService.setShowFavoritePlaylists(value);
|
||||
break;
|
||||
case 'favorite-radio-stations':
|
||||
_showFavoriteRadioStations = value;
|
||||
SettingsService.setShowFavoriteRadioStations(value);
|
||||
break;
|
||||
case 'favorite-podcasts':
|
||||
_showFavoritePodcasts = value;
|
||||
SettingsService.setShowFavoritePodcasts(value);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -287,13 +329,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Logo - same size as login screen (50% of screen width)
|
||||
// Use color filter to make logo dark in light theme
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0, bottom: 48.0),
|
||||
child: Image.asset(
|
||||
'assets/images/ensemble_icon_transparent.png',
|
||||
width: MediaQuery.of(context).size.width * 0.5,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
child: Theme.of(context).brightness == Brightness.light
|
||||
? ColorFiltered(
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Color(0xFF1a1a1a), // Dark color for light theme
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/images/ensemble_icon_transparent.png',
|
||||
width: MediaQuery.of(context).size.width * 0.5,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
)
|
||||
: Image.asset(
|
||||
'assets/images/ensemble_icon_transparent.png',
|
||||
width: MediaQuery.of(context).size.width * 0.5,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
|
||||
// Connection status box - centered with border radius like theme boxes
|
||||
@@ -635,6 +690,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -647,6 +711,66 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
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
|
||||
Text(
|
||||
S.of(context)!.players,
|
||||
@@ -708,6 +832,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
title: Text(
|
||||
'Volume precision mode',
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Hold still while adjusting volume for fine control',
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||
),
|
||||
value: _volumePrecisionMode,
|
||||
onChanged: (value) {
|
||||
setState(() => _volumePrecisionMode = value);
|
||||
SettingsService.setVolumePrecisionMode(value);
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
|
||||
@@ -20,9 +20,15 @@ class CacheService {
|
||||
List<Album>? _cachedRecentAlbums;
|
||||
List<Artist>? _cachedDiscoverArtists;
|
||||
List<Album>? _cachedDiscoverAlbums;
|
||||
List<Audiobook>? _cachedInProgressAudiobooks;
|
||||
List<Audiobook>? _cachedDiscoverAudiobooks;
|
||||
List<AudiobookSeries>? _cachedDiscoverSeries;
|
||||
DateTime? _recentAlbumsLastFetched;
|
||||
DateTime? _discoverArtistsLastFetched;
|
||||
DateTime? _discoverAlbumsLastFetched;
|
||||
DateTime? _inProgressAudiobooksLastFetched;
|
||||
DateTime? _discoverAudiobooksLastFetched;
|
||||
DateTime? _discoverSeriesLastFetched;
|
||||
|
||||
// Detail screen caching
|
||||
final Map<String, List<Track>> _albumTracksCache = {};
|
||||
@@ -112,11 +118,82 @@ class CacheService {
|
||||
_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)
|
||||
void invalidateHomeCache() {
|
||||
_recentAlbumsLastFetched = null;
|
||||
_discoverArtistsLastFetched = null;
|
||||
_discoverAlbumsLastFetched = null;
|
||||
_inProgressAudiobooksLastFetched = null;
|
||||
_discoverAudiobooksLastFetched = null;
|
||||
_discoverSeriesLastFetched = null;
|
||||
_logger.log('🗑️ Home screen cache invalidated');
|
||||
}
|
||||
|
||||
|
||||
@@ -326,120 +326,301 @@ class MetadataService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Fetches author image URL from multiple sources
|
||||
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia
|
||||
/// Returns the image URL if found, null otherwise
|
||||
static Future<String?> getAuthorImageUrl(String authorName) async {
|
||||
// Check cache first
|
||||
final cacheKey = 'authorImage:$authorName';
|
||||
if (_authorImageCache.containsKey(cacheKey)) {
|
||||
return _authorImageCache[cacheKey];
|
||||
/// Generate name variations for fuzzy matching
|
||||
/// Returns a list of name variations to try, in priority order
|
||||
static List<String> _generateNameVariations(String authorName) {
|
||||
final variations = <String>[];
|
||||
final original = authorName.trim();
|
||||
|
||||
// Always try original first
|
||||
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 {
|
||||
final audnexusUri = Uri.https(
|
||||
'api.audnex.us',
|
||||
'/authors',
|
||||
{'name': authorName},
|
||||
);
|
||||
// Step 1: Search for author to get ASIN
|
||||
final searchUri = Uri.https('api.audnex.us', '/authors', {'name': name});
|
||||
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
|
||||
|
||||
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) {
|
||||
final data = json.decode(audnexusResponse.body);
|
||||
// Audnexus returns an array of authors
|
||||
if (data is List && data.isNotEmpty) {
|
||||
final author = data[0];
|
||||
final imageUrl = author['image'] as String?;
|
||||
if (imageUrl != null && imageUrl.isNotEmpty) {
|
||||
_authorImageCache[cacheKey] = imageUrl;
|
||||
return imageUrl;
|
||||
for (final result in searchData) {
|
||||
final resultName = (result['name'] as String?)?.toLowerCase().replaceAll(RegExp(r'[.\s]+'), '');
|
||||
final asin = result['asin'] as String?;
|
||||
if (asin != null) {
|
||||
if (resultName == nameLower) {
|
||||
bestAsin = asin;
|
||||
break; // Exact match found
|
||||
}
|
||||
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) {
|
||||
_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 {
|
||||
final uri = Uri.https(
|
||||
'openlibrary.org',
|
||||
'/search/authors.json',
|
||||
{
|
||||
'q': authorName,
|
||||
'limit': '1',
|
||||
},
|
||||
);
|
||||
|
||||
final uri = Uri.https('openlibrary.org', '/search/authors.json', {
|
||||
'q': name,
|
||||
'limit': '1',
|
||||
});
|
||||
final response = await http.get(uri).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
final authors = data['docs'] as List?;
|
||||
if (authors != null && authors.isNotEmpty) {
|
||||
final author = authors[0];
|
||||
final authorKey = author['key'] as String?;
|
||||
final authorKey = authors[0]['key'] as String?;
|
||||
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 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 {
|
||||
final imageCheck = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3));
|
||||
if (imageCheck.statusCode == 200) {
|
||||
_authorImageCache[cacheKey] = imageUrl;
|
||||
final check = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3));
|
||||
// Accept 200 (direct), 301/302 (redirect to archive.org), or 307/308 (temporary redirect)
|
||||
if (check.statusCode == 200 || check.statusCode == 301 ||
|
||||
check.statusCode == 302 || check.statusCode == 307 || check.statusCode == 308) {
|
||||
return imageUrl;
|
||||
}
|
||||
} catch (_) {
|
||||
// Image doesn't exist, continue to cache null
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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) {
|
||||
_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 {
|
||||
// Search for author on Wikipedia
|
||||
final searchUri = Uri.https(
|
||||
'en.wikipedia.org',
|
||||
'/w/api.php',
|
||||
{
|
||||
'action': 'query',
|
||||
'list': 'search',
|
||||
'srsearch': '$authorName writer',
|
||||
'srlimit': '1',
|
||||
'format': 'json',
|
||||
},
|
||||
);
|
||||
|
||||
// Search for author - "writer author" suffix helps find the right page
|
||||
final searchUri = Uri.https('en.wikipedia.org', '/w/api.php', {
|
||||
'action': 'query',
|
||||
'list': 'search',
|
||||
'srsearch': '$name writer author',
|
||||
'srlimit': '1',
|
||||
'format': 'json',
|
||||
});
|
||||
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (searchResponse.statusCode == 200) {
|
||||
final searchData = json.decode(searchResponse.body);
|
||||
final searchResults = searchData['query']?['search'] as List?;
|
||||
if (searchResults != null && searchResults.isNotEmpty) {
|
||||
final pageTitle = searchResults[0]['title'] as String?;
|
||||
final results = searchData['query']?['search'] as List?;
|
||||
if (results != null && results.isNotEmpty) {
|
||||
final pageTitle = results[0]['title'] as String?;
|
||||
if (pageTitle != null) {
|
||||
// Get page image
|
||||
final imageUri = Uri.https(
|
||||
'en.wikipedia.org',
|
||||
'/w/api.php',
|
||||
{
|
||||
'action': 'query',
|
||||
'titles': pageTitle,
|
||||
'prop': 'pageimages',
|
||||
'pithumbsize': '500',
|
||||
'format': 'json',
|
||||
},
|
||||
);
|
||||
|
||||
final imageUri = Uri.https('en.wikipedia.org', '/w/api.php', {
|
||||
'action': 'query',
|
||||
'titles': pageTitle,
|
||||
'prop': 'pageimages',
|
||||
'pithumbsize': '500',
|
||||
'format': 'json',
|
||||
});
|
||||
final imageResponse = await http.get(imageUri).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (imageResponse.statusCode == 200) {
|
||||
@@ -450,7 +631,6 @@ class MetadataService {
|
||||
final thumbnail = page?['thumbnail'] as Map<String, dynamic>?;
|
||||
final imageUrl = thumbnail?['source'] as String?;
|
||||
if (imageUrl != null && imageUrl.isNotEmpty) {
|
||||
_authorImageCache[cacheKey] = imageUrl;
|
||||
return imageUrl;
|
||||
}
|
||||
}
|
||||
@@ -459,7 +639,100 @@ class MetadataService {
|
||||
}
|
||||
}
|
||||
} 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
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:uuid/uuid.dart';
|
||||
import '../constants/network.dart';
|
||||
import '../constants/timings.dart' show Timings, LibraryConstants;
|
||||
import '../models/media_item.dart';
|
||||
import '../models/music_provider.dart';
|
||||
import '../models/player.dart';
|
||||
import 'debug_logger.dart';
|
||||
import 'settings_service.dart';
|
||||
@@ -52,6 +53,10 @@ class MusicAssistantAPI {
|
||||
bool _authRequired = false;
|
||||
int? _schemaVersion;
|
||||
|
||||
// Music providers (type == "music")
|
||||
List<MusicProvider> _musicProviders = [];
|
||||
List<MusicProvider> get musicProviders => _musicProviders;
|
||||
|
||||
// Auth state
|
||||
bool _isAuthenticated = false;
|
||||
bool get isAuthenticated => _isAuthenticated;
|
||||
@@ -376,23 +381,30 @@ class MusicAssistantAPI {
|
||||
String? search,
|
||||
bool? favoriteOnly,
|
||||
bool albumArtistsOnly = true,
|
||||
String? orderBy,
|
||||
String? provider, // Filter to specific provider instance
|
||||
}) async {
|
||||
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(
|
||||
'music/artists/library_items',
|
||||
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,
|
||||
},
|
||||
args: args,
|
||||
);
|
||||
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
if (items == null) {
|
||||
return [];
|
||||
}
|
||||
_logger.log('🔍 API getArtists returned ${items.length} items');
|
||||
return items
|
||||
.map((item) => Artist.fromJson(item as Map<String, dynamic>))
|
||||
.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 {
|
||||
final response = await _sendCommand(
|
||||
'music/artists/library_items',
|
||||
@@ -410,6 +422,7 @@ class MusicAssistantAPI {
|
||||
'limit': limit,
|
||||
'order_by': 'random',
|
||||
'album_artists_only': albumArtistsOnly,
|
||||
if (provider != null) 'provider': provider,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -431,6 +444,8 @@ class MusicAssistantAPI {
|
||||
String? search,
|
||||
bool? favoriteOnly,
|
||||
String? artistId,
|
||||
String? orderBy,
|
||||
String? provider, // Filter to specific provider instance
|
||||
}) async {
|
||||
try {
|
||||
final args = <String, dynamic>{
|
||||
@@ -439,7 +454,10 @@ class MusicAssistantAPI {
|
||||
if (search != null) 'search': search,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
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(
|
||||
'music/albums/library_items',
|
||||
@@ -448,6 +466,7 @@ class MusicAssistantAPI {
|
||||
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
if (items == null) return [];
|
||||
_logger.log('🔍 API getAlbums returned ${items.length} items');
|
||||
|
||||
return items
|
||||
.map((item) => Album.fromJson(item as Map<String, dynamic>))
|
||||
@@ -465,6 +484,8 @@ class MusicAssistantAPI {
|
||||
bool? favoriteOnly,
|
||||
String? artistId,
|
||||
String? albumId,
|
||||
String? orderBy,
|
||||
String? provider, // Filter to specific provider instance
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
@@ -473,9 +494,11 @@ class MusicAssistantAPI {
|
||||
if (limit != null) 'limit': limit,
|
||||
if (offset != null) 'offset': offset,
|
||||
if (search != null) 'search': search,
|
||||
if (provider != null) 'provider': provider,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (artistId != null) 'artist': artistId,
|
||||
if (albumId != null) 'album': albumId,
|
||||
if (orderBy != null) 'order_by': orderBy,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -496,6 +519,7 @@ class MusicAssistantAPI {
|
||||
int? limit,
|
||||
int? offset,
|
||||
bool? favoriteOnly,
|
||||
String? orderBy,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
@@ -504,6 +528,7 @@ class MusicAssistantAPI {
|
||||
if (limit != null) 'limit': limit,
|
||||
if (offset != null) 'offset': offset,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (orderBy != null) 'order_by': orderBy,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -555,6 +580,7 @@ class MusicAssistantAPI {
|
||||
int? limit,
|
||||
int? offset,
|
||||
bool? favoriteOnly,
|
||||
String? orderBy,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
@@ -563,6 +589,7 @@ class MusicAssistantAPI {
|
||||
if (limit != null) 'limit': limit,
|
||||
if (offset != null) 'offset': offset,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (orderBy != null) 'order_by': orderBy,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1082,7 +1109,7 @@ class MusicAssistantAPI {
|
||||
for (final item in providerRoot) {
|
||||
final itemMap = item as Map<String, dynamic>;
|
||||
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 != '..') {
|
||||
discoveredLibraries.add({'path': path, 'name': name});
|
||||
}
|
||||
@@ -1101,7 +1128,7 @@ class MusicAssistantAPI {
|
||||
for (final libraryItem in providerRoot) {
|
||||
final libraryMap = libraryItem as Map<String, dynamic>;
|
||||
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
|
||||
if (libraryPath == 'root' || libraryName == '..') continue;
|
||||
@@ -1121,7 +1148,7 @@ class MusicAssistantAPI {
|
||||
// Log library contents to find series
|
||||
for (final item in libraryContents) {
|
||||
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? ?? '';
|
||||
_logger.log('📚 Library item: name=$name, path=$path');
|
||||
}
|
||||
@@ -1129,10 +1156,11 @@ class MusicAssistantAPI {
|
||||
// Look for "Series" folder in the library
|
||||
for (final item in libraryContents) {
|
||||
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? ?? '';
|
||||
|
||||
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');
|
||||
|
||||
// Browse the series folder
|
||||
@@ -1141,13 +1169,15 @@ class MusicAssistantAPI {
|
||||
|
||||
for (final seriesItem in seriesItems) {
|
||||
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
|
||||
if (seriesMap['path'] == 'root' || seriesMap['name'] == '..') continue;
|
||||
if (seriesPath == 'root' || seriesName == '..' || seriesPath.endsWith('://')) continue;
|
||||
// Skip actual audiobook items (we only want series folders)
|
||||
final mediaType = seriesMap['media_type'] as String?;
|
||||
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));
|
||||
}
|
||||
break; // Found series in this library, move to next library
|
||||
@@ -1335,13 +1365,14 @@ class MusicAssistantAPI {
|
||||
}
|
||||
|
||||
/// Get random albums
|
||||
Future<List<Album>> getRandomAlbums({int limit = 10}) async {
|
||||
Future<List<Album>> getRandomAlbums({int limit = 10, String? provider}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/albums/library_items',
|
||||
args: {
|
||||
'limit': limit,
|
||||
'order_by': 'random',
|
||||
if (provider != null) 'provider': provider,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1457,6 +1488,7 @@ class MusicAssistantAPI {
|
||||
int? offset,
|
||||
String? search,
|
||||
bool? favoriteOnly,
|
||||
String? orderBy,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
@@ -1466,6 +1498,7 @@ class MusicAssistantAPI {
|
||||
if (offset != null) 'offset': offset,
|
||||
if (search != null) 'search': search,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (orderBy != null) 'order_by': orderBy,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1487,7 +1520,7 @@ class MusicAssistantAPI {
|
||||
final response = await _sendCommand(
|
||||
'music/playlist',
|
||||
args: {
|
||||
'provider': provider,
|
||||
'provider_instance_id_or_domain': provider,
|
||||
'item_id': itemId,
|
||||
},
|
||||
);
|
||||
@@ -2453,6 +2486,109 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete an item from the queue
|
||||
Future<void> queueCommandDeleteItem(String queueId, String itemId) async {
|
||||
try {
|
||||
await _sendCommand(
|
||||
'player_queues/delete_item',
|
||||
args: {
|
||||
'queue_id': queueId,
|
||||
'item_id_or_index': itemId,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Error deleting queue item: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move an item in the queue by a relative position shift
|
||||
/// 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 {
|
||||
await _sendCommand(
|
||||
'player_queues/move_item',
|
||||
args: {
|
||||
'queue_id': queueId,
|
||||
'queue_item_id': itemId,
|
||||
'pos_shift': posShift,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Error moving queue item: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
// ============================================================================
|
||||
@@ -2496,6 +2632,23 @@ class MusicAssistantAPI {
|
||||
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
|
||||
/// CRITICAL: This creates a player config in MA's settings.json
|
||||
/// The server expects: player_id and player_name
|
||||
@@ -3146,10 +3299,22 @@ class MusicAssistantAPI {
|
||||
_logger.log('⚠️ Could not load provider manifests: $e');
|
||||
}
|
||||
|
||||
// Fetch provider instances (active providers)
|
||||
// Fetch provider instances (active providers) and extract music providers
|
||||
try {
|
||||
await _sendCommand('providers');
|
||||
_logger.log('✓ Provider instances loaded');
|
||||
final response = await _sendCommand('providers');
|
||||
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) {
|
||||
_logger.log('⚠️ Could not load provider instances: $e');
|
||||
}
|
||||
|
||||
@@ -20,7 +20,17 @@ class SecureStorageService {
|
||||
|
||||
// Auth Token (for stream requests)
|
||||
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 {
|
||||
@@ -33,7 +43,17 @@ class SecureStorageService {
|
||||
|
||||
// Music Assistant Native Auth Token (long-lived token)
|
||||
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 {
|
||||
@@ -50,7 +70,18 @@ class SecureStorageService {
|
||||
|
||||
// Auth Credentials (serialized auth strategy credentials)
|
||||
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;
|
||||
try {
|
||||
return jsonDecode(json) as Map<String, dynamic>;
|
||||
@@ -69,7 +100,17 @@ class SecureStorageService {
|
||||
|
||||
// Password
|
||||
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 {
|
||||
@@ -82,7 +123,17 @@ class SecureStorageService {
|
||||
|
||||
// Audiobookshelf API Token
|
||||
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 {
|
||||
@@ -98,12 +149,21 @@ class SecureStorageService {
|
||||
await _storage.deleteAll();
|
||||
}
|
||||
|
||||
// Migration completed flag key
|
||||
static const String _keyMigrationCompleted = 'secure_storage_migration_completed';
|
||||
|
||||
/// Migrate credentials from SharedPreferences to secure storage.
|
||||
/// 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 {
|
||||
// Import SharedPreferences for migration
|
||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Skip if migration already completed
|
||||
if (prefs.getBool(_keyMigrationCompleted) == true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Migrate auth token
|
||||
final authToken = prefs.getString('auth_token');
|
||||
if (authToken != null && authToken.isNotEmpty) {
|
||||
@@ -144,5 +204,8 @@ class SecureStorageService {
|
||||
await setAbsApiToken(absApiToken);
|
||||
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
|
||||
/// 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) {
|
||||
_logger.log('🔄 Sync already in progress, skipping');
|
||||
return;
|
||||
@@ -149,16 +150,17 @@ class SyncService with ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
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
|
||||
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)
|
||||
// If provider is specified, filter to only that provider's library items
|
||||
final results = await Future.wait([
|
||||
api.getAlbums(limit: 1000),
|
||||
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums),
|
||||
api.getAlbums(limit: 1000, provider: provider),
|
||||
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums, provider: provider),
|
||||
api.getAudiobooks(limit: 1000),
|
||||
api.getPlaylists(limit: 1000),
|
||||
]);
|
||||
@@ -273,11 +275,12 @@ class SyncService with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Force a fresh sync (for pull-to-refresh)
|
||||
Future<void> forceSync(MusicAssistantAPI api) async {
|
||||
await syncFromApi(api, force: true);
|
||||
Future<void> forceSync(MusicAssistantAPI api, {String? provider}) async {
|
||||
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 {
|
||||
if (!_db.isInitialized) return;
|
||||
|
||||
@@ -287,9 +290,10 @@ class SyncService with ChangeNotifier {
|
||||
_cachedAudiobooks = [];
|
||||
_cachedPlaylists = [];
|
||||
_lastSyncTime = null;
|
||||
_isSyncing = false; // Reset so a new sync can start
|
||||
_status = SyncStatus.idle;
|
||||
notifyListeners();
|
||||
_logger.log('🗑️ Library cache cleared');
|
||||
_logger.log('🗑️ Library cache cleared (sync state reset)');
|
||||
}
|
||||
|
||||
/// 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 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
|
||||
/// 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 {
|
||||
if (imageUrl == null || imageUrl.isEmpty) return;
|
||||
|
||||
@@ -16,7 +17,7 @@ Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUr
|
||||
final colorSchemes = await PaletteHelper.extractColorSchemes(CachedNetworkImageProvider(imageUrl));
|
||||
if (colorSchemes != null && context.mounted) {
|
||||
final themeProvider = context.read<ThemeProvider>();
|
||||
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2);
|
||||
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently fail - colors will update when track plays
|
||||
@@ -42,6 +43,10 @@ class ThemeProvider extends ChangeNotifier {
|
||||
ColorScheme? _adaptiveLightScheme;
|
||||
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() {
|
||||
_loadSettings();
|
||||
}
|
||||
@@ -55,12 +60,14 @@ class ThemeProvider extends ChangeNotifier {
|
||||
AdaptiveColors? get adaptiveColors => _adaptiveColors;
|
||||
ColorScheme? get adaptiveLightScheme => _adaptiveLightScheme;
|
||||
ColorScheme? get adaptiveDarkScheme => _adaptiveDarkScheme;
|
||||
bool get isOnDetailScreen => _isOnDetailScreen;
|
||||
|
||||
/// Get the current adaptive primary color (for bottom nav highlight, etc.)
|
||||
Color get adaptivePrimaryColor => _adaptiveColors?.primary ?? _customColor;
|
||||
|
||||
/// Get the current adaptive surface color (for bottom nav background, etc.)
|
||||
/// Returns a subtle tinted surface based on the adaptive colors
|
||||
/// NOTE: Prefer getAdaptiveSurfaceColorFor() which respects light/dark mode
|
||||
Color? get adaptiveSurfaceColor {
|
||||
if (_adaptiveColors == null) return null;
|
||||
// Use the miniPlayer color but darkened for a subtle tinted background
|
||||
@@ -68,6 +75,20 @@ class ThemeProvider extends ChangeNotifier {
|
||||
return hsl.withLightness((hsl.lightness * 0.4).clamp(0.08, 0.15)).toColor();
|
||||
}
|
||||
|
||||
/// Get adaptive surface color for the given brightness (light/dark mode aware)
|
||||
/// Returns the same color as the expanded player background (scheme.surface)
|
||||
Color? getAdaptiveSurfaceColorFor(Brightness brightness) {
|
||||
// Pick scheme based on current mode
|
||||
final scheme = brightness == Brightness.dark
|
||||
? _adaptiveDarkScheme
|
||||
: _adaptiveLightScheme;
|
||||
|
||||
if (scheme == null) return null;
|
||||
|
||||
// Use surface - same as expanded player background (line 1234 in expandable_player.dart)
|
||||
return scheme.surface;
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final themeModeString = await SettingsService.getThemeMode();
|
||||
_themeMode = _parseThemeMode(themeModeString);
|
||||
@@ -147,10 +168,18 @@ class ThemeProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// 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;
|
||||
_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
|
||||
if (darkScheme != null) {
|
||||
_adaptiveColors = AdaptiveColors(
|
||||
@@ -166,11 +195,12 @@ class ThemeProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear adaptive colors (when no track is playing)
|
||||
/// Clear adaptive colors (when navigating back from detail screen or no track playing)
|
||||
void clearAdaptiveColors() {
|
||||
_adaptiveColors = null;
|
||||
_adaptiveLightScheme = null;
|
||||
_adaptiveDarkScheme = null;
|
||||
_isOnDetailScreen = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'dart:math';
|
||||
|
||||
/// Fuzzy string matching using Jaro-Winkler similarity.
|
||||
///
|
||||
/// Jaro-Winkler is optimized for short strings like artist/song names
|
||||
/// and gives extra weight to matching prefixes (common typos occur mid-word).
|
||||
class FuzzyMatcher {
|
||||
/// Calculate Jaro-Winkler similarity between two strings.
|
||||
///
|
||||
/// Returns a value between 0.0 (no similarity) and 1.0 (exact match).
|
||||
///
|
||||
/// Jaro-Winkler gives bonus weight to strings that match from the beginning,
|
||||
/// which is useful for handling typos like "Beetles" vs "Beatles".
|
||||
double jaroWinklerSimilarity(String s1, String s2) {
|
||||
if (s1 == s2) return 1.0;
|
||||
if (s1.isEmpty || s2.isEmpty) return 0.0;
|
||||
|
||||
final jaro = _jaroSimilarity(s1, s2);
|
||||
|
||||
// Winkler modification: boost score for common prefix
|
||||
int prefixLength = 0;
|
||||
const maxPrefix = 4; // Standard Winkler prefix length
|
||||
final minLen = min(s1.length, s2.length);
|
||||
final prefixLimit = min(maxPrefix, minLen);
|
||||
|
||||
for (int i = 0; i < prefixLimit; i++) {
|
||||
if (s1[i] == s2[i]) {
|
||||
prefixLength++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const scalingFactor = 0.1; // Standard Winkler scaling factor
|
||||
return jaro + (prefixLength * scalingFactor * (1 - jaro));
|
||||
}
|
||||
|
||||
/// Calculate base Jaro similarity.
|
||||
double _jaroSimilarity(String s1, String s2) {
|
||||
final s1Len = s1.length;
|
||||
final s2Len = s2.length;
|
||||
|
||||
// Match window: characters within this distance can match
|
||||
final matchWindow = (max(s1Len, s2Len) / 2 - 1).floor();
|
||||
if (matchWindow < 0) return 0.0;
|
||||
|
||||
final s1Matches = List.filled(s1Len, false);
|
||||
final s2Matches = List.filled(s2Len, false);
|
||||
|
||||
int matches = 0;
|
||||
int transpositions = 0;
|
||||
|
||||
// Find matching characters
|
||||
for (int i = 0; i < s1Len; i++) {
|
||||
final start = max(0, i - matchWindow);
|
||||
final end = min(s2Len, i + matchWindow + 1);
|
||||
|
||||
for (int j = start; j < end; j++) {
|
||||
if (s2Matches[j] || s1[i] != s2[j]) continue;
|
||||
s1Matches[i] = true;
|
||||
s2Matches[j] = true;
|
||||
matches++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches == 0) return 0.0;
|
||||
|
||||
// Count transpositions
|
||||
int k = 0;
|
||||
for (int i = 0; i < s1Len; i++) {
|
||||
if (!s1Matches[i]) continue;
|
||||
while (!s2Matches[k]) {
|
||||
k++;
|
||||
}
|
||||
if (s1[i] != s2[k]) transpositions++;
|
||||
k++;
|
||||
}
|
||||
|
||||
// Jaro similarity formula
|
||||
return (matches / s1Len +
|
||||
matches / s2Len +
|
||||
(matches - transpositions / 2) / matches) /
|
||||
3;
|
||||
}
|
||||
|
||||
/// Find the best match score between query tokens and text tokens.
|
||||
///
|
||||
/// Useful for matching individual words when full string matching fails.
|
||||
/// Returns the highest similarity score found between any token pair.
|
||||
double bestTokenMatch(List<String> queryTokens, List<String> textTokens) {
|
||||
if (queryTokens.isEmpty || textTokens.isEmpty) return 0.0;
|
||||
|
||||
double bestScore = 0.0;
|
||||
|
||||
for (final queryToken in queryTokens) {
|
||||
for (final textToken in textTokens) {
|
||||
final score = jaroWinklerSimilarity(queryToken, textToken);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
// Early exit if we find a perfect match
|
||||
if (score == 1.0) return 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestScore;
|
||||
}
|
||||
|
||||
/// Check if two strings are a fuzzy match within a threshold.
|
||||
bool isFuzzyMatch(String s1, String s2, {double threshold = 0.85}) {
|
||||
return jaroWinklerSimilarity(s1, s2) >= threshold;
|
||||
}
|
||||
|
||||
/// Calculate average token match score.
|
||||
///
|
||||
/// For each query token, finds the best matching text token,
|
||||
/// then averages all best matches. Useful for multi-word queries.
|
||||
double averageTokenMatch(List<String> queryTokens, List<String> textTokens) {
|
||||
if (queryTokens.isEmpty || textTokens.isEmpty) return 0.0;
|
||||
|
||||
double totalScore = 0.0;
|
||||
|
||||
for (final queryToken in queryTokens) {
|
||||
double bestForToken = 0.0;
|
||||
for (final textToken in textTokens) {
|
||||
final score = jaroWinklerSimilarity(queryToken, textToken);
|
||||
if (score > bestForToken) {
|
||||
bestForToken = score;
|
||||
}
|
||||
}
|
||||
totalScore += bestForToken;
|
||||
}
|
||||
|
||||
return totalScore / queryTokens.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/// N-gram based string matching for partial matches.
|
||||
///
|
||||
/// Uses character bigrams (2-grams) to find partial string matches
|
||||
/// when exact and fuzzy matching fail.
|
||||
class NgramMatcher {
|
||||
/// Generate character bigrams from text.
|
||||
///
|
||||
/// Example: "hello" -> ["he", "el", "ll", "lo"]
|
||||
///
|
||||
/// For strings shorter than 2 characters, returns the string itself.
|
||||
List<String> generateBigrams(String text) {
|
||||
if (text.length < 2) return text.isNotEmpty ? [text] : [];
|
||||
|
||||
final bigrams = <String>[];
|
||||
for (int i = 0; i < text.length - 1; i++) {
|
||||
bigrams.add(text.substring(i, i + 2));
|
||||
}
|
||||
return bigrams;
|
||||
}
|
||||
|
||||
/// Generate character trigrams from text.
|
||||
///
|
||||
/// Example: "hello" -> ["hel", "ell", "llo"]
|
||||
List<String> generateTrigrams(String text) {
|
||||
if (text.length < 3) return text.isNotEmpty ? [text] : [];
|
||||
|
||||
final trigrams = <String>[];
|
||||
for (int i = 0; i < text.length - 2; i++) {
|
||||
trigrams.add(text.substring(i, i + 3));
|
||||
}
|
||||
return trigrams;
|
||||
}
|
||||
|
||||
/// Calculate bigram similarity using Dice coefficient.
|
||||
///
|
||||
/// Dice coefficient: 2 * |intersection| / (|set1| + |set2|)
|
||||
/// Returns a value between 0.0 and 1.0.
|
||||
double bigramSimilarity(String s1, String s2) {
|
||||
final lower1 = s1.toLowerCase();
|
||||
final lower2 = s2.toLowerCase();
|
||||
|
||||
final bigrams1 = generateBigrams(lower1);
|
||||
final bigrams2 = generateBigrams(lower2);
|
||||
|
||||
if (bigrams1.isEmpty || bigrams2.isEmpty) return 0.0;
|
||||
|
||||
final set1 = bigrams1.toSet();
|
||||
final set2 = bigrams2.toSet();
|
||||
|
||||
final intersection = set1.intersection(set2).length;
|
||||
|
||||
// Dice coefficient
|
||||
return (2 * intersection) / (set1.length + set2.length);
|
||||
}
|
||||
|
||||
/// Calculate Jaccard similarity between bigram sets.
|
||||
///
|
||||
/// Jaccard: |intersection| / |union|
|
||||
/// More conservative than Dice coefficient.
|
||||
double jaccardSimilarity(String s1, String s2) {
|
||||
final lower1 = s1.toLowerCase();
|
||||
final lower2 = s2.toLowerCase();
|
||||
|
||||
final bigrams1 = generateBigrams(lower1).toSet();
|
||||
final bigrams2 = generateBigrams(lower2).toSet();
|
||||
|
||||
if (bigrams1.isEmpty || bigrams2.isEmpty) return 0.0;
|
||||
|
||||
final intersection = bigrams1.intersection(bigrams2).length;
|
||||
final union = bigrams1.union(bigrams2).length;
|
||||
|
||||
return intersection / union;
|
||||
}
|
||||
|
||||
/// Check if query partially matches text via bigrams.
|
||||
///
|
||||
/// Uses Dice coefficient with configurable threshold.
|
||||
bool hasPartialMatch(String query, String text, {double threshold = 0.5}) {
|
||||
return bigramSimilarity(query, text) >= threshold;
|
||||
}
|
||||
|
||||
/// Calculate containment coefficient.
|
||||
///
|
||||
/// Measures how many of the query's bigrams are in the text.
|
||||
/// Useful when query is expected to be shorter than text.
|
||||
/// Returns |intersection| / |query bigrams|
|
||||
double containmentScore(String query, String text) {
|
||||
final queryBigrams = generateBigrams(query.toLowerCase()).toSet();
|
||||
final textBigrams = generateBigrams(text.toLowerCase()).toSet();
|
||||
|
||||
if (queryBigrams.isEmpty) return 0.0;
|
||||
|
||||
final intersection = queryBigrams.intersection(textBigrams).length;
|
||||
return intersection / queryBigrams.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/// Configuration for search scoring weights and thresholds.
|
||||
///
|
||||
/// All scores are designed to work with the existing sorting system
|
||||
/// where higher scores indicate better matches.
|
||||
class ScoringConfig {
|
||||
// Base scores for match types (primary name matching)
|
||||
final double exactMatch;
|
||||
final double exactMatchNoStopwords;
|
||||
final double startsWithMatch;
|
||||
final double startsWithNoStopwords;
|
||||
final double wordBoundaryMatch;
|
||||
final double wordBoundaryNoStopwords;
|
||||
final double reverseContainsMatch;
|
||||
final double reverseContainsNoStopwords;
|
||||
final double containsMatch;
|
||||
final double containsNoStopwords;
|
||||
final double fuzzyMatchHigh;
|
||||
final double fuzzyMatchMedium;
|
||||
final double ngramMatch;
|
||||
final double baseline;
|
||||
|
||||
// Bonuses (additive)
|
||||
final double libraryBonus;
|
||||
final double favoriteBonus;
|
||||
final double artistFieldExactBonus;
|
||||
final double artistFieldPartialBonus;
|
||||
final double albumFieldBonus;
|
||||
final double authorFieldExactBonus;
|
||||
final double authorFieldPartialBonus;
|
||||
final double narratorFieldBonus;
|
||||
final double creatorFieldExactBonus;
|
||||
final double creatorFieldPartialBonus;
|
||||
final double descriptionBonus;
|
||||
|
||||
// Thresholds
|
||||
final double fuzzyHighThreshold;
|
||||
final double fuzzyMediumThreshold;
|
||||
final double ngramThreshold;
|
||||
|
||||
// Minimum lengths for matching
|
||||
final int minReverseMatchLength;
|
||||
final int minTokenLength;
|
||||
|
||||
const ScoringConfig({
|
||||
// Primary match scores
|
||||
this.exactMatch = 100,
|
||||
this.exactMatchNoStopwords = 95,
|
||||
this.startsWithMatch = 85,
|
||||
this.startsWithNoStopwords = 80,
|
||||
this.wordBoundaryMatch = 70,
|
||||
this.wordBoundaryNoStopwords = 65,
|
||||
this.reverseContainsMatch = 60,
|
||||
this.reverseContainsNoStopwords = 55,
|
||||
this.containsMatch = 50,
|
||||
this.containsNoStopwords = 45,
|
||||
this.fuzzyMatchHigh = 40,
|
||||
this.fuzzyMatchMedium = 35,
|
||||
this.ngramMatch = 25,
|
||||
this.baseline = 20,
|
||||
|
||||
// Bonuses
|
||||
this.libraryBonus = 10,
|
||||
this.favoriteBonus = 5,
|
||||
this.artistFieldExactBonus = 15,
|
||||
this.artistFieldPartialBonus = 8,
|
||||
this.albumFieldBonus = 5,
|
||||
this.authorFieldExactBonus = 15,
|
||||
this.authorFieldPartialBonus = 8,
|
||||
this.narratorFieldBonus = 5,
|
||||
this.creatorFieldExactBonus = 15,
|
||||
this.creatorFieldPartialBonus = 8,
|
||||
this.descriptionBonus = 5,
|
||||
|
||||
// Thresholds
|
||||
this.fuzzyHighThreshold = 0.90,
|
||||
this.fuzzyMediumThreshold = 0.85,
|
||||
this.ngramThreshold = 0.5,
|
||||
|
||||
// Minimum lengths
|
||||
this.minReverseMatchLength = 3,
|
||||
this.minTokenLength = 3,
|
||||
});
|
||||
|
||||
/// Default configuration
|
||||
static const ScoringConfig defaults = ScoringConfig();
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/// Search scoring system for ranking search results.
|
||||
///
|
||||
/// Provides advanced text matching including:
|
||||
/// - Stopword removal ("the Ramones" finds "Ramones")
|
||||
/// - Fuzzy matching (typo tolerance)
|
||||
/// - N-gram matching (partial matches)
|
||||
/// - Reverse matching (query contains result)
|
||||
library;
|
||||
|
||||
export 'scoring_config.dart';
|
||||
export 'text_normalizer.dart';
|
||||
export 'fuzzy_matcher.dart';
|
||||
export 'ngram_matcher.dart';
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import '../../models/media_item.dart';
|
||||
import 'scoring_config.dart';
|
||||
import 'text_normalizer.dart';
|
||||
import 'fuzzy_matcher.dart';
|
||||
import 'ngram_matcher.dart';
|
||||
|
||||
/// Main search scorer that combines all matching strategies.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final scorer = SearchScorer();
|
||||
/// final score = scorer.scoreItem(mediaItem, query);
|
||||
/// ```
|
||||
class SearchScorer {
|
||||
final TextNormalizer _normalizer;
|
||||
final FuzzyMatcher _fuzzyMatcher;
|
||||
final NgramMatcher _ngramMatcher;
|
||||
final ScoringConfig _config;
|
||||
|
||||
// Cache for normalized query (reused across items in same search)
|
||||
String? _cachedQueryString;
|
||||
NormalizedQuery? _cachedQuery;
|
||||
|
||||
SearchScorer({ScoringConfig? config})
|
||||
: _normalizer = TextNormalizer(),
|
||||
_fuzzyMatcher = FuzzyMatcher(),
|
||||
_ngramMatcher = NgramMatcher(),
|
||||
_config = config ?? ScoringConfig.defaults;
|
||||
|
||||
/// Access to the normalizer for external use
|
||||
TextNormalizer get normalizer => _normalizer;
|
||||
|
||||
/// Clear the query cache (call when starting a new search)
|
||||
void clearCache() {
|
||||
_cachedQueryString = null;
|
||||
_cachedQuery = null;
|
||||
}
|
||||
|
||||
/// Score a media item against a search query.
|
||||
///
|
||||
/// Returns a score where higher values indicate better matches.
|
||||
/// Score components:
|
||||
/// - Primary name matching (0-100)
|
||||
/// - Secondary field bonuses (0-20)
|
||||
/// - Library/favorite bonuses (0-15)
|
||||
double scoreItem(MediaItem item, String query) {
|
||||
// Use cached normalized query if available
|
||||
if (_cachedQueryString != query) {
|
||||
_cachedQueryString = query;
|
||||
_cachedQuery = _normalizer.normalizeQuery(query);
|
||||
}
|
||||
|
||||
final nq = _cachedQuery!;
|
||||
if (nq.isEmpty) return 0;
|
||||
|
||||
final nameLower = item.name.toLowerCase();
|
||||
final nameNoStopwords = _normalizer.normalizeTextNoStopwords(item.name);
|
||||
|
||||
// Calculate primary score based on name matching
|
||||
double score = _calculatePrimaryScore(nameLower, nameNoStopwords, nq);
|
||||
|
||||
// Add secondary field bonuses based on media type
|
||||
score += _calculateSecondaryScore(item, nq);
|
||||
|
||||
// Add library/favorite bonuses
|
||||
score += _calculateBonuses(item);
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/// Calculate primary score based on name matching.
|
||||
double _calculatePrimaryScore(
|
||||
String nameLower,
|
||||
String nameNoStopwords,
|
||||
NormalizedQuery nq,
|
||||
) {
|
||||
// Tier 1: Exact match (highest priority)
|
||||
if (nameLower == nq.normalized) {
|
||||
return _config.exactMatch;
|
||||
}
|
||||
if (nameNoStopwords == nq.withoutStopwords) {
|
||||
return _config.exactMatchNoStopwords;
|
||||
}
|
||||
|
||||
// Tier 2: Starts with
|
||||
if (nameLower.startsWith(nq.normalized)) {
|
||||
return _config.startsWithMatch;
|
||||
}
|
||||
if (nameNoStopwords.startsWith(nq.withoutStopwords)) {
|
||||
return _config.startsWithNoStopwords;
|
||||
}
|
||||
|
||||
// Tier 3: Word boundary match
|
||||
if (_matchesWordBoundary(nameLower, nq.normalized)) {
|
||||
return _config.wordBoundaryMatch;
|
||||
}
|
||||
if (_matchesWordBoundary(nameNoStopwords, nq.withoutStopwords)) {
|
||||
return _config.wordBoundaryNoStopwords;
|
||||
}
|
||||
|
||||
// Tier 4: Reverse contains (result name IN query)
|
||||
// This solves "the ramones" finding "Ramones"
|
||||
if (_reverseContains(nq.normalized, nameLower)) {
|
||||
return _config.reverseContainsMatch;
|
||||
}
|
||||
if (_reverseContains(nq.withoutStopwords, nameNoStopwords)) {
|
||||
return _config.reverseContainsNoStopwords;
|
||||
}
|
||||
|
||||
// Tier 5: Contains anywhere
|
||||
if (nameLower.contains(nq.normalized)) {
|
||||
return _config.containsMatch;
|
||||
}
|
||||
if (nameNoStopwords.contains(nq.withoutStopwords)) {
|
||||
return _config.containsNoStopwords;
|
||||
}
|
||||
|
||||
// Tier 6: Fuzzy matching (typo tolerance)
|
||||
final fuzzyScore = _fuzzyMatcher.jaroWinklerSimilarity(
|
||||
nq.withoutStopwords,
|
||||
nameNoStopwords,
|
||||
);
|
||||
if (fuzzyScore >= _config.fuzzyHighThreshold) {
|
||||
// Scale score based on similarity (0.90-1.0 -> 40-45)
|
||||
return _config.fuzzyMatchHigh +
|
||||
((fuzzyScore - _config.fuzzyHighThreshold) * 50);
|
||||
}
|
||||
if (fuzzyScore >= _config.fuzzyMediumThreshold) {
|
||||
return _config.fuzzyMatchMedium;
|
||||
}
|
||||
|
||||
// Tier 7: Token-level fuzzy (individual words)
|
||||
final tokenFuzzy = _fuzzyMatcher.bestTokenMatch(
|
||||
nq.tokensNoStop,
|
||||
_normalizer.tokenize(nameNoStopwords),
|
||||
);
|
||||
if (tokenFuzzy >= _config.fuzzyHighThreshold) {
|
||||
return _config.fuzzyMatchMedium;
|
||||
}
|
||||
|
||||
// Tier 8: N-gram partial matching
|
||||
final ngramScore = _ngramMatcher.bigramSimilarity(
|
||||
nq.withoutStopwords,
|
||||
nameNoStopwords,
|
||||
);
|
||||
if (ngramScore >= _config.ngramThreshold) {
|
||||
// Scale: 0.5-1.0 -> 25-35
|
||||
return _config.ngramMatch + (ngramScore * 10);
|
||||
}
|
||||
|
||||
// Baseline: Music Assistant API returned it, so some relevance
|
||||
return _config.baseline;
|
||||
}
|
||||
|
||||
/// Check if query matches at word boundary in text.
|
||||
bool _matchesWordBoundary(String text, String query) {
|
||||
if (query.contains(' ')) {
|
||||
// Multi-word query: check if at start or after space
|
||||
if (text.startsWith(query)) return true;
|
||||
if (text.contains(' $query')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Single-word query: check if any word starts with query
|
||||
final words = text.split(RegExp(r'\s+'));
|
||||
for (final word in words) {
|
||||
if (word.startsWith(query)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Check if text is contained within query (reverse matching).
|
||||
///
|
||||
/// Solves: "the ramones" query finding "Ramones" artist
|
||||
bool _reverseContains(String query, String text) {
|
||||
if (text.length < _config.minReverseMatchLength) return false;
|
||||
|
||||
// Direct containment
|
||||
if (query.contains(text)) return true;
|
||||
|
||||
// Token-level: any query token equals text
|
||||
final queryTokens = query.split(RegExp(r'\s+'));
|
||||
for (final token in queryTokens) {
|
||||
if (token.length >= _config.minReverseMatchLength && text == token) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Calculate secondary field bonuses based on media type.
|
||||
double _calculateSecondaryScore(MediaItem item, NormalizedQuery nq) {
|
||||
double bonus = 0;
|
||||
|
||||
if (item is Album) {
|
||||
bonus += _scoreArtistField(item.artistsString, nq);
|
||||
} else if (item is Track) {
|
||||
bonus += _scoreArtistField(item.artistsString, nq);
|
||||
// Also check album name
|
||||
if (item.album?.name != null) {
|
||||
final albumLower = item.album!.name.toLowerCase();
|
||||
if (albumLower.contains(nq.withoutStopwords)) {
|
||||
bonus += _config.albumFieldBonus;
|
||||
}
|
||||
}
|
||||
} else if (item is Audiobook) {
|
||||
// Check authors
|
||||
final authorLower = item.authorsString.toLowerCase();
|
||||
if (authorLower == nq.withoutStopwords) {
|
||||
bonus += _config.authorFieldExactBonus;
|
||||
} else if (authorLower.contains(nq.withoutStopwords)) {
|
||||
bonus += _config.authorFieldPartialBonus;
|
||||
}
|
||||
// Check narrators
|
||||
final narratorLower = item.narratorsString.toLowerCase();
|
||||
if (narratorLower.contains(nq.withoutStopwords)) {
|
||||
bonus += _config.narratorFieldBonus;
|
||||
}
|
||||
} else if (item.mediaType == MediaType.podcast || item.mediaType == MediaType.podcastEpisode) {
|
||||
bonus += _scorePodcastFields(item, nq);
|
||||
}
|
||||
|
||||
return bonus;
|
||||
}
|
||||
|
||||
/// Score artist field for albums and tracks.
|
||||
double _scoreArtistField(String artistsString, NormalizedQuery nq) {
|
||||
final artistLower = artistsString.toLowerCase();
|
||||
if (artistLower == nq.withoutStopwords) {
|
||||
return _config.artistFieldExactBonus;
|
||||
}
|
||||
if (artistLower.contains(nq.withoutStopwords)) {
|
||||
return _config.artistFieldPartialBonus;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Score podcast-specific fields (creator, description).
|
||||
double _scorePodcastFields(MediaItem item, NormalizedQuery nq) {
|
||||
double bonus = 0;
|
||||
final metadata = item.metadata;
|
||||
|
||||
if (metadata != null) {
|
||||
// Check creator fields
|
||||
final creatorFields = [
|
||||
metadata['author'] as String?,
|
||||
metadata['publisher'] as String?,
|
||||
metadata['owner'] as String?,
|
||||
metadata['creator'] as String?,
|
||||
].where((s) => s != null && s.isNotEmpty).map((s) => s!.toLowerCase());
|
||||
|
||||
bool foundExact = false;
|
||||
bool foundContains = false;
|
||||
for (final field in creatorFields) {
|
||||
if (field == nq.withoutStopwords) {
|
||||
foundExact = true;
|
||||
break;
|
||||
} else if (field.contains(nq.withoutStopwords)) {
|
||||
foundContains = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundExact) {
|
||||
bonus += _config.creatorFieldExactBonus;
|
||||
} else if (foundContains) {
|
||||
bonus += _config.creatorFieldPartialBonus;
|
||||
}
|
||||
|
||||
// Check description
|
||||
final description =
|
||||
(metadata['description'] as String? ?? '').toLowerCase();
|
||||
if (description.contains(nq.withoutStopwords)) {
|
||||
bonus += _config.descriptionBonus;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: podcast name prominence check
|
||||
if (bonus == 0) {
|
||||
final nameLower = item.name.toLowerCase();
|
||||
if (nameLower.contains(nq.withoutStopwords)) {
|
||||
if (nq.withoutStopwords.contains(' ')) {
|
||||
// Multi-word query prominence
|
||||
final prominence = nq.withoutStopwords.length / nameLower.length;
|
||||
if (prominence >= 0.5) {
|
||||
bonus += _config.creatorFieldExactBonus;
|
||||
} else if (prominence >= 0.3) {
|
||||
bonus += _config.creatorFieldPartialBonus + 4;
|
||||
} else {
|
||||
bonus += _config.creatorFieldPartialBonus;
|
||||
}
|
||||
} else {
|
||||
bonus += _config.descriptionBonus;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bonus;
|
||||
}
|
||||
|
||||
/// Calculate library and favorite bonuses.
|
||||
double _calculateBonuses(MediaItem item) {
|
||||
double bonus = 0;
|
||||
|
||||
// Library bonus (Album has inLibrary property)
|
||||
if (item is Album && item.inLibrary) {
|
||||
bonus += _config.libraryBonus;
|
||||
}
|
||||
|
||||
// Favorite bonus
|
||||
if (item.favorite == true) {
|
||||
bonus += _config.favoriteBonus;
|
||||
}
|
||||
|
||||
return bonus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/// Text normalization utilities for search scoring.
|
||||
///
|
||||
/// Handles query normalization, stopword removal, and tokenization.
|
||||
|
||||
/// Represents a normalized search query with various forms for matching.
|
||||
class NormalizedQuery {
|
||||
/// Original query as entered by user
|
||||
final String original;
|
||||
|
||||
/// Lowercased and trimmed query
|
||||
final String normalized;
|
||||
|
||||
/// Query with stopwords removed
|
||||
final String withoutStopwords;
|
||||
|
||||
/// Individual tokens from normalized query
|
||||
final List<String> tokens;
|
||||
|
||||
/// Tokens with stopwords removed
|
||||
final List<String> tokensNoStop;
|
||||
|
||||
const NormalizedQuery({
|
||||
required this.original,
|
||||
required this.normalized,
|
||||
required this.withoutStopwords,
|
||||
required this.tokens,
|
||||
required this.tokensNoStop,
|
||||
});
|
||||
|
||||
/// Whether the query is empty after normalization
|
||||
bool get isEmpty => normalized.isEmpty;
|
||||
|
||||
/// Whether stopword removal changed the query
|
||||
bool get hasStopwordsRemoved => normalized != withoutStopwords;
|
||||
}
|
||||
|
||||
/// Normalizes text and queries for search matching.
|
||||
class TextNormalizer {
|
||||
/// Common English stopwords relevant to music/media searches.
|
||||
///
|
||||
/// Intentionally minimal to avoid over-filtering:
|
||||
/// - Articles that commonly prefix artist/album names
|
||||
/// - Common prepositions that add noise
|
||||
static const Set<String> _stopwords = {
|
||||
// Articles
|
||||
'the',
|
||||
'a',
|
||||
'an',
|
||||
// Common prepositions in music/media context
|
||||
'of',
|
||||
'and',
|
||||
'in',
|
||||
'on',
|
||||
'at',
|
||||
'to',
|
||||
'for',
|
||||
'with',
|
||||
'by',
|
||||
// Common noise words
|
||||
'is',
|
||||
'are',
|
||||
'was',
|
||||
'be',
|
||||
};
|
||||
|
||||
/// Normalize a search query into multiple forms for matching.
|
||||
///
|
||||
/// Returns a [NormalizedQuery] containing:
|
||||
/// - Original query
|
||||
/// - Lowercased/trimmed version
|
||||
/// - Version with stopwords removed
|
||||
/// - Tokenized versions
|
||||
NormalizedQuery normalizeQuery(String query) {
|
||||
final normalized = query.toLowerCase().trim();
|
||||
final tokens = tokenize(normalized);
|
||||
final tokensNoStop = tokens.where((t) => !_stopwords.contains(t)).toList();
|
||||
|
||||
// If all tokens were stopwords, preserve original tokens
|
||||
// e.g., "The The" (band name) shouldn't become empty
|
||||
final effectiveTokensNoStop =
|
||||
tokensNoStop.isEmpty ? tokens : tokensNoStop;
|
||||
final withoutStopwords = effectiveTokensNoStop.join(' ');
|
||||
|
||||
return NormalizedQuery(
|
||||
original: query,
|
||||
normalized: normalized,
|
||||
withoutStopwords: withoutStopwords,
|
||||
tokens: tokens,
|
||||
tokensNoStop: effectiveTokensNoStop,
|
||||
);
|
||||
}
|
||||
|
||||
/// Normalize text for comparison (lowercase, trim).
|
||||
String normalizeText(String text) {
|
||||
return text.toLowerCase().trim();
|
||||
}
|
||||
|
||||
/// Normalize text with stopwords removed.
|
||||
String normalizeTextNoStopwords(String text) {
|
||||
final tokens = tokenize(text.toLowerCase().trim());
|
||||
final filtered = tokens.where((t) => !_stopwords.contains(t)).toList();
|
||||
return filtered.isEmpty ? text.toLowerCase().trim() : filtered.join(' ');
|
||||
}
|
||||
|
||||
/// Split text into tokens on whitespace.
|
||||
List<String> tokenize(String text) {
|
||||
return text.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
/// Check if a word is a stopword.
|
||||
bool isStopword(String word) {
|
||||
return _stopwords.contains(word.toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../screens/album_details_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
@@ -36,6 +37,7 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
bool _maImageFailed = false;
|
||||
String? _cachedMaImageUrl;
|
||||
Timer? _fallbackTimer;
|
||||
bool _isNavigating = false;
|
||||
|
||||
/// Delay before fetching fallback images to avoid requests during fast scroll
|
||||
static const _fallbackDelay = Duration(milliseconds: 200);
|
||||
@@ -117,6 +119,10 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
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(
|
||||
@@ -128,7 +134,12 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
).then((_) {
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -38,6 +38,12 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
||||
@override
|
||||
void 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();
|
||||
}
|
||||
|
||||
@@ -45,18 +51,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// 1. Try to get cached data synchronously for instant display
|
||||
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)
|
||||
// Load fresh data (always update - fresh data may have images that cached data lacks)
|
||||
try {
|
||||
final freshAlbums = await widget.loadAlbums();
|
||||
if (mounted && freshAlbums.isNotEmpty) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../screens/artist_details_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
@@ -38,6 +39,7 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
bool _maImageFailed = false;
|
||||
String? _cachedMaImageUrl;
|
||||
Timer? _fallbackTimer;
|
||||
bool _isNavigating = false;
|
||||
|
||||
/// Delay before fetching fallback images to avoid requests during fast scroll
|
||||
static const _fallbackDelay = Duration(milliseconds: 200);
|
||||
@@ -110,6 +112,10 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
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(
|
||||
@@ -121,7 +127,12 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
).then((_) {
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
|
||||
@@ -38,6 +38,12 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
|
||||
@override
|
||||
void 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();
|
||||
}
|
||||
|
||||
@@ -45,18 +51,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// 1. Try to get cached data synchronously for instant display
|
||||
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)
|
||||
// Load fresh data (always update - fresh data may have images that cached data lacks)
|
||||
try {
|
||||
final freshArtists = await widget.loadArtists();
|
||||
if (mounted && freshArtists.isNotEmpty) {
|
||||
|
||||
@@ -14,6 +14,8 @@ class AudiobookRow extends StatefulWidget {
|
||||
final Future<List<Audiobook>> Function() loadAudiobooks;
|
||||
final String? heroTagSuffix;
|
||||
final double? rowHeight;
|
||||
/// Optional: synchronous getter for cached data (for instant display)
|
||||
final List<Audiobook>? Function()? getCachedAudiobooks;
|
||||
|
||||
const AudiobookRow({
|
||||
super.key,
|
||||
@@ -21,6 +23,7 @@ class AudiobookRow extends StatefulWidget {
|
||||
required this.loadAudiobooks,
|
||||
this.heroTagSuffix,
|
||||
this.rowHeight,
|
||||
this.getCachedAudiobooks,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -28,8 +31,8 @@ class AudiobookRow extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClientMixin {
|
||||
late Future<List<Audiobook>> _audiobooksFuture;
|
||||
List<Audiobook>? _cachedAudiobooks;
|
||||
List<Audiobook> _audiobooks = [];
|
||||
bool _isLoading = true;
|
||||
bool _hasLoaded = false;
|
||||
bool _hasPrecachedAudiobooks = false;
|
||||
|
||||
@@ -39,18 +42,36 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
|
||||
@override
|
||||
void 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() {
|
||||
if (!_hasLoaded) {
|
||||
_audiobooksFuture = widget.loadAudiobooks().then((audiobooks) {
|
||||
_cachedAudiobooks = audiobooks;
|
||||
// Pre-cache images for smooth Hero animations
|
||||
_precacheAudiobookImages(audiobooks);
|
||||
return audiobooks;
|
||||
});
|
||||
_hasLoaded = true;
|
||||
Future<void> _loadAudiobooks() async {
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// Load fresh data (always update - fresh data may have updated progress)
|
||||
try {
|
||||
final freshAudiobooks = await widget.loadAudiobooks();
|
||||
if (mounted && freshAudiobooks.isNotEmpty) {
|
||||
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();
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
_logger.startBuild('AudiobookRow:${widget.title}');
|
||||
super.build(context);
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
@@ -109,62 +177,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FutureBuilder<List<Audiobook>>(
|
||||
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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: _buildContent(contentHeight, colorScheme, maProvider),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../providers/music_assistant_provider.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
|
||||
import 'expandable_player.dart';
|
||||
import 'player/mini_player_content.dart' show MiniPlayerLayout;
|
||||
import 'player/player_reveal_overlay.dart';
|
||||
@@ -67,13 +68,14 @@ class BottomSpacing {
|
||||
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 {
|
||||
final double progress;
|
||||
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.
|
||||
///
|
||||
@@ -95,10 +97,31 @@ class GlobalPlayerOverlay extends StatefulWidget {
|
||||
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
|
||||
static bool get isPlayerExpanded =>
|
||||
globalPlayerKey.currentState?.isExpanded ?? false;
|
||||
|
||||
/// Check if the queue panel is currently open (animation value > 0.5)
|
||||
static bool get isQueuePanelOpen =>
|
||||
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
|
||||
/// [withHaptic]: Set to false for Android back gesture (system provides haptic)
|
||||
static void closeQueuePanel({bool withHaptic = true}) {
|
||||
globalPlayerKey.currentState?.closeQueuePanel(withHaptic: withHaptic);
|
||||
}
|
||||
|
||||
/// Get the current expansion progress (0.0 to 1.0)
|
||||
static double get expansionProgress =>
|
||||
globalPlayerKey.currentState?.expansionProgress ?? 0.0;
|
||||
@@ -409,15 +432,17 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
// Watch provider to trigger rebuilds when connection state changes
|
||||
final provider = context.watch<MusicAssistantProvider>();
|
||||
// PERF: Use select() to only rebuild when connection state or player changes
|
||||
final (isConnected, hasSelectedPlayer) = context.select<MusicAssistantProvider, (bool, bool)>(
|
||||
(p) => (p.isConnected, p.selectedPlayer != null),
|
||||
);
|
||||
|
||||
// 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.
|
||||
final isFirstTimeUserReady = _settingsLoaded &&
|
||||
!_hasCompletedOnboarding &&
|
||||
provider.isConnected &&
|
||||
provider.selectedPlayer != null;
|
||||
isConnected &&
|
||||
hasSelectedPlayer;
|
||||
|
||||
// Show welcome backdrop if:
|
||||
// 1. Already in hint mode (_isHintModeActive), OR
|
||||
@@ -463,7 +488,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
// Hide when: not connected, no player selected, OR showing welcome screen
|
||||
// For first-time users, selectedPlayer becomes non-null at the same time
|
||||
// as shouldShowWelcomeBackdrop becomes true, preventing any gap.
|
||||
if (provider.isConnected && provider.selectedPlayer != null && !shouldShowWelcomeBackdrop)
|
||||
if (isConnected && hasSelectedPlayer && !shouldShowWelcomeBackdrop)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
@@ -473,74 +498,44 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
builder: (context, _) {
|
||||
return Consumer<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 navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
|
||||
|
||||
// Base background: use adaptive surface color if available, otherwise default surface
|
||||
final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null)
|
||||
? themeProvider.adaptiveSurfaceColor!
|
||||
: colorScheme.surface;
|
||||
|
||||
return ValueListenableBuilder<PlayerExpansionState>(
|
||||
valueListenable: playerExpansionNotifier,
|
||||
// Pass BottomNavigationBar as child to avoid rebuilding it every frame
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
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;
|
||||
builder: (context, expansionState, _) {
|
||||
// Nav bar color logic - only use adaptive colors when:
|
||||
// 1. Player is expanding/expanded, OR
|
||||
// 2. On a detail screen (isOnDetailScreen)
|
||||
// On home screen with collapsed player: always use default theme colors
|
||||
final bool useAdaptiveColors = themeProvider.adaptiveTheme &&
|
||||
(expansionState.progress > 0 || themeProvider.isOnDetailScreen);
|
||||
|
||||
// Nav bar background color
|
||||
final Color navBgColor;
|
||||
if (expansionState.progress > 0 && expansionState.backgroundColor != null) {
|
||||
// Player is expanding - blend from surface to player's adaptive color
|
||||
navBgColor = Color.lerp(colorScheme.surface, expansionState.backgroundColor, expansionState.progress)!;
|
||||
} else if (useAdaptiveColors) {
|
||||
// On a detail screen - use adaptive surface color for nav bar
|
||||
final adaptiveBg = themeProvider.getAdaptiveSurfaceColorFor(Theme.of(context).brightness);
|
||||
navBgColor = adaptiveBg ?? colorScheme.surface;
|
||||
} else {
|
||||
// Home screen with collapsed player - use default surface color
|
||||
navBgColor = colorScheme.surface;
|
||||
}
|
||||
|
||||
// Icon color - only use adaptive colors when appropriate
|
||||
final Color baseSourceColor = (useAdaptiveColors && themeProvider.adaptiveColors != null)
|
||||
? themeProvider.adaptiveColors!.primary
|
||||
: colorScheme.primary;
|
||||
|
||||
// Blend icon color with player's primary color during expansion
|
||||
Color sourceColor = baseSourceColor;
|
||||
if (themeProvider.adaptiveTheme && expansionState.progress > 0 && expansionState.primaryColor != null) {
|
||||
sourceColor = Color.lerp(baseSourceColor, expansionState.primaryColor!, expansionState.progress)!;
|
||||
}
|
||||
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
|
||||
|
||||
// Use AnimatedContainer for smoother transitions instead of rebuilding
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: navBgColor,
|
||||
@@ -554,7 +549,50 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
]
|
||||
: 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
|
||||
/// 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 {
|
||||
/// The scrollable child widget (ListView, GridView, etc.)
|
||||
final Widget child;
|
||||
@@ -9,16 +12,34 @@ class LetterScrollbar extends StatefulWidget {
|
||||
/// The scroll controller for the child
|
||||
final ScrollController controller;
|
||||
|
||||
/// List of items to extract letters from (must be sorted alphabetically)
|
||||
/// Each item's first character is used to determine the letter
|
||||
/// List of strings to extract letters from (should match visual sort order).
|
||||
/// Each item's first character is used to determine the letter shown in the popup.
|
||||
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;
|
||||
|
||||
/// Callback when drag state changes (for disabling scroll-to-hide)
|
||||
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({
|
||||
super.key,
|
||||
required this.child,
|
||||
@@ -26,6 +47,12 @@ class LetterScrollbar extends StatefulWidget {
|
||||
required this.items,
|
||||
this.onScrollToIndex,
|
||||
this.onDragStateChanged,
|
||||
this.itemExtent,
|
||||
this.crossAxisCount,
|
||||
this.childAspectRatio,
|
||||
this.mainAxisSpacing,
|
||||
this.horizontalPadding,
|
||||
this.bottomPadding = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -55,87 +82,54 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
final position = widget.controller.position;
|
||||
if (position.maxScrollExtent > 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) {
|
||||
setState(() {
|
||||
_scrollFraction = newFraction;
|
||||
_currentLetter = _getLetterAtFraction(newFraction);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map of letter -> first index for that letter
|
||||
Map<String, int> get _letterIndexMap {
|
||||
final map = <String, int>{};
|
||||
for (int i = 0; i < widget.items.length; i++) {
|
||||
final item = widget.items[i];
|
||||
if (item.isNotEmpty) {
|
||||
final letter = item[0].toUpperCase();
|
||||
if (!map.containsKey(letter)) {
|
||||
map[letter] = i;
|
||||
}
|
||||
}
|
||||
/// Normalizes a character to a letter for the scrollbar.
|
||||
String _normalizeToLetter(String char) {
|
||||
if (char.isEmpty) return '';
|
||||
final upper = char[0].toUpperCase();
|
||||
if (upper.codeUnitAt(0) >= 65 && upper.codeUnitAt(0) <= 90) {
|
||||
return upper;
|
||||
}
|
||||
return map;
|
||||
return '#';
|
||||
}
|
||||
|
||||
String _getLetterAtPosition(double position, double maxHeight) {
|
||||
if (widget.items.isEmpty || maxHeight <= 0) return '';
|
||||
/// Get the letter at a given scroll fraction (0.0 to 1.0)
|
||||
String _getLetterAtFraction(double fraction) {
|
||||
if (widget.items.isEmpty) return '';
|
||||
|
||||
// Calculate which item index corresponds to this position
|
||||
final fraction = (position / maxHeight).clamp(0.0, 1.0);
|
||||
final index = (fraction * (widget.items.length - 1)).round();
|
||||
|
||||
if (index >= 0 && index < widget.items.length) {
|
||||
final index = (fraction * (widget.items.length - 1)).round().clamp(0, widget.items.length - 1);
|
||||
if (index < widget.items.length) {
|
||||
final item = widget.items[index];
|
||||
if (item.isNotEmpty) {
|
||||
return item[0].toUpperCase();
|
||||
return _normalizeToLetter(item[0]);
|
||||
}
|
||||
}
|
||||
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) {
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
_dragPosition = details.localPosition.dy;
|
||||
});
|
||||
widget.onDragStateChanged?.call(true);
|
||||
_updateLetterAndScroll(details.localPosition.dy);
|
||||
_scrollToPosition(details.localPosition.dy);
|
||||
}
|
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) {
|
||||
setState(() {
|
||||
_dragPosition = details.localPosition.dy;
|
||||
});
|
||||
_updateLetterAndScroll(details.localPosition.dy);
|
||||
_scrollToPosition(details.localPosition.dy);
|
||||
}
|
||||
|
||||
void _handleDragEnd(DragEndDetails details) {
|
||||
@@ -145,18 +139,35 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
widget.onDragStateChanged?.call(false);
|
||||
}
|
||||
|
||||
void _updateLetterAndScroll(double position) {
|
||||
/// Scroll proportionally based on drag position
|
||||
void _scrollToPosition(double position) {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
if (renderBox != null) {
|
||||
final height = renderBox.size.height;
|
||||
final letter = _getLetterAtPosition(position, height);
|
||||
if (letter.isNotEmpty && letter != _currentLetter) {
|
||||
setState(() {
|
||||
_currentLetter = letter;
|
||||
});
|
||||
_scrollToLetter(letter);
|
||||
if (renderBox == null || !widget.controller.hasClients) return;
|
||||
|
||||
// Account for bottom padding in the effective track height
|
||||
final totalHeight = renderBox.size.height;
|
||||
final effectiveHeight = totalHeight - widget.bottomPadding;
|
||||
if (effectiveHeight <= 0) return;
|
||||
|
||||
// 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
|
||||
@@ -168,12 +179,12 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
// The scrollable content
|
||||
widget.child,
|
||||
|
||||
// The draggable scrollbar area (right edge) - wider grab area
|
||||
// The draggable scrollbar area (right edge)
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 32, // Wider grab area
|
||||
bottom: widget.bottomPadding,
|
||||
width: 32,
|
||||
child: GestureDetector(
|
||||
onVerticalDragStart: _handleDragStart,
|
||||
onVerticalDragUpdate: _handleDragUpdate,
|
||||
@@ -196,11 +207,11 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
final trackHeight = constraints.maxHeight;
|
||||
final thumbHeight = _isDragging ? 60.0 : 40.0;
|
||||
final availableTrack = trackHeight - thumbHeight;
|
||||
final thumbTop = availableTrack * _scrollFraction;
|
||||
final thumbTop = (availableTrack * _scrollFraction).clamp(0.0, availableTrack);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Thumb that tracks scroll position - use AnimatedPositioned for smooth motion
|
||||
// Thumb that tracks scroll position
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 50),
|
||||
curve: Curves.linear,
|
||||
@@ -229,7 +240,10 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
if (_isDragging && _currentLetter.isNotEmpty)
|
||||
Positioned(
|
||||
right: 48,
|
||||
top: _dragPosition - 28,
|
||||
top: (_dragPosition - 28).clamp(
|
||||
0.0,
|
||||
(context.findRenderObject() as RenderBox?)?.size.height ?? 500 - 56 - widget.bottomPadding,
|
||||
),
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
color: colorScheme.primaryContainer,
|
||||
|
||||
@@ -8,8 +8,8 @@ class MiniPlayerLayout {
|
||||
static const double textLeftOffset = 10.0; // Gap between art and text
|
||||
static const double textLeft = artSize + textLeftOffset; // 82px
|
||||
// 3-line layout: track, artist, player
|
||||
static const double primaryTop = 5.0; // raised slightly from 7
|
||||
static const double secondaryTop = 26.0; // raised by 1
|
||||
static const double primaryTop = 9.0;
|
||||
static const double secondaryTop = 26.0;
|
||||
static const double tertiaryTop = 46.0;
|
||||
// 2-line layout (evenly spaced): player name, hint
|
||||
// Height 72 / 3 = 24px spacing. Line 1 center at 24, Line 2 center at 48
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../../providers/music_assistant_provider.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../services/debug_logger.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
|
||||
final _volumeLogger = DebugLogger();
|
||||
|
||||
/// A player card that matches the mini player's visual style.
|
||||
/// Used in the player reveal overlay when swiping down or tapping the device button.
|
||||
@@ -23,10 +28,23 @@ class PlayerCard extends StatefulWidget {
|
||||
final VoidCallback? onSkipNext;
|
||||
final VoidCallback? onPower;
|
||||
final ValueChanged<double>? onVolumeChange;
|
||||
final ValueChanged<bool>? onPrecisionModeChanged;
|
||||
|
||||
// Pastel yellow for grouped players
|
||||
static const Color groupBorderColor = Color(0xFFFFF59D);
|
||||
|
||||
// Precision mode settings
|
||||
static const int precisionTriggerMs = 800; // Hold still for 800ms to enter precision mode
|
||||
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)
|
||||
|
||||
// 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({
|
||||
super.key,
|
||||
required this.player,
|
||||
@@ -43,6 +61,7 @@ class PlayerCard extends StatefulWidget {
|
||||
this.onSkipNext,
|
||||
this.onPower,
|
||||
this.onVolumeChange,
|
||||
this.onPrecisionModeChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -54,7 +73,70 @@ class _PlayerCardState extends State<PlayerCard> {
|
||||
double _dragVolumeLevel = 0.0;
|
||||
double _cardWidth = 0.0;
|
||||
int _lastVolumeUpdateTime = 0;
|
||||
int _lastDragEndTime = 0; // Track when last drag ended for consecutive swipes
|
||||
bool _hasLocalVolumeOverride = false; // True if we've set volume locally
|
||||
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
|
||||
|
||||
// Precision mode state
|
||||
bool _inPrecisionMode = false;
|
||||
Timer? _precisionTimer;
|
||||
Offset? _lastDragPosition;
|
||||
double _lastLocalX = 0.0; // Last local X position during drag (for precision mode)
|
||||
bool _precisionModeEnabled = true; // From settings
|
||||
double _precisionZoomCenter = 0.0; // Volume level when precision mode started
|
||||
double _precisionStartX = 0.0; // Finger X position when precision mode started
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPrecisionModeSetting();
|
||||
}
|
||||
|
||||
Future<void> _loadPrecisionModeSetting() async {
|
||||
final enabled = await SettingsService.getVolumePrecisionMode();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_precisionModeEnabled = enabled;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_precisionTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _enterPrecisionMode() {
|
||||
if (_inPrecisionMode) return;
|
||||
HapticFeedback.mediumImpact(); // Vibrate to indicate precision mode
|
||||
setState(() {
|
||||
_inPrecisionMode = true;
|
||||
_precisionZoomCenter = _dragVolumeLevel; // Capture current volume as zoom center
|
||||
_precisionStartX = _lastLocalX; // Capture finger position at entry
|
||||
});
|
||||
widget.onPrecisionModeChanged?.call(true);
|
||||
_volumeLogger.debug(
|
||||
'PRECISION_MODE [${widget.player.name}]: ENTERED at ${(_precisionZoomCenter * 100).round()}%',
|
||||
context: 'Volume',
|
||||
);
|
||||
}
|
||||
|
||||
void _exitPrecisionMode() {
|
||||
_precisionTimer?.cancel();
|
||||
_precisionTimer = null;
|
||||
if (!_inPrecisionMode) return;
|
||||
setState(() {
|
||||
_inPrecisionMode = false;
|
||||
});
|
||||
widget.onPrecisionModeChanged?.call(false);
|
||||
_volumeLogger.debug(
|
||||
'PRECISION_MODE [${widget.player.name}]: EXITED',
|
||||
context: 'Volume',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -67,7 +149,10 @@ class _PlayerCardState extends State<PlayerCard> {
|
||||
final filledColor = widget.backgroundColor;
|
||||
final unfilledColor = Color.lerp(widget.backgroundColor, Colors.black, 0.3)!;
|
||||
|
||||
return LayoutBuilder(
|
||||
// RepaintBoundary isolates this card's repaint from parent transform animations
|
||||
// This improves performance during the staggered reveal animation
|
||||
return RepaintBoundary(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
_cardWidth = constraints.maxWidth;
|
||||
|
||||
@@ -92,13 +177,8 @@ class _PlayerCardState extends State<PlayerCard> {
|
||||
decoration: BoxDecoration(
|
||||
color: unfilledColor,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
// PERF Phase 4: Use pre-cached static BoxShadow
|
||||
boxShadow: const [PlayerCard.cardShadow],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Align(
|
||||
@@ -133,6 +213,7 @@ class _PlayerCardState extends State<PlayerCard> {
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,13 +223,8 @@ class _PlayerCardState extends State<PlayerCard> {
|
||||
decoration: BoxDecoration(
|
||||
color: widget.backgroundColor,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
// PERF Phase 4: Use pre-cached static BoxShadow
|
||||
boxShadow: const [PlayerCard.cardShadow],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Row(
|
||||
@@ -282,25 +358,83 @@ class _PlayerCardState extends State<PlayerCard> {
|
||||
}
|
||||
|
||||
void _onDragStart(DragStartDetails details) {
|
||||
// Get current volume from player
|
||||
final currentVolume = (widget.player.volumeLevel ?? 0).toDouble() / 100.0;
|
||||
// For consecutive swipes, use local volume (API may not have updated player state yet)
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final timeSinceLastDrag = now - _lastDragEndTime;
|
||||
final isWithinWindow = timeSinceLastDrag < _consecutiveSwipeWindowMs;
|
||||
|
||||
// Use local volume if:
|
||||
// 1. We have a local override (we've swiped before) AND
|
||||
// 2. We're within the consecutive swipe window (5 seconds)
|
||||
// Otherwise, read fresh from player state
|
||||
final useLocalVolume = _hasLocalVolumeOverride && isWithinWindow;
|
||||
|
||||
final playerVolume = (widget.player.volumeLevel ?? 0).toDouble() / 100.0;
|
||||
final startVolume = useLocalVolume
|
||||
? _dragVolumeLevel // Continue from where last swipe ended
|
||||
: playerVolume; // Fresh from player
|
||||
|
||||
_volumeLogger.debug(
|
||||
'DRAG_START [${widget.player.name}]: '
|
||||
'useLocal=$useLocalVolume, hasOverride=$_hasLocalVolumeOverride, '
|
||||
'inWindow=$isWithinWindow (${timeSinceLastDrag}ms ago), '
|
||||
'localVol=${(_dragVolumeLevel * 100).round()}%, '
|
||||
'playerVol=${(playerVolume * 100).round()}%, '
|
||||
'startVol=${(startVolume * 100).round()}%',
|
||||
context: 'Volume',
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_isDraggingVolume = true;
|
||||
_dragVolumeLevel = currentVolume;
|
||||
_dragVolumeLevel = startVolume;
|
||||
});
|
||||
_lastDragPosition = details.globalPosition;
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails details) {
|
||||
if (!_isDraggingVolume || _cardWidth <= 0) return;
|
||||
|
||||
// Directional volume: drag right = increase, drag left = decrease
|
||||
// Distance determines amount of change (full card width = 100%)
|
||||
// Multiple swipes accumulate - each starts from current volume
|
||||
final dragDelta = details.delta.dx;
|
||||
final volumeDelta = dragDelta / _cardWidth;
|
||||
// Track local X position for precision mode
|
||||
_lastLocalX = details.localPosition.dx;
|
||||
|
||||
final newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
|
||||
// Check for stillness to trigger precision mode (only if enabled in settings)
|
||||
final currentPosition = details.globalPosition;
|
||||
if (_precisionModeEnabled && _lastDragPosition != null) {
|
||||
final movement = (currentPosition - _lastDragPosition!).distance;
|
||||
|
||||
if (movement < PlayerCard.precisionStillnessThreshold) {
|
||||
// Finger is still - start precision timer if not already running
|
||||
if (_precisionTimer == null && !_inPrecisionMode) {
|
||||
_precisionTimer = Timer(
|
||||
Duration(milliseconds: PlayerCard.precisionTriggerMs),
|
||||
_enterPrecisionMode,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Finger moved - cancel timer (but don't exit precision mode if already in it)
|
||||
_precisionTimer?.cancel();
|
||||
_precisionTimer = null;
|
||||
}
|
||||
}
|
||||
_lastDragPosition = currentPosition;
|
||||
|
||||
double newVolume;
|
||||
|
||||
if (_inPrecisionMode) {
|
||||
// PRECISION MODE: Movement from entry point maps to zoomed range
|
||||
// Full card width of movement = precisionSensitivity (10%) change
|
||||
// e.g., at 40% center: moving full card right = 50%, full card left = 30%
|
||||
final offsetX = details.localPosition.dx - _precisionStartX;
|
||||
final normalizedOffset = offsetX / _cardWidth; // -1.0 to +1.0 range
|
||||
final volumeChange = normalizedOffset * PlayerCard.precisionSensitivity;
|
||||
newVolume = (_precisionZoomCenter + volumeChange).clamp(0.0, 1.0);
|
||||
} else {
|
||||
// NORMAL MODE: Delta-based movement (full card width = 100%)
|
||||
final dragDelta = details.delta.dx;
|
||||
final volumeDelta = dragDelta / _cardWidth;
|
||||
newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
// Always update visual
|
||||
if ((newVolume - _dragVolumeLevel).abs() > 0.001) {
|
||||
@@ -308,9 +442,10 @@ class _PlayerCardState extends State<PlayerCard> {
|
||||
_dragVolumeLevel = newVolume;
|
||||
});
|
||||
|
||||
// Throttle API calls to prevent flooding
|
||||
// Throttle API calls to prevent flooding (faster in precision mode)
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
if (now - _lastVolumeUpdateTime >= _volumeThrottleMs) {
|
||||
final throttleMs = _inPrecisionMode ? _precisionThrottleMs : _volumeThrottleMs;
|
||||
if (now - _lastVolumeUpdateTime >= throttleMs) {
|
||||
_lastVolumeUpdateTime = now;
|
||||
widget.onVolumeChange?.call(newVolume);
|
||||
}
|
||||
@@ -319,7 +454,17 @@ class _PlayerCardState extends State<PlayerCard> {
|
||||
|
||||
void _onDragEnd(DragEndDetails details) {
|
||||
// Send final volume on release
|
||||
_volumeLogger.debug(
|
||||
'DRAG_END [${widget.player.name}]: '
|
||||
'finalVol=${(_dragVolumeLevel * 100).round()}%, '
|
||||
'precisionMode=$_inPrecisionMode',
|
||||
context: 'Volume',
|
||||
);
|
||||
widget.onVolumeChange?.call(_dragVolumeLevel);
|
||||
_lastDragEndTime = DateTime.now().millisecondsSinceEpoch; // Track for consecutive swipes
|
||||
_hasLocalVolumeOverride = true; // Mark that we have a local volume value
|
||||
_exitPrecisionMode();
|
||||
_lastDragPosition = null;
|
||||
setState(() {
|
||||
_isDraggingVolume = false;
|
||||
});
|
||||
@@ -327,6 +472,8 @@ class _PlayerCardState extends State<PlayerCard> {
|
||||
}
|
||||
|
||||
void _onDragCancel() {
|
||||
_exitPrecisionMode();
|
||||
_lastDragPosition = null;
|
||||
setState(() {
|
||||
_isDraggingVolume = false;
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import '../../l10n/app_localizations.dart';
|
||||
import '../../providers/music_assistant_provider.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../../theme/theme_provider.dart';
|
||||
import '../../theme/palette_helper.dart';
|
||||
import '../../constants/timings.dart';
|
||||
import '../global_player_overlay.dart';
|
||||
@@ -41,9 +40,11 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
Timer? _refreshTimer;
|
||||
|
||||
// Track vertical drag for dismissal
|
||||
double _dragOffset = 0;
|
||||
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
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _isScrollable = false;
|
||||
@@ -54,6 +55,13 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
// Hint system
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -147,13 +155,24 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
// Trigger single bounce on mini player when cards are ~70% collapsed
|
||||
// This creates the effect of cards "landing" on the mini player
|
||||
bool bounceFired = false;
|
||||
void checkBounce() {
|
||||
if (!bounceFired && _revealController.value < 0.3) {
|
||||
void fireBounce() {
|
||||
if (!bounceFired) {
|
||||
bounceFired = true;
|
||||
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.addListener(checkBounce);
|
||||
_revealController.reverse().then((_) {
|
||||
@@ -194,11 +213,17 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
// Ignore swipe up when not scrollable - cards should stay fixed
|
||||
if (delta <= 0) return;
|
||||
|
||||
// Swiping down = dismiss gesture
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
_dragOffset += delta;
|
||||
});
|
||||
// Swiping down = dismiss gesture - directly control animation controller
|
||||
_isDragging = true;
|
||||
|
||||
// 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) {
|
||||
@@ -206,31 +231,27 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
|
||||
// If not dragging (was scrolling), don't dismiss
|
||||
if (!_isDragging) return;
|
||||
_isDragging = false;
|
||||
|
||||
// Dismiss if dragged down enough or with enough velocity
|
||||
if (_dragOffset > 100 || velocity > 500) {
|
||||
// Dismiss if dragged past threshold or with enough velocity
|
||||
if (_revealController.value < _dismissThreshold || velocity > 500) {
|
||||
HapticFeedback.lightImpact();
|
||||
// Reset drag offset before dismiss so animation starts from correct position
|
||||
setState(() {
|
||||
_isDragging = false;
|
||||
_dragOffset = 0;
|
||||
});
|
||||
dismiss();
|
||||
} else {
|
||||
// Spring back
|
||||
setState(() {
|
||||
_isDragging = false;
|
||||
_dragOffset = 0;
|
||||
});
|
||||
// Spring back to fully revealed
|
||||
_revealController.duration = const Duration(milliseconds: 150);
|
||||
_revealController.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeProvider = context.watch<ThemeProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
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
|
||||
return Consumer<MusicAssistantProvider>(
|
||||
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(
|
||||
animation: _revealAnimation,
|
||||
builder: (context, child) {
|
||||
@@ -287,7 +333,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
Positioned(
|
||||
left: 12,
|
||||
right: 12,
|
||||
bottom: widget.miniPlayerBottom + widget.miniPlayerHeight + 8 - _dragOffset,
|
||||
bottom: widget.miniPlayerBottom + widget.miniPlayerHeight + 8,
|
||||
child: GestureDetector(
|
||||
onVerticalDragUpdate: _handleVerticalDragUpdate,
|
||||
onVerticalDragEnd: _handleVerticalDragEnd,
|
||||
@@ -308,6 +354,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
// Show different hint for onboarding vs regular use
|
||||
final isOnboarding = widget.showOnboardingHints;
|
||||
|
||||
// PERF Phase 4: Use pre-computed hintColor
|
||||
Widget buildHintRow(IconData icon, String text) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -315,13 +362,13 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
color: hintColor,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
color: hintColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -354,31 +401,23 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
// Build player cards - scrollable when overflow, otherwise static
|
||||
// Using ListView.builder for many players: only builds visible items
|
||||
// This significantly improves animation performance with 10+ players
|
||||
// PERF Phase 4: Use pre-computed playerDataList
|
||||
if (needsScroll)
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxListHeight),
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
itemCount: players.length,
|
||||
itemCount: playerDataList.length,
|
||||
// Fixed height per item for optimal scroll performance
|
||||
itemExtent: cardHeight + cardSpacing,
|
||||
itemBuilder: (context, index) {
|
||||
final player = players[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;
|
||||
final data = playerDataList[index];
|
||||
|
||||
// Animation: slide from behind mini player
|
||||
// Only calculate for items that will be built (visible ones)
|
||||
const baseOffset = 80.0;
|
||||
final reverseIndex = players.length - 1 - index;
|
||||
final reverseIndex = playerDataList.length - 1 - index;
|
||||
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
|
||||
final slideOffset = distanceToTravel * (1.0 - t);
|
||||
|
||||
@@ -387,33 +426,33 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: cardSpacing),
|
||||
child: PlayerCard(
|
||||
player: player,
|
||||
trackInfo: playerTrack,
|
||||
albumArtUrl: albumArtUrl,
|
||||
player: data.player,
|
||||
trackInfo: data.playerTrack,
|
||||
albumArtUrl: data.albumArtUrl,
|
||||
isSelected: false,
|
||||
isPlaying: isPlaying,
|
||||
isGrouped: maProvider.isPlayerManuallySynced(player.playerId),
|
||||
backgroundColor: cardBgColor,
|
||||
textColor: cardTextColor,
|
||||
isPlaying: data.isPlaying,
|
||||
isGrouped: data.isGrouped,
|
||||
backgroundColor: data.cardBgColor,
|
||||
textColor: data.cardTextColor,
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
maProvider.selectPlayer(player);
|
||||
maProvider.selectPlayer(data.player);
|
||||
dismiss();
|
||||
},
|
||||
onLongPress: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
maProvider.togglePlayerSync(player.playerId);
|
||||
maProvider.togglePlayerSync(data.player.playerId);
|
||||
},
|
||||
onPlayPause: () {
|
||||
if (isPlaying) {
|
||||
maProvider.pausePlayer(player.playerId);
|
||||
if (data.isPlaying) {
|
||||
maProvider.pausePlayer(data.player.playerId);
|
||||
} else {
|
||||
maProvider.resumePlayer(player.playerId);
|
||||
maProvider.resumePlayer(data.player.playerId);
|
||||
}
|
||||
},
|
||||
onSkipNext: () => maProvider.nextTrack(player.playerId),
|
||||
onPower: () => maProvider.togglePower(player.playerId),
|
||||
onVolumeChange: (volume) => maProvider.setVolume(player.playerId, (volume * 100).round()),
|
||||
onSkipNext: () => maProvider.nextTrack(data.player.playerId),
|
||||
onPower: () => maProvider.togglePower(data.player.playerId),
|
||||
onVolumeChange: (volume) => maProvider.setVolume(data.player.playerId, (volume * 100).round()),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -422,28 +461,14 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
)
|
||||
else
|
||||
// Non-scrollable list for when players fit
|
||||
...List.generate(players.length, (index) {
|
||||
final player = players[index];
|
||||
final isPlaying = player.state == 'playing';
|
||||
|
||||
// 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;
|
||||
// PERF Phase 4: Use pre-computed playerDataList
|
||||
...List.generate(playerDataList.length, (index) {
|
||||
final data = playerDataList[index];
|
||||
|
||||
// Animation: all cards start hidden behind mini player
|
||||
// and fan out to their final positions with spring physics
|
||||
const baseOffset = 80.0;
|
||||
final reverseIndex = players.length - 1 - index;
|
||||
final reverseIndex = playerDataList.length - 1 - index;
|
||||
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
|
||||
|
||||
// Use the elastic animation value directly (already has bounce)
|
||||
@@ -454,44 +479,33 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: PlayerCard(
|
||||
player: player,
|
||||
trackInfo: playerTrack,
|
||||
albumArtUrl: albumArtUrl,
|
||||
player: data.player,
|
||||
trackInfo: data.playerTrack,
|
||||
albumArtUrl: data.albumArtUrl,
|
||||
isSelected: false,
|
||||
isPlaying: isPlaying,
|
||||
isGrouped: maProvider.isPlayerManuallySynced(player.playerId),
|
||||
backgroundColor: cardBgColor,
|
||||
textColor: cardTextColor,
|
||||
isPlaying: data.isPlaying,
|
||||
isGrouped: data.isGrouped,
|
||||
backgroundColor: data.cardBgColor,
|
||||
textColor: data.cardTextColor,
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
maProvider.selectPlayer(player);
|
||||
maProvider.selectPlayer(data.player);
|
||||
dismiss();
|
||||
},
|
||||
onLongPress: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Syncing ${player.name}...'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
maProvider.togglePlayerSync(player.playerId);
|
||||
maProvider.togglePlayerSync(data.player.playerId);
|
||||
},
|
||||
onPlayPause: () {
|
||||
if (isPlaying) {
|
||||
maProvider.pausePlayer(player.playerId);
|
||||
if (data.isPlaying) {
|
||||
maProvider.pausePlayer(data.player.playerId);
|
||||
} else {
|
||||
maProvider.resumePlayer(player.playerId);
|
||||
maProvider.resumePlayer(data.player.playerId);
|
||||
}
|
||||
},
|
||||
onSkipNext: () {
|
||||
maProvider.nextTrack(player.playerId);
|
||||
},
|
||||
onPower: () {
|
||||
maProvider.togglePower(player.playerId);
|
||||
},
|
||||
onVolumeChange: (volume) => maProvider.setVolume(player.playerId, (volume * 100).round()),
|
||||
onSkipNext: () => maProvider.nextTrack(data.player.playerId),
|
||||
onPower: () => maProvider.togglePower(data.player.playerId),
|
||||
onVolumeChange: (volume) => maProvider.setVolume(data.player.playerId, (volume * 100).round()),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -508,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,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true, // Required for overlays like QueuePanel
|
||||
builder: (context) => PlayerPickerSheet(
|
||||
title: title,
|
||||
players: players,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'dart:async';
|
||||
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 '../screens/playlist_details_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
|
||||
class PlaylistCard extends StatefulWidget {
|
||||
final Playlist playlist;
|
||||
final VoidCallback? onTap;
|
||||
final String? heroTagSuffix;
|
||||
final int? imageCacheSize;
|
||||
|
||||
const PlaylistCard({
|
||||
super.key,
|
||||
required this.playlist,
|
||||
this.onTap,
|
||||
this.heroTagSuffix,
|
||||
this.imageCacheSize,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PlaylistCard> createState() => _PlaylistCardState();
|
||||
}
|
||||
|
||||
class _PlaylistCardState extends State<PlaylistCard> {
|
||||
bool _isNavigating = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.api?.getImageUrl(widget.playlist, size: 256);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||
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: PlaylistDetailsScreen(
|
||||
playlist: widget.playlist,
|
||||
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: [
|
||||
// Playlist artwork
|
||||
AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Hero(
|
||||
tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + suffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
child: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
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(
|
||||
Icons.playlist_play_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Playlist title
|
||||
Hero(
|
||||
tag: HeroTags.playlistTitle + (widget.playlist.uri ?? widget.playlist.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
widget.playlist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Owner/track count
|
||||
Text(
|
||||
widget.playlist.owner ?? (widget.playlist.trackCount != null ? '${widget.playlist.trackCount} tracks' : ''),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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 'playlist_card.dart';
|
||||
|
||||
class PlaylistRow extends StatefulWidget {
|
||||
final String title;
|
||||
final Future<List<Playlist>> Function() loadPlaylists;
|
||||
final String? heroTagSuffix;
|
||||
final double? rowHeight;
|
||||
final List<Playlist>? Function()? getCachedPlaylists;
|
||||
|
||||
const PlaylistRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.loadPlaylists,
|
||||
this.heroTagSuffix,
|
||||
this.rowHeight,
|
||||
this.getCachedPlaylists,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PlaylistRow> createState() => _PlaylistRowState();
|
||||
}
|
||||
|
||||
class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClientMixin {
|
||||
List<Playlist> _playlists = [];
|
||||
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.getCachedPlaylists?.call();
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
_playlists = cached;
|
||||
_isLoading = false;
|
||||
}
|
||||
_loadPlaylists();
|
||||
}
|
||||
|
||||
Future<void> _loadPlaylists() async {
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// Load fresh data
|
||||
try {
|
||||
final freshPlaylists = await widget.loadPlaylists();
|
||||
if (mounted && freshPlaylists.isNotEmpty) {
|
||||
setState(() {
|
||||
_playlists = freshPlaylists;
|
||||
_isLoading = false;
|
||||
});
|
||||
// Pre-cache images for smooth hero animations
|
||||
_precachePlaylistImages(freshPlaylists);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure - keep showing cached data
|
||||
}
|
||||
|
||||
if (mounted && _isLoading) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _precachePlaylistImages(List<Playlist> playlists) {
|
||||
if (!mounted) return;
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
final playlistsToCache = playlists.take(10);
|
||||
|
||||
for (final playlist in playlistsToCache) {
|
||||
final imageUrl = maProvider.api?.getImageUrl(playlist, size: 256);
|
||||
if (imageUrl != null) {
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
context,
|
||||
).catchError((_) => false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
|
||||
// Only show loading if we have no data at all
|
||||
if (_playlists.isEmpty && _isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_playlists.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No playlists 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 owner = ~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: _playlists.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 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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 '../constants/hero_tags.dart';
|
||||
import '../constants/timings.dart';
|
||||
|
||||
class RadioStationCard extends StatefulWidget {
|
||||
final MediaItem radioStation;
|
||||
final VoidCallback? onTap;
|
||||
final String? heroTagSuffix;
|
||||
final int? imageCacheSize;
|
||||
|
||||
const RadioStationCard({
|
||||
super.key,
|
||||
required this.radioStation,
|
||||
this.onTap,
|
||||
this.heroTagSuffix,
|
||||
this.imageCacheSize,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RadioStationCard> createState() => _RadioStationCardState();
|
||||
}
|
||||
|
||||
class _RadioStationCardState extends State<RadioStationCard> {
|
||||
bool _isTapping = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.api?.getImageUrl(widget.radioStation, size: 256);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||
final cacheSize = widget.imageCacheSize ?? 256;
|
||||
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap ?? () {
|
||||
// Prevent double-tap actions
|
||||
if (_isTapping) return;
|
||||
_isTapping = true;
|
||||
|
||||
HapticFeedback.mediumImpact();
|
||||
// Play the radio station on selected player
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
if (selectedPlayer != null) {
|
||||
maProvider.api?.playRadioStation(selectedPlayer.playerId, widget.radioStation);
|
||||
}
|
||||
|
||||
// Reset after debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isTapping = false;
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Radio station artwork - circular for radio
|
||||
AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Hero(
|
||||
tag: HeroTags.radioCover + (widget.radioStation.uri ?? widget.radioStation.itemId) + suffix,
|
||||
child: ClipOval(
|
||||
child: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
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(
|
||||
Icons.radio_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.radio_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Radio station name - fixed height container so image size is consistent
|
||||
SizedBox(
|
||||
height: 36, // Fixed height for 2 lines of text
|
||||
child: Hero(
|
||||
tag: HeroTags.radioTitle + (widget.radioStation.uri ?? widget.radioStation.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
widget.radioStation.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
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 'radio_station_card.dart';
|
||||
|
||||
class RadioStationRow extends StatefulWidget {
|
||||
final String title;
|
||||
final Future<List<MediaItem>> Function() loadRadioStations;
|
||||
final String? heroTagSuffix;
|
||||
final double? rowHeight;
|
||||
final List<MediaItem>? Function()? getCachedRadioStations;
|
||||
|
||||
const RadioStationRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.loadRadioStations,
|
||||
this.heroTagSuffix,
|
||||
this.rowHeight,
|
||||
this.getCachedRadioStations,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RadioStationRow> createState() => _RadioStationRowState();
|
||||
}
|
||||
|
||||
class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAliveClientMixin {
|
||||
List<MediaItem> _radioStations = [];
|
||||
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.getCachedRadioStations?.call();
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
_radioStations = cached;
|
||||
_isLoading = false;
|
||||
}
|
||||
_loadRadioStations();
|
||||
}
|
||||
|
||||
Future<void> _loadRadioStations() async {
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// Load fresh data
|
||||
try {
|
||||
final freshStations = await widget.loadRadioStations();
|
||||
if (mounted && freshStations.isNotEmpty) {
|
||||
setState(() {
|
||||
_radioStations = freshStations;
|
||||
_isLoading = false;
|
||||
});
|
||||
// Pre-cache images for smooth hero animations
|
||||
_precacheRadioImages(freshStations);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure - keep showing cached data
|
||||
}
|
||||
|
||||
if (mounted && _isLoading) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _precacheRadioImages(List<MediaItem> stations) {
|
||||
if (!mounted) return;
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
final stationsToCache = stations.take(10);
|
||||
|
||||
for (final station in stationsToCache) {
|
||||
final imageUrl = maProvider.api?.getImageUrl(station, size: 256);
|
||||
if (imageUrl != null) {
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
context,
|
||||
).catchError((_) => false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
|
||||
// Only show loading if we have no data at all
|
||||
if (_radioStations.isEmpty && _isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_radioStations.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No radio stations found',
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Card layout: circle image + name below (same as ArtistRow)
|
||||
// Text area: 8px gap + ~36px for 2-line name = ~44px
|
||||
const textAreaHeight = 44.0;
|
||||
final imageSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = imageSize; // Card width = image width (circle)
|
||||
final itemExtent = cardWidth + 16; // width + horizontal margins
|
||||
|
||||
return ScrollConfiguration(
|
||||
behavior: const _StretchScrollBehavior(),
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: _radioStations.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 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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@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 Future<List<AudiobookSeries>> Function() loadSeries;
|
||||
final double? rowHeight;
|
||||
/// Optional: synchronous getter for cached data (for instant display)
|
||||
final List<AudiobookSeries>? Function()? getCachedSeries;
|
||||
|
||||
const SeriesRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.loadSeries,
|
||||
this.rowHeight,
|
||||
this.getCachedSeries,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -25,8 +28,8 @@ class SeriesRow extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixin {
|
||||
late Future<List<AudiobookSeries>> _seriesFuture;
|
||||
List<AudiobookSeries>? _cachedSeries;
|
||||
List<AudiobookSeries> _series = [];
|
||||
bool _isLoading = true;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
// Cache for series cover images
|
||||
@@ -43,16 +46,34 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
||||
@override
|
||||
void 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() {
|
||||
if (!_hasLoaded) {
|
||||
_seriesFuture = widget.loadSeries().then((series) {
|
||||
_cachedSeries = series;
|
||||
return series;
|
||||
});
|
||||
_hasLoaded = true;
|
||||
Future<void> _loadSeries() async {
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// Load fresh data (always update)
|
||||
try {
|
||||
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;
|
||||
_seriesBookCounts[seriesId] = books.length;
|
||||
});
|
||||
// Extract colors asynchronously
|
||||
_extractSeriesColors(seriesId, covers);
|
||||
// Precache images for smooth hero animations
|
||||
_precacheSeriesCovers(covers);
|
||||
// Extract colors asynchronously (delayed to avoid jank during scroll)
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) _extractSeriesColors(seriesId, covers);
|
||||
});
|
||||
}
|
||||
}
|
||||
} 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
|
||||
Future<void> _extractSeriesColors(String seriesId, List<String> covers) async {
|
||||
if (_seriesExtractedColors.containsKey(seriesId) || covers.isEmpty) return;
|
||||
@@ -127,10 +163,68 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
_logger.startBuild('SeriesRow:${widget.title}');
|
||||
super.build(context);
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
@@ -156,73 +250,7 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FutureBuilder<List<AudiobookSeries>>(
|
||||
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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: _buildContent(contentHeight, colorScheme, textTheme, maProvider),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -286,49 +314,14 @@ class _SeriesCard extends StatelessWidget {
|
||||
// Use AspectRatio to guarantee square cover
|
||||
Hero(
|
||||
tag: heroTag,
|
||||
// PERF: Use flightShuttleBuilder to avoid animating complex grid
|
||||
// during hero flight. Show cached first cover image instead.
|
||||
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
|
||||
// Use first cover as simple placeholder during flight
|
||||
final heroChild = toContext.widget as Hero;
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
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(),
|
||||
// RepaintBoundary caches the rendered grid for smooth animation
|
||||
child: RepaintBoundary(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12), // Match detail screen
|
||||
child: _buildCoverGrid(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -9,12 +9,15 @@ class TrackRow extends StatefulWidget {
|
||||
final String title;
|
||||
final Future<List<Track>> Function() loadTracks;
|
||||
final double? rowHeight;
|
||||
/// Optional: synchronous getter for cached data (for instant display)
|
||||
final List<Track>? Function()? getCachedTracks;
|
||||
|
||||
const TrackRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.loadTracks,
|
||||
this.rowHeight,
|
||||
this.getCachedTracks,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -22,8 +25,8 @@ class TrackRow extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin {
|
||||
late Future<List<Track>> _tracksFuture;
|
||||
List<Track>? _cachedTracks;
|
||||
List<Track> _tracks = [];
|
||||
bool _isLoading = true;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
@override
|
||||
@@ -32,25 +35,111 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
|
||||
@override
|
||||
void 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() {
|
||||
if (!_hasLoaded) {
|
||||
_tracksFuture = widget.loadTracks().then((tracks) {
|
||||
_cachedTracks = tracks;
|
||||
return tracks;
|
||||
});
|
||||
_hasLoaded = true;
|
||||
Future<void> _loadTracks() async {
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// Load fresh data (always update)
|
||||
try {
|
||||
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();
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
_logger.startBuild('TrackRow:${widget.title}');
|
||||
super.build(context);
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
@@ -76,64 +165,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FutureBuilder<List<Track>>(
|
||||
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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: _buildContent(contentHeight, colorScheme),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
@@ -11,8 +12,9 @@ import '../services/settings_service.dart';
|
||||
/// For MA players: controls player volume via API
|
||||
class VolumeControl extends StatefulWidget {
|
||||
final bool compact;
|
||||
final Color? accentColor;
|
||||
|
||||
const VolumeControl({super.key, this.compact = false});
|
||||
const VolumeControl({super.key, this.compact = false, this.accentColor});
|
||||
|
||||
@override
|
||||
State<VolumeControl> createState() => _VolumeControlState();
|
||||
@@ -25,11 +27,34 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
StreamSubscription? _volumeListener;
|
||||
String? _localPlayerId;
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initLocalPlayer();
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
Future<void> _initLocalPlayer() async {
|
||||
@@ -43,7 +68,7 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
}
|
||||
|
||||
_volumeListener = FlutterVolumeController.addListener((volume) {
|
||||
if (mounted && _isLocalPlayer) {
|
||||
if (mounted && _isLocalPlayer && !_isDragging) {
|
||||
setState(() {
|
||||
_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
|
||||
void dispose() {
|
||||
_volumeListener?.cancel();
|
||||
_precisionTimer?.cancel();
|
||||
_buttonIndicatorTimer?.cancel();
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
@@ -68,118 +180,262 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
|
||||
_isLocalPlayer = _localPlayerId != null && player.playerId == _localPlayerId;
|
||||
|
||||
final currentVolume = _isLocalPlayer
|
||||
? (_pendingVolume ?? (_systemVolume ?? 0.5))
|
||||
: (_pendingVolume ?? player.volume.toDouble()) / 100.0;
|
||||
final isMuted = _isLocalPlayer ? false : player.isMuted;
|
||||
// Use pending volume during drag or button tap to prevent stale state issues
|
||||
final currentVolume = (_isDragging || _showButtonIndicator)
|
||||
? (_pendingVolume ?? (_isLocalPlayer ? (_systemVolume ?? 0.5) : player.volume.toDouble() / 100.0))
|
||||
: _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) {
|
||||
return IconButton(
|
||||
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,
|
||||
),
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.setMute(player.playerId, !isMuted);
|
||||
} catch (e) {
|
||||
// Error already logged by provider
|
||||
}
|
||||
// In compact mode, toggle between 0 and 50%
|
||||
final newVolume = currentVolume < 0.01 ? 50 : 0;
|
||||
await _adjustVolume(maProvider, player.playerId, newVolume / 100.0, 0);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
// Volume decrease button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isMuted
|
||||
? Icons.volume_off
|
||||
: currentVolume < 0.3
|
||||
? Icons.volume_down
|
||||
: Icons.volume_up,
|
||||
icon: const Icon(
|
||||
Icons.volume_down,
|
||||
color: Colors.white70,
|
||||
),
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.setMute(player.playerId, !isMuted);
|
||||
} catch (e) {
|
||||
// Error already logged by provider
|
||||
}
|
||||
},
|
||||
onPressed: () => _adjustVolume(maProvider, player.playerId, currentVolume, -1),
|
||||
),
|
||||
// Slider with floating teardrop indicator
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 48, // Increase touch target height
|
||||
child: SliderTheme(
|
||||
data: SliderThemeData(
|
||||
trackHeight: 2,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
|
||||
activeTrackColor: Colors.white,
|
||||
inactiveTrackColor: Colors.white.withOpacity(0.3),
|
||||
thumbColor: Colors.white,
|
||||
overlayColor: Colors.white.withOpacity(0.2),
|
||||
),
|
||||
child: Slider(
|
||||
value: currentVolume.clamp(0.0, 1.0),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_pendingVolume = _isLocalPlayer ? value : value * 100;
|
||||
});
|
||||
height: 48,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final sliderWidth = constraints.maxWidth;
|
||||
// Calculate thumb position to match Flutter Slider's internal positioning
|
||||
// The Slider uses overlayRadius as padding on each side for the track
|
||||
const overlayRadius = 20.0; // Must match SliderThemeData overlayShape
|
||||
final thumbPosition = overlayRadius +
|
||||
(currentVolume.clamp(0.0, 1.0) * (sliderWidth - 2 * overlayRadius));
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// The slider with gesture detection for precision mode
|
||||
GestureDetector(
|
||||
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(
|
||||
width: 40,
|
||||
child: Text(
|
||||
'${(currentVolume * 100).round()}%',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontSize: 12,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
// Volume increase button
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.volume_up,
|
||||
color: Colors.white70,
|
||||
),
|
||||
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
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 2.8.2-beta+39
|
||||
version: 2.8.8-beta+46
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||